Skip to content

Commit a984953

Browse files
committed
feature: add a built-in SSH Key Helper (#2647)
Signed-off-by: leo <longshuang@msn.cn>
1 parent 2464fa4 commit a984953

11 files changed

Lines changed: 679 additions & 5 deletions

File tree

src/App.Commands.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,15 @@ public static bool IsCheckForUpdateCommandVisible
101101
if (OperatingSystem.IsMacOS())
102102
Native.MacOSUtilities.ShowAllApplications();
103103
});
104+
105+
public static readonly Command OpenSSHKeyHelperCommand = new Command(async _ =>
106+
{
107+
if (Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
108+
{
109+
var vm = new ViewModels.SSHKeyHelper();
110+
var dialog = new Views.SSHKeyHelper() { DataContext = vm };
111+
await dialog.ShowDialog(owner);
112+
}
113+
});
104114
}
105115
}

src/App.axaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
<NativeMenuItemSeparator/>
4747
<NativeMenuItem Header="{DynamicResource Text.Preferences}" Command="{x:Static s:App.OpenPreferencesCommand}" Gesture="⌘+,"/>
4848
<NativeMenuItem Header="{DynamicResource Text.OpenAppDataDir}" Command="{x:Static s:App.OpenAppDataDirCommand}"/>
49+
<NativeMenuItem Header="{DynamicResource Text.SSHKeyHelper}" Command="{x:Static s:App.OpenSSHKeyHelperCommand}"/>
4950
<NativeMenuItemSeparator/>
5051
<NativeMenuItem Header="{DynamicResource Text.App.Hide}" Command="{x:Static s:App.HideAppCommand}" Gesture="⌘+H"/>
5152
<NativeMenuItem Header="{DynamicResource Text.App.HideOthers}" Command="{x:Static s:App.HideOtherApplicationsCommand}" Gesture="⌘+Alt+H"/>

src/Models/SSHKeyPair.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using System;
2+
using System.IO;
3+
using System.Security.Cryptography;
4+
5+
namespace SourceGit.Models
6+
{
7+
public class SSHKeyPair
8+
{
9+
public string Name { get; set; }
10+
public string FullPath { get; set; }
11+
public string PublicKey { get; set; }
12+
public string Fingerprint { get; set; } = "--- (invalid)";
13+
14+
public SSHKeyPair(string file)
15+
{
16+
Name = Path.GetFileName(file);
17+
FullPath = file;
18+
PublicKey = File.ReadAllText($"{file}.pub");
19+
20+
var parts = PublicKey.Split(' ', StringSplitOptions.RemoveEmptyEntries);
21+
if (parts.Length != 3)
22+
return;
23+
24+
try
25+
{
26+
var blob = Convert.FromBase64String(parts[1]);
27+
Fingerprint = "SHA256:" + Convert.ToBase64String(SHA256.HashData(blob)).TrimEnd('=');
28+
}
29+
catch
30+
{
31+
// Ignore errors and keep the fingerprint as invalid
32+
}
33+
}
34+
}
35+
}

src/Resources/Icons.axaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@
143143
<StreamGeometry x:Key="Icons.Tags">M996 452 572 28A96 96 0 00504 0H96C43 0 0 43 0 96v408a96 96 0 0028 68l424 424c37 37 98 37 136 0l408-408c37-37 37-98 0-136zM224 320c-53 0-96-43-96-96s43-96 96-96 96 43 96 96-43 96-96 96zm1028 268L844 996c-37 37-98 37-136 0l-1-1L1055 647c34-34 53-79 53-127s-19-93-53-127L663 0h97a96 96 0 0168 28l424 424c37 37 37 98 0 136z</StreamGeometry>
144144
<StreamGeometry x:Key="Icons.Target">M765 118 629 239l-16 137-186 160 54 59 183-168 144 4 136-129 47-43-175-12L827 67zM489 404c-66 0-124 55-124 125s54 121 124 121c66 0 120-55 120-121H489l23-121c-8-4-16-4-23-4zM695 525c0 114-93 207-206 207s-206-94-206-207 93-207 206-207c16 0 27 0 43 4l43-207c-27-4-54-8-85-8-229 0-416 188-416 419s187 419 416 419c225 0 408-180 416-403v-12l-210-4z</StreamGeometry>
145145
<StreamGeometry x:Key="Icons.Terminal">M144 112h736c18 0 32 14 32 32v736c0 18-14 32-32 32H144c-18 0-32-14-32-32V144c0-18 14-32 32-32zm112 211v72a9 9 0 003 7L386 509 259 615a9 9 0 00-3 7v72a9 9 0 0015 7L493 516a9 9 0 000-14l-222-186a9 9 0 00-15 7zM522 624a10 10 0 00-10 10v60a10 10 0 0010 10h237a10 10 0 0010-10v-60a10 10 0 00-10-10H522z</StreamGeometry>
146+
<StreamGeometry x:Key="Icons.Tool">M929 201c-1-1-1-2-2-3-6-6-15-6-21 0L709 395l-78-78L828 120c1-1 2-2 2-3 4-7 2-16-5-20-112-66-259-51-356 45-77 77-102 185-76 283l-335 335c-3 3-3 9 0 13l194 194c4 4 9 4 13 0l335-335c98 26 206 1 283-76 96-97 112-244 45-356zM825 498c-66 66-166 79-245 38l-10 10-0-0-313 313-91-91 263-263s0 0 0 0l60-60c-41-79-28-179 38-245a211 211 0 01165-61L541 291a37 37 0 000 52l142 142a37 37 0 0052 0l152-152c4 59-16 120-61 164z</StreamGeometry>
146147
<StreamGeometry x:Key="Icons.Top">M170 831 513 489 855 831 960 726 512 278 64 726 170 831zM512 278h448v-128h-896v128h448z</StreamGeometry>
147148
<StreamGeometry x:Key="Icons.Track">M897 673v13c0 51-42 93-93 93h-10c-1 0-2 0-2 0H220c-23 0-42 19-42 42v13c0 23 19 42 42 42h552c14 0 26 12 26 26 0 14-12 26-26 26H220c-51 0-93-42-93-93v-13c0-51 42-93 93-93h20c1-0 2-0 2-0h562c23 0 42-19 42-42v-13c0-11-5-22-13-29-8-7-17-11-28-10H660c-14 0-26-12-26-26 0-14 12-26 26-26h144c24-1 47 7 65 24 18 17 29 42 29 67zM479 98c-112 0-203 91-203 203 0 44 14 85 38 118l132 208c15 24 50 24 66 0l133-209c23-33 37-73 37-117 0-112-91-203-203-203zm0 327c-68 0-122-55-122-122s55-122 122-122 122 55 122 122-55 122-122 122z</StreamGeometry>
148149
<StreamGeometry x:Key="Icons.Tree">M912 800a48 48 0 1 1 0 96h-416a48 48 0 1 1 0-96h416z m-704-704A112 112 0 0 1 256 309.184V480h80a48 48 0 0 1 0 96H256v224h81.664a48 48 0 1 1 0 96H256a96 96 0 0 1-96-96V309.248A112 112 0 0 1 208 96z m704 384a48 48 0 1 1 0 96h-416a48 48 0 0 1 0-96h416z m0-320a48 48 0 1 1 0 96h-416a48 48 0 0 1 0-96h416z</StreamGeometry>

src/Resources/Locales/en_US.axaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,21 @@
900900
<x:String x:Key="Text.SHALinkCM.NavigateTo" xml:space="preserve">Go to</x:String>
901901
<x:String x:Key="Text.SSHKey" xml:space="preserve">SSH Private Key:</x:String>
902902
<x:String x:Key="Text.SSHKey.Placeholder" xml:space="preserve">Private SSH key store path</x:String>
903+
<x:String x:Key="Text.SSHKeyHelper" xml:space="preserve">Open SSH Key Helper</x:String>
904+
<x:String x:Key="Text.SSHKeyHelper.ConfirmDeletion" xml:space="preserve">Do you really want to delete the following keys?</x:String>
905+
<x:String x:Key="Text.SSHKeyHelper.Fingerprint" xml:space="preserve">Fingerprint</x:String>
906+
<x:String x:Key="Text.SSHKeyHelper.Generator" xml:space="preserve">Generate SSH Key (ED25519)</x:String>
907+
<x:String x:Key="Text.SSHKeyHelper.Generator.ConfirmPassphrase" xml:space="preserve">Confirm Passphrase:</x:String>
908+
<x:String x:Key="Text.SSHKeyHelper.Generator.Email" xml:space="preserve">Email:</x:String>
909+
<x:String x:Key="Text.SSHKeyHelper.Generator.Email.Placeholder" xml:space="preserve">Required.</x:String>
910+
<x:String x:Key="Text.SSHKeyHelper.Generator.Name" xml:space="preserve">Key Name:</x:String>
911+
<x:String x:Key="Text.SSHKeyHelper.Generator.Name.Placeholder" xml:space="preserve">Required. For example: id_github</x:String>
912+
<x:String x:Key="Text.SSHKeyHelper.Generator.Passphrase" xml:space="preserve">Passphrase:</x:String>
913+
<x:String x:Key="Text.SSHKeyHelper.Generator.Start" xml:space="preserve">GENERATE</x:String>
914+
<x:String x:Key="Text.SSHKeyHelper.Generator.UsePassphrase" xml:space="preserve">Use passphrase for private key</x:String>
915+
<x:String x:Key="Text.SSHKeyHelper.Path" xml:space="preserve">Path</x:String>
916+
<x:String x:Key="Text.SSHKeyHelper.PublicKey" xml:space="preserve">Public Key</x:String>
917+
<x:String x:Key="Text.SSHKeyHelper.Title" xml:space="preserve">SSH Key Helper</x:String>
903918
<x:String x:Key="Text.Start" xml:space="preserve">START</x:String>
904919
<x:String x:Key="Text.Stash" xml:space="preserve">Stash</x:String>
905920
<x:String x:Key="Text.Stash.IncludeUntracked" xml:space="preserve">Include untracked files</x:String>

src/Resources/Locales/zh_CN.axaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -904,6 +904,21 @@
904904
<x:String x:Key="Text.SHALinkCM.NavigateTo" xml:space="preserve">跳转到提交</x:String>
905905
<x:String x:Key="Text.SSHKey" xml:space="preserve">SSH密钥 :</x:String>
906906
<x:String x:Key="Text.SSHKey.Placeholder" xml:space="preserve">SSH密钥文件</x:String>
907+
<x:String x:Key="Text.SSHKeyHelper" xml:space="preserve">打开SSH密钥工具</x:String>
908+
<x:String x:Key="Text.SSHKeyHelper.ConfirmDeletion" xml:space="preserve">您确定要删除以下SSH密钥吗?</x:String>
909+
<x:String x:Key="Text.SSHKeyHelper.Fingerprint" xml:space="preserve">SHA-256 指纹</x:String>
910+
<x:String x:Key="Text.SSHKeyHelper.Generator" xml:space="preserve">生成ED25519格式的SSH密钥</x:String>
911+
<x:String x:Key="Text.SSHKeyHelper.Generator.ConfirmPassphrase" xml:space="preserve">再次确认口令:</x:String>
912+
<x:String x:Key="Text.SSHKeyHelper.Generator.Email" xml:space="preserve">邮箱:</x:String>
913+
<x:String x:Key="Text.SSHKeyHelper.Generator.Email.Placeholder" xml:space="preserve">必填项</x:String>
914+
<x:String x:Key="Text.SSHKeyHelper.Generator.Name" xml:space="preserve">密钥文件名:</x:String>
915+
<x:String x:Key="Text.SSHKeyHelper.Generator.Name.Placeholder" xml:space="preserve">必填项,如:id_github</x:String>
916+
<x:String x:Key="Text.SSHKeyHelper.Generator.Passphrase" xml:space="preserve">输入口令:</x:String>
917+
<x:String x:Key="Text.SSHKeyHelper.Generator.Start" xml:space="preserve">生 成</x:String>
918+
<x:String x:Key="Text.SSHKeyHelper.Generator.UsePassphrase" xml:space="preserve">为私钥文件设置口令</x:String>
919+
<x:String x:Key="Text.SSHKeyHelper.Path" xml:space="preserve">文件路径</x:String>
920+
<x:String x:Key="Text.SSHKeyHelper.PublicKey" xml:space="preserve">公钥内容</x:String>
921+
<x:String x:Key="Text.SSHKeyHelper.Title" xml:space="preserve">SSH密钥工具</x:String>
907922
<x:String x:Key="Text.Start" xml:space="preserve">开 始</x:String>
908923
<x:String x:Key="Text.Stash" xml:space="preserve">贮藏(stash)</x:String>
909924
<x:String x:Key="Text.Stash.IncludeUntracked" xml:space="preserve">包含未跟踪的文件</x:String>

src/ViewModels/SSHKeyGenerator.cs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using System;
2+
using System.ComponentModel.DataAnnotations;
3+
using System.Diagnostics;
4+
using System.IO;
5+
using System.Text.RegularExpressions;
6+
7+
using CommunityToolkit.Mvvm.ComponentModel;
8+
9+
namespace SourceGit.ViewModels
10+
{
11+
public partial class SSHKeyGenerator : ObservableValidator
12+
{
13+
[Required(ErrorMessage = "Name is required.")]
14+
[RegularExpression(@"^[a-zA-Z0-9_\-]+$", ErrorMessage = "Name can only contain letters, numbers, underscores, and hyphens.")]
15+
public string Name
16+
{
17+
get => _name;
18+
set => SetProperty(ref _name, value, true);
19+
}
20+
21+
[Required(ErrorMessage = "Email is required.")]
22+
[EmailAddress(ErrorMessage = "Invalid email address.")]
23+
public string Email
24+
{
25+
get => _email;
26+
set => SetProperty(ref _email, value, true);
27+
}
28+
29+
public bool UsePassphrase
30+
{
31+
get => _usePassphrase;
32+
set
33+
{
34+
if (SetProperty(ref _usePassphrase, value))
35+
{
36+
ValidateProperty(_passphrase, nameof(Passphrase));
37+
ValidateProperty(_confirmedPassphrase, nameof(ConfirmedPassphrase));
38+
}
39+
}
40+
}
41+
42+
[CustomValidation(typeof(SSHKeyGenerator), nameof(ValidatePassphrase))]
43+
public string Passphrase
44+
{
45+
get => _passphrase;
46+
set
47+
{
48+
if (SetProperty(ref _passphrase, value, true))
49+
ValidateProperty(_confirmedPassphrase, nameof(ConfirmedPassphrase));
50+
}
51+
}
52+
53+
[CustomValidation(typeof(SSHKeyGenerator), nameof(ValidateConfirmedPassphrase))]
54+
public string ConfirmedPassphrase
55+
{
56+
get => _confirmedPassphrase;
57+
set => SetProperty(ref _confirmedPassphrase, value, true);
58+
}
59+
60+
public string ErrorMessage
61+
{
62+
get => _errorMessage;
63+
set => SetProperty(ref _errorMessage, value);
64+
}
65+
66+
public SSHKeyGenerator(string baseDir)
67+
{
68+
_baseDir = baseDir;
69+
}
70+
71+
public static ValidationResult ValidatePassphrase(string password, ValidationContext context)
72+
{
73+
var instance = (SSHKeyGenerator)context.ObjectInstance;
74+
if (!instance.UsePassphrase)
75+
return ValidationResult.Success;
76+
77+
if (string.IsNullOrEmpty(password))
78+
return new ValidationResult("Passphrase is required!");
79+
80+
if (!REG_PSWD_FORMAT().IsMatch(password))
81+
return new ValidationResult("Passphrase can only contain letters, numbers, and special characters: _ + - = @ # $ % ! &");
82+
83+
return ValidationResult.Success;
84+
}
85+
86+
public static ValidationResult ValidateConfirmedPassphrase(string confirmedPassword, ValidationContext context)
87+
{
88+
var instance = (SSHKeyGenerator)context.ObjectInstance;
89+
if (!instance.UsePassphrase)
90+
return ValidationResult.Success;
91+
92+
if (confirmedPassword != instance.Passphrase)
93+
return new ValidationResult("Passphrase and confirmation do not match!");
94+
95+
return ValidationResult.Success;
96+
}
97+
98+
public bool Run()
99+
{
100+
ErrorMessage = string.Empty;
101+
102+
ValidateAllProperties();
103+
if (HasErrors)
104+
return false;
105+
106+
var passphrase = _usePassphrase ? _passphrase : string.Empty;
107+
var keyFile = Path.Combine(_baseDir, _name);
108+
var start = new ProcessStartInfo();
109+
start.FileName = "ssh-keygen";
110+
start.Arguments = $"-q -t ed25519 -N {passphrase.Quoted()} -C {_email.Quoted()} -f {keyFile.Quoted()}";
111+
start.UseShellExecute = false;
112+
start.CreateNoWindow = true;
113+
114+
try
115+
{
116+
var proc = Process.Start(start);
117+
proc.WaitForExit();
118+
proc.Close();
119+
}
120+
catch (Exception e)
121+
{
122+
ErrorMessage = $"Failed to generate SSH key: {e.Message}";
123+
return false;
124+
}
125+
126+
return true;
127+
}
128+
129+
[GeneratedRegex(@"^[0-9a-zA-Z_\-\@\#\$\%\!\&\+\=]+$")]
130+
private static partial Regex REG_PSWD_FORMAT();
131+
132+
private string _baseDir;
133+
private string _name;
134+
private string _email;
135+
private bool _usePassphrase;
136+
private string _passphrase;
137+
private string _confirmedPassphrase;
138+
private string _errorMessage;
139+
}
140+
}

src/ViewModels/SSHKeyHelper.cs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Threading.Tasks;
5+
6+
using Avalonia.Collections;
7+
using Avalonia.Threading;
8+
9+
using CommunityToolkit.Mvvm.ComponentModel;
10+
11+
namespace SourceGit.ViewModels
12+
{
13+
public class SSHKeyHelper : ObservableObject
14+
{
15+
public AvaloniaList<Models.SSHKeyPair> Keys
16+
{
17+
get;
18+
}
19+
20+
public Models.SSHKeyPair SelectedKey
21+
{
22+
get => _selectedKey;
23+
set => SetProperty(ref _selectedKey, value);
24+
}
25+
26+
public SSHKeyGenerator Generator
27+
{
28+
get => _generator;
29+
private set => SetProperty(ref _generator, value);
30+
}
31+
32+
public SSHKeyHelper()
33+
{
34+
_baseDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh");
35+
Keys = new AvaloniaList<Models.SSHKeyPair>();
36+
37+
Task.Run(() =>
38+
{
39+
var sshDir = new DirectoryInfo(_baseDir);
40+
var keys = new List<Models.SSHKeyPair>();
41+
42+
if (sshDir.Exists)
43+
{
44+
var files = sshDir.GetFiles("*.pub");
45+
foreach (var file in files)
46+
{
47+
var privateKeyPath = file.FullName.Substring(0, file.FullName.Length - 4);
48+
if (File.Exists(privateKeyPath))
49+
keys.Add(new(privateKeyPath));
50+
}
51+
52+
keys.Sort((l, r) => l.Name.CompareTo(r.Name));
53+
}
54+
55+
if (keys.Count > 0)
56+
{
57+
Dispatcher.UIThread.Post(() =>
58+
{
59+
Keys.AddRange(keys);
60+
SelectedKey = keys[0];
61+
});
62+
}
63+
});
64+
}
65+
66+
public void OpenGenerator()
67+
{
68+
Generator = new SSHKeyGenerator(_baseDir);
69+
}
70+
71+
public void CloseGenerator()
72+
{
73+
Generator = null;
74+
}
75+
76+
public void Generate()
77+
{
78+
var succ = _generator.Run();
79+
if (!succ)
80+
return;
81+
82+
var keyFile = Path.Combine(_baseDir, $"{_generator.Name}");
83+
if (File.Exists(keyFile))
84+
{
85+
var added = new Models.SSHKeyPair(keyFile);
86+
Keys.Add(added);
87+
SelectedKey = added;
88+
}
89+
90+
Generator = null;
91+
}
92+
93+
public void DeleteSelected()
94+
{
95+
var key = SelectedKey;
96+
if (key == null)
97+
return;
98+
99+
try
100+
{
101+
if (File.Exists(key.FullPath))
102+
File.Delete(key.FullPath);
103+
if (File.Exists($"{key.FullPath}.pub"))
104+
File.Delete($"{key.FullPath}.pub");
105+
106+
var idx = Keys.IndexOf(key);
107+
if (idx >= 0)
108+
{
109+
Keys.RemoveAt(idx);
110+
111+
if (Keys.Count == 0)
112+
SelectedKey = null;
113+
else if (idx > Keys.Count - 1)
114+
SelectedKey = Keys[Keys.Count - 1];
115+
else
116+
SelectedKey = Keys[idx];
117+
}
118+
}
119+
catch
120+
{
121+
// Ignore any errors during deletion
122+
}
123+
}
124+
125+
private string _baseDir = null;
126+
private Models.SSHKeyPair _selectedKey = null;
127+
private SSHKeyGenerator _generator = null;
128+
}
129+
}

0 commit comments

Comments
 (0)