diff --git a/KeeAgent/KeeAgent.csproj b/KeeAgent/KeeAgent.csproj index f9a4cb8..d18a99d 100644 --- a/KeeAgent/KeeAgent.csproj +++ b/KeeAgent/KeeAgent.csproj @@ -56,6 +56,7 @@ + diff --git a/KeeAgent/SshKeyGenerator.cs b/KeeAgent/SshKeyGenerator.cs new file mode 100644 index 0000000..cbe43af --- /dev/null +++ b/KeeAgent/SshKeyGenerator.cs @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: GPL-2.0-only +using System; +using System.IO; +using System.Text; +using KeePassLib.Cryptography; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Security; +using SshAgentLib.Keys; + +namespace KeeAgent +{ + internal static class SshKeyGenerator + { + internal static void Generate(string comment, + out byte[] privateKeyBytes, out byte[] publicKeyBytes) + { + if (comment == null) comment = string.Empty; + if (comment.IndexOfAny(new char[] { '\r', '\n', '\0' }) >= 0) { + throw new ArgumentException("comment must not contain newlines or NUL", "comment"); + } + // No-arg constructor seeds from RNGCryptoServiceProvider. + // SetSeed() ADDS entropy — it does not replace existing seeding. + var secureRandom = new SecureRandom(); + secureRandom.SetSeed(CryptoRandom.Instance.GetRandomBytes(64)); + + var privateParams = new Ed25519PrivateKeyParameters(secureRandom); + var seed = privateParams.GetEncoded(); // 32 bytes + var pub = privateParams.GeneratePublicKey().GetEncoded(); // 32 bytes + + try { + privateKeyBytes = BuildPrivateKey(seed, pub, comment); + publicKeyBytes = BuildPublicKey(pub, comment); + } + finally { + Array.Clear(seed, 0, seed.Length); + } + } + + static byte[] BuildPrivateKey(byte[] seed, byte[] pub, string comment) + { + var commentBytes = Encoding.UTF8.GetBytes(comment); + var checkInt = CryptoRandom.Instance.GetRandomBytes(4); + + using (var blob = new MemoryStream()) { + // magic + blob.Write(Encoding.ASCII.GetBytes("openssh-key-v1"), 0, 14); + blob.WriteByte(0); + + WriteString(blob, "none"); // cipher + WriteString(blob, "none"); // kdf + WriteString(blob, new byte[0]); // kdf options (empty) + + WriteUInt32(blob, 1); // number of keys + + // public key blob + using (var pubBlob = new MemoryStream()) { + WriteString(pubBlob, "ssh-ed25519"); + WriteString(pubBlob, pub); + WriteString(blob, pubBlob.ToArray()); + } + + // private key blob + using (var privBlob = new MemoryStream()) { + privBlob.Write(checkInt, 0, 4); // check_int repeated twice + privBlob.Write(checkInt, 0, 4); + WriteString(privBlob, "ssh-ed25519"); + WriteString(privBlob, pub); + var privMaterial = new byte[64]; // seed (32) || pubkey (32) + try { + Buffer.BlockCopy(seed, 0, privMaterial, 0, 32); + Buffer.BlockCopy(pub, 0, privMaterial, 32, 32); + WriteString(privBlob, privMaterial); + } + finally { + Array.Clear(privMaterial, 0, privMaterial.Length); + } + WriteString(privBlob, commentBytes); + for (int pad = 1; privBlob.Length % 8 != 0; pad++) { + privBlob.WriteByte((byte)pad); + } + WriteString(blob, privBlob.ToArray()); + } + + var b64 = Convert.ToBase64String(blob.ToArray()); + var sb = new StringBuilder(); + sb.Append("-----BEGIN OPENSSH PRIVATE KEY-----\n"); + for (int i = 0; i < b64.Length; i += 70) { + sb.Append(b64.Substring(i, Math.Min(70, b64.Length - i))).Append('\n'); + } + sb.Append("-----END OPENSSH PRIVATE KEY-----\n"); + return Encoding.ASCII.GetBytes(sb.ToString()); + } + } + + static byte[] BuildPublicKey(byte[] pub, string comment) + { + using (var keyBlob = new MemoryStream()) { + WriteString(keyBlob, "ssh-ed25519"); + WriteString(keyBlob, pub); + var b64 = Convert.ToBase64String(keyBlob.ToArray()); + return Encoding.UTF8.GetBytes("ssh-ed25519 " + b64 + " " + comment); + } + } + + static void WriteString(Stream s, string value) + { + WriteString(s, Encoding.UTF8.GetBytes(value)); + } + + static void WriteString(Stream s, byte[] value) + { + WriteUInt32(s, (uint)value.Length); + s.Write(value, 0, value.Length); + } + + static void WriteUInt32(Stream s, uint value) + { + s.WriteByte((byte)(value >> 24)); + s.WriteByte((byte)(value >> 16)); + s.WriteByte((byte)(value >> 8)); + s.WriteByte((byte)value); + } + + internal static void ChangeComment( + byte[] attachmentBytes, + string newComment, + out byte[] privateKeyBytes, + out byte[] publicKeyBytes) + { + if (newComment == null) newComment = string.Empty; + + SshPrivateKey key; + using (var ms = new MemoryStream(attachmentBytes)) { + key = SshPrivateKey.Read(ms); + } + + if (key.IsEncrypted) { + throw new InvalidOperationException( + "Key is encrypted. Decrypt it first to change the comment."); + } + + string oldComment; + var privateParams = key.Decrypt(() => new byte[0], null, out oldComment); + + var authParts = key.PublicKey.AuthorizedKeysString.Split(' '); + var algo = authParts[0]; + var b64Pub = authParts[1]; + var pubKeyBlob = Convert.FromBase64String(b64Pub); + + privateKeyBytes = BuildDecryptedPrivateKey(privateParams, pubKeyBlob, newComment); + + publicKeyBytes = Encoding.UTF8.GetBytes( + string.IsNullOrEmpty(newComment) + ? algo + " " + b64Pub + : algo + " " + b64Pub + " " + newComment); + } + + internal static void Decrypt( + byte[] attachmentBytes, + string passphrase, + out byte[] privateKeyBytes, + out byte[] publicKeyBytes) + { + var pass = Encoding.UTF8.GetBytes(passphrase ?? string.Empty); + try { + SshPrivateKey key; + using (var ms = new MemoryStream(attachmentBytes)) { + key = SshPrivateKey.Read(ms); + } + + if (!key.IsEncrypted) { + throw new InvalidOperationException("Key is not encrypted."); + } + + string comment; + var capturedPass = pass; + var privateParams = key.Decrypt( + () => capturedPass, null, out comment); + + // AuthorizedKeysString = "{algo} {base64(KeyBlob)} [{comment}]" + // KeyBlob is internal, so decode via AuthorizedKeysString. + var authParts = key.PublicKey.AuthorizedKeysString.Split(' '); + var algo = authParts[0]; + var b64Pub = authParts[1]; + var pubKeyBlob = Convert.FromBase64String(b64Pub); + + privateKeyBytes = BuildDecryptedPrivateKey(privateParams, pubKeyBlob, comment); + + publicKeyBytes = Encoding.UTF8.GetBytes( + string.IsNullOrEmpty(comment) + ? algo + " " + b64Pub + : algo + " " + b64Pub + " " + comment); + } + finally { + Array.Clear(pass, 0, pass.Length); + } + } + + static byte[] BuildDecryptedPrivateKey( + AsymmetricKeyParameter privateParams, byte[] pubKeyBlob, string comment) + { + var commentBytes = Encoding.UTF8.GetBytes(comment ?? string.Empty); + var checkInt = CryptoRandom.Instance.GetRandomBytes(4); + + using (var blob = new MemoryStream()) { + blob.Write(Encoding.ASCII.GetBytes("openssh-key-v1"), 0, 14); + blob.WriteByte(0); + + WriteString(blob, "none"); + WriteString(blob, "none"); + WriteString(blob, new byte[0]); + WriteUInt32(blob, 1); + WriteString(blob, pubKeyBlob); + + using (var privBlob = new MemoryStream()) { + privBlob.Write(checkInt, 0, 4); + privBlob.Write(checkInt, 0, 4); + privBlob.Write(pubKeyBlob, 0, pubKeyBlob.Length); + WriteDecryptedKeyMaterial(privBlob, privateParams); + WriteString(privBlob, commentBytes); + for (int pad = 1; privBlob.Length % 8 != 0; pad++) { + privBlob.WriteByte((byte)pad); + } + WriteString(blob, privBlob.ToArray()); + } + + var b64 = Convert.ToBase64String(blob.ToArray()); + var sb = new StringBuilder(); + sb.Append("-----BEGIN OPENSSH PRIVATE KEY-----\n"); + for (int i = 0; i < b64.Length; i += 70) { + sb.Append(b64.Substring(i, Math.Min(70, b64.Length - i))).Append('\n'); + } + sb.Append("-----END OPENSSH PRIVATE KEY-----\n"); + return Encoding.ASCII.GetBytes(sb.ToString()); + } + } + + static void WriteDecryptedKeyMaterial(Stream s, AsymmetricKeyParameter key) + { + var ed25519 = key as Ed25519PrivateKeyParameters; + if (ed25519 != null) { + var seed = ed25519.GetEncoded(); + var pub = ed25519.GeneratePublicKey().GetEncoded(); + var combined = new byte[64]; + try { + Buffer.BlockCopy(seed, 0, combined, 0, 32); + Buffer.BlockCopy(pub, 0, combined, 32, 32); + WriteString(s, combined); + } + finally { + Array.Clear(seed, 0, seed.Length); + Array.Clear(combined, 0, combined.Length); + } + return; + } + + var rsa = key as RsaPrivateCrtKeyParameters; + if (rsa != null) { + // order: d, iqmp, p, q (matches OpenSSH wire format) + WriteString(s, rsa.Exponent.ToByteArrayUnsigned()); + WriteString(s, rsa.QInv.ToByteArrayUnsigned()); + WriteString(s, rsa.P.ToByteArrayUnsigned()); + WriteString(s, rsa.Q.ToByteArrayUnsigned()); + return; + } + + var ecdsa = key as ECPrivateKeyParameters; + if (ecdsa != null) { + WriteString(s, ecdsa.D.ToByteArrayUnsigned()); + return; + } + + var dsa = key as DsaPrivateKeyParameters; + if (dsa != null) { + WriteString(s, dsa.X.ToByteArrayUnsigned()); + return; + } + + var ed448 = key as Ed448PrivateKeyParameters; + if (ed448 != null) { + var seed = ed448.GetEncoded(); + var pub = ed448.GeneratePublicKey().GetEncoded(); + var combined = new byte[seed.Length + pub.Length]; + try { + Buffer.BlockCopy(seed, 0, combined, 0, seed.Length); + Buffer.BlockCopy(pub, 0, combined, seed.Length, pub.Length); + WriteString(s, combined); + } + finally { + Array.Clear(seed, 0, seed.Length); + Array.Clear(combined, 0, combined.Length); + } + return; + } + + throw new NotSupportedException("Unsupported key type: " + key.GetType().Name); + } + } +} diff --git a/KeeAgent/UI/EntryPanel.Designer.cs b/KeeAgent/UI/EntryPanel.Designer.cs index 36a145f..c1ccca5 100644 --- a/KeeAgent/UI/EntryPanel.Designer.cs +++ b/KeeAgent/UI/EntryPanel.Designer.cs @@ -129,6 +129,7 @@ private void InitializeComponent() resources.ApplyResources(this.commentTextBox, "commentTextBox"); this.commentTextBox.Name = "commentTextBox"; this.commentTextBox.ReadOnly = true; + this.commentTextBox.Leave += new System.EventHandler(this.commentTextBox_Leave); // // fingerprintTextBox // diff --git a/KeeAgent/UI/EntryPanel.cs b/KeeAgent/UI/EntryPanel.cs index 8ffa85d..e8b5892 100644 --- a/KeeAgent/UI/EntryPanel.cs +++ b/KeeAgent/UI/EntryPanel.cs @@ -5,11 +5,16 @@ using System.Collections.Generic; using System.Diagnostics; using System.Drawing; +using System.IO; using System.Linq; using System.Windows.Forms; using KeePass.Forms; using KeePass.Util; +using KeePass.Util.Spr; +using KeePassLib; using KeePassLib.Security; +using KeePassLib.Utility; +using dlech.SshAgentLib; using SshAgentLib.Extension; using SshAgentLib.Keys; @@ -19,6 +24,10 @@ public partial class EntryPanel : UserControl { private PwEntryForm pwEntryForm; private readonly KeeAgentExt ext; + private string editableCommentOriginal; + private ISshKey pendingOldAgentKey; + private byte[] pendingNewPrivKeyBytes; + private byte[] pendingNewPubKeyBytes; public EntrySettings InitialSettings { get; @@ -79,10 +88,13 @@ protected override void OnLoad(EventArgs e) }; } - pwEntryForm.FormClosing += delegate { + pwEntryForm.FormClosing += (sender2, e2) => { while (delayedUpdateKeyInfoTimer.Enabled) { Application.DoEvents(); } + if (!e2.Cancel && pwEntryForm.DialogResult == DialogResult.OK) { + ApplyPendingAgentReload(); + } }; } else { @@ -133,6 +145,7 @@ void UpdateKeyInfo() invalidKeyWarningIcon.Visible = !isLocationValid; } + var commentEditable = false; try { var key = CurrentSettings.TryGetSshPublicKey(pwEntryForm.EntryBinaries); @@ -140,6 +153,18 @@ void UpdateKeyInfo() fingerprintTextBox.Text = key.Sha256Fingerprint; publicKeyTextBox.Text = key.AuthorizedKeysString; copyPublicKeyButton.Enabled = true; + + // Comment is editable when the key is an unencrypted attachment. + if (CurrentSettings.Location != null && + CurrentSettings.Location.SelectedType == EntrySettings.LocationType.Attachment && + !string.IsNullOrEmpty(CurrentSettings.Location.AttachmentName)) { + var attach = pwEntryForm.EntryBinaries.Get(CurrentSettings.Location.AttachmentName); + if (attach != null) { + using (var ms = new MemoryStream(attach.ReadData())) { + commentEditable = !SshPrivateKey.Read(ms).IsEncrypted; + } + } + } } catch (Exception) { commentTextBox.Text = string.Empty; @@ -147,6 +172,108 @@ void UpdateKeyInfo() publicKeyTextBox.Text = string.Empty; copyPublicKeyButton.Enabled = false; } + + commentTextBox.ReadOnly = !commentEditable; + editableCommentOriginal = commentEditable ? commentTextBox.Text : null; + } + + private void commentTextBox_Leave(object sender, EventArgs e) + { + if (commentTextBox.ReadOnly) return; + if (commentTextBox.Text == editableCommentOriginal) return; + ApplyCommentChange(); + } + + private void ApplyCommentChange() + { + var loc = CurrentSettings.Location; + if (loc == null || + loc.SelectedType != EntrySettings.LocationType.Attachment || + string.IsNullOrEmpty(loc.AttachmentName)) { + return; + } + + var attachName = loc.AttachmentName; + var attachment = pwEntryForm.EntryBinaries.Get(attachName); + if (attachment == null) return; + + // On the first comment edit, capture the currently-loaded agent key so we + // can replace it when the entry dialog closes with OK. We defer the live + // agent swap to FormClosing so a Cancel roll-back doesn't leave the agent + // in an inconsistent state. + if (pendingOldAgentKey == null && ext.agent != null) { + try { + var pubKey = CurrentSettings.TryGetSshPublicKey(pwEntryForm.EntryBinaries); + if (pubKey != null) { + pendingOldAgentKey = ext.agent.ListKeys() + .FirstOrDefault(k => pubKey.Matches(k.GetPublicKeyBlob())); + } + } + catch (Exception) { } + } + + byte[] privateKeyBytes, publicKeyBytes; + try { + SshKeyGenerator.ChangeComment( + attachment.ReadData(), + commentTextBox.Text, + out privateKeyBytes, + out publicKeyBytes); + } + catch (Exception ex) { + MessageService.ShowWarning("KeeAgent: Failed to update comment:", ex.Message); + return; + } + + pwEntryForm.EntryBinaries.Set(attachName, new ProtectedBinary(false, privateKeyBytes)); + pwEntryForm.EntryBinaries.Set(attachName + ".pub", new ProtectedBinary(false, publicKeyBytes)); + pwEntryForm.UpdateEntryBinaries(false, true); + + editableCommentOriginal = commentTextBox.Text; + + // Track the latest bytes; the FormClosing handler will use the final state. + pendingNewPrivKeyBytes = privateKeyBytes; + pendingNewPubKeyBytes = publicKeyBytes; + + UpdateKeyInfoDelayed(); + } + + private void ApplyPendingAgentReload() + { + if (pendingOldAgentKey == null || pendingNewPrivKeyBytes == null) return; + try { + SshPrivateKey newPrivKey; + using (var ms = new MemoryStream(pendingNewPrivKeyBytes)) { + newPrivKey = SshPrivateKey.Read(ms); + } + string newComment; + var privateParam = newPrivKey.Decrypt(() => new byte[0], null, out newComment); + + SshPublicKey newPubKey; + using (var ms = new MemoryStream(pendingNewPubKeyBytes)) { + newPubKey = SshPublicKey.Read(ms); + } + + var newKey = new SshKey( + newPubKey.Parameter, privateParam, newComment, + newPubKey.Nonce, newPubKey.Certificate); + newKey.Source = pendingOldAgentKey.Source; + newKey.DestinationConstraint = pendingOldAgentKey.DestinationConstraint; + foreach (var c in pendingOldAgentKey.Constraints) { + newKey.AddConstraint(c); + } + + ext.RemoveKey(pendingOldAgentKey); + ext.agent.AddKey(newKey); + } + catch (Exception) { + // Not critical — old key stays loaded if reload fails. + } + finally { + pendingOldAgentKey = null; + pendingNewPrivKeyBytes = null; + pendingNewPubKeyBytes = null; + } } private void hasSshKeyCheckBox_CheckedChanged(object sender, EventArgs e) @@ -180,6 +307,10 @@ private void openManageFilesDialogButton_Click(object sender, EventArgs e) pwEntryForm.UpdateEntryBinaries(true, false); var dialog = new ManageKeyFileDialog { + DefaultComment = pwEntryForm.EntryRef.Strings.ReadSafe(PwDefs.TitleField), + Passphrase = SprEngine.Compile( + pwEntryForm.EntryRef.Strings.ReadSafe(PwDefs.PasswordField), + new SprContext(pwEntryForm.EntryRef, pwEntryForm.EntryRef.GetDatabase(), SprCompileFlags.Deref)), Attachments = new AttachmentBindingList(pwEntryForm.EntryBinaries), KeyLocation = CurrentSettings.Location.DeepCopy(), }; diff --git a/KeeAgent/UI/EntryPanel.resx b/KeeAgent/UI/EntryPanel.resx index 1e8795d..739ffd1 100644 --- a/KeeAgent/UI/EntryPanel.resx +++ b/KeeAgent/UI/EntryPanel.resx @@ -844,7 +844,7 @@ NoControl - 312, 358 + 306, 358 6, 6, 6, 6 diff --git a/KeeAgent/UI/KeyLocationPanel.Designer.cs b/KeeAgent/UI/KeyLocationPanel.Designer.cs index 692e2c7..1d3a336 100644 --- a/KeeAgent/UI/KeyLocationPanel.Designer.cs +++ b/KeeAgent/UI/KeyLocationPanel.Designer.cs @@ -40,6 +40,10 @@ private void InitializeComponent() this.fileRadioButton = new System.Windows.Forms.RadioButton(); this.attachmentRadioButton = new System.Windows.Forms.RadioButton(); this.attachButton = new System.Windows.Forms.Button(); + this.generateRadioButton = new System.Windows.Forms.RadioButton(); + this.generateCommentLabel = new System.Windows.Forms.Label(); + this.generateCommentTextBox = new System.Windows.Forms.TextBox(); + this.decryptRadioButton = new System.Windows.Forms.RadioButton(); ((System.ComponentModel.ISupportInitialize)(this.locationSettingsBindingSource)).BeginInit(); this.locationGroupBox.SuspendLayout(); this.SuspendLayout(); @@ -59,6 +63,10 @@ private void InitializeComponent() this.locationGroupBox.Controls.Add(this.attachmentComboBox); this.locationGroupBox.Controls.Add(this.fileRadioButton); this.locationGroupBox.Controls.Add(this.attachmentRadioButton); + this.locationGroupBox.Controls.Add(this.generateRadioButton); + this.locationGroupBox.Controls.Add(this.generateCommentLabel); + this.locationGroupBox.Controls.Add(this.generateCommentTextBox); + this.locationGroupBox.Controls.Add(this.decryptRadioButton); this.locationGroupBox.DataBindings.Add(new System.Windows.Forms.Binding("SelectedRadioButton", this.locationSettingsBindingSource, "SelectedType", true, System.Windows.Forms.DataSourceUpdateMode.OnPropertyChanged)); resources.ApplyResources(this.locationGroupBox, "locationGroupBox"); this.locationGroupBox.Name = "locationGroupBox"; @@ -119,14 +127,40 @@ private void InitializeComponent() this.attachmentRadioButton.CheckedChanged += new System.EventHandler(this.radioButton_CheckedChanged); // // attachButton - // + // resources.ApplyResources(this.attachButton, "attachButton"); this.attachButton.Name = "attachButton"; this.attachButton.UseVisualStyleBackColor = true; this.attachButton.Click += new System.EventHandler(this.attachButton_Click); - // + // + // generateRadioButton + // + resources.ApplyResources(this.generateRadioButton, "generateRadioButton"); + this.generateRadioButton.Name = "generateRadioButton"; + this.generateRadioButton.TabStop = true; + this.generateRadioButton.UseVisualStyleBackColor = true; + this.generateRadioButton.CheckedChanged += new System.EventHandler(this.radioButton_CheckedChanged); + // + // generateCommentLabel + // + resources.ApplyResources(this.generateCommentLabel, "generateCommentLabel"); + this.generateCommentLabel.Name = "generateCommentLabel"; + // + // generateCommentTextBox + // + resources.ApplyResources(this.generateCommentTextBox, "generateCommentTextBox"); + this.generateCommentTextBox.Name = "generateCommentTextBox"; + // + // decryptRadioButton + // + resources.ApplyResources(this.decryptRadioButton, "decryptRadioButton"); + this.decryptRadioButton.Name = "decryptRadioButton"; + this.decryptRadioButton.TabStop = true; + this.decryptRadioButton.UseVisualStyleBackColor = true; + this.decryptRadioButton.CheckedChanged += new System.EventHandler(this.radioButton_CheckedChanged); + // // KeyLocationPanel - // + // resources.ApplyResources(this, "$this"); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.Controls.Add(this.locationGroupBox); @@ -151,5 +185,9 @@ private void InitializeComponent() private System.Windows.Forms.CheckBox saveKeyToTempFileCheckBox; private InPlaceMessage errorMessage; private System.Windows.Forms.Button attachButton; + private System.Windows.Forms.RadioButton generateRadioButton; + private System.Windows.Forms.Label generateCommentLabel; + private System.Windows.Forms.TextBox generateCommentTextBox; + private System.Windows.Forms.RadioButton decryptRadioButton; } } diff --git a/KeeAgent/UI/KeyLocationPanel.cs b/KeeAgent/UI/KeyLocationPanel.cs index c2dcaf8..7d08b08 100644 --- a/KeeAgent/UI/KeyLocationPanel.cs +++ b/KeeAgent/UI/KeyLocationPanel.cs @@ -182,6 +182,28 @@ protected override void OnLoad(EventArgs e) UpdateControlStates(); } + public bool IsGenerateSelected { + get { return generateRadioButton.Checked; } + } + + public string GenerateComment { + get { return generateCommentTextBox.Text; } + set { generateCommentTextBox.Text = value; } + } + + public bool IsDecryptSelected { + get { return decryptRadioButton.Checked; } + } + + public string DecryptAttachmentName { + get { return attachmentComboBox.SelectedValue as string; } + } + + public void SetDecryptEnabled(bool enabled) + { + decryptRadioButton.Enabled = enabled; + } + private void UpdateControlStates() { attachmentComboBox.Enabled = attachmentRadioButton.Checked; @@ -189,6 +211,8 @@ private void UpdateControlStates() saveKeyToTempFileCheckBox.Enabled = attachmentRadioButton.Checked; fileNameTextBox.Enabled = fileRadioButton.Checked; browseButton.Enabled = fileRadioButton.Checked; + generateCommentLabel.Enabled = generateRadioButton.Checked; + generateCommentTextBox.Enabled = generateRadioButton.Checked; } private void radioButton_CheckedChanged(object sender, EventArgs e) diff --git a/KeeAgent/UI/KeyLocationPanel.resx b/KeeAgent/UI/KeyLocationPanel.resx index 69cf34d..6462c0e 100644 --- a/KeeAgent/UI/KeyLocationPanel.resx +++ b/KeeAgent/UI/KeyLocationPanel.resx @@ -156,8 +156,122 @@ 0 + + True + + + NoControl + + + 12, 95 + + + 168, 17 + + + 8 + + + Generate new Ed25519 key + + + generateRadioButton + + + System.Windows.Forms.RadioButton, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + locationGroupBox + + + 8 + + + True + + + NoControl + + + 97, 120 + + + 55, 13 + + + 9 + + + Comment: + + + generateCommentLabel + + + System.Windows.Forms.Label, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + locationGroupBox + + + 9 + + + Top, Left, Right + + + 155, 117 + + + 284, 20 + + + 10 + + + generateCommentTextBox + + + System.Windows.Forms.TextBox, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + locationGroupBox + + + 10 + + + True + + + NoControl + + + 12, 143 + + + 200, 17 + + + 11 + + + Decrypt existing key (uses entry password) + + + decryptRadioButton + + + System.Windows.Forms.RadioButton, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + locationGroupBox + + + 11 + - 12, 93 + 12, 167 427, 19 @@ -355,7 +469,7 @@ 0, 0 - 445, 115 + 445, 192 3 @@ -385,7 +499,7 @@ 6, 13 - 445, 115 + 445, 192 locationSettingsBindingSource diff --git a/KeeAgent/UI/ManageKeyFileDialog.Designer.cs b/KeeAgent/UI/ManageKeyFileDialog.Designer.cs index 86decc6..c8bee68 100644 --- a/KeeAgent/UI/ManageKeyFileDialog.Designer.cs +++ b/KeeAgent/UI/ManageKeyFileDialog.Designer.cs @@ -36,25 +36,25 @@ private void InitializeComponent() // this.cancelButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); this.cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel; - this.cancelButton.Location = new System.Drawing.Point(456, 143); + this.cancelButton.Location = new System.Drawing.Point(456, 217); this.cancelButton.Margin = new System.Windows.Forms.Padding(2); this.cancelButton.Name = "cancelButton"; this.cancelButton.Size = new System.Drawing.Size(75, 23); this.cancelButton.TabIndex = 2; this.cancelButton.Text = "&Cancel"; this.cancelButton.UseVisualStyleBackColor = true; - // + // // okButton - // + // this.okButton.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right))); - this.okButton.DialogResult = System.Windows.Forms.DialogResult.OK; - this.okButton.Location = new System.Drawing.Point(377, 143); + this.okButton.Location = new System.Drawing.Point(377, 217); this.okButton.Margin = new System.Windows.Forms.Padding(2); this.okButton.Name = "okButton"; this.okButton.Size = new System.Drawing.Size(75, 23); this.okButton.TabIndex = 3; this.okButton.Text = "&OK"; this.okButton.UseVisualStyleBackColor = true; + this.okButton.Click += new System.EventHandler(this.okButton_Click); // // privateKeyLocationPanel // @@ -67,7 +67,7 @@ private void InitializeComponent() this.privateKeyLocationPanel.Location = new System.Drawing.Point(15, 15); this.privateKeyLocationPanel.Margin = new System.Windows.Forms.Padding(6); this.privateKeyLocationPanel.Name = "privateKeyLocationPanel"; - this.privateKeyLocationPanel.Size = new System.Drawing.Size(512, 118); + this.privateKeyLocationPanel.Size = new System.Drawing.Size(512, 192); this.privateKeyLocationPanel.TabIndex = 0; this.privateKeyLocationPanel.Title = "Private Key File Location"; // @@ -77,7 +77,7 @@ private void InitializeComponent() this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.CancelButton = this.cancelButton; - this.ClientSize = new System.Drawing.Size(542, 177); + this.ClientSize = new System.Drawing.Size(542, 251); this.Controls.Add(this.okButton); this.Controls.Add(this.cancelButton); this.Controls.Add(this.privateKeyLocationPanel); diff --git a/KeeAgent/UI/ManageKeyFileDialog.cs b/KeeAgent/UI/ManageKeyFileDialog.cs index 29704ef..6a03769 100644 --- a/KeeAgent/UI/ManageKeyFileDialog.cs +++ b/KeeAgent/UI/ManageKeyFileDialog.cs @@ -2,9 +2,13 @@ // Copyright (c) 2022 David Lechner using System; +using System.Collections.Generic; +using System.IO; using System.Linq; using System.Windows.Forms; using KeePassLib.Security; +using KeePassLib.Utility; +using SshAgentLib.Keys; namespace KeeAgent.UI { @@ -13,6 +17,8 @@ namespace KeeAgent.UI /// public partial class ManageKeyFileDialog : Form { + private string passphrase; + /// /// Creates a new key file dialog. /// @@ -41,11 +47,59 @@ public ManageKeyFileDialog() }); privateKeyLocationPanel.KeyLocationChanged += (s, e) => { - privateKeyLocationPanel.ErrorMessage = UI.Validate.Location( - privateKeyLocationPanel.KeyLocation, getAttachment); + UpdateDecryptEnabled(); + if (privateKeyLocationPanel.IsGenerateSelected || + privateKeyLocationPanel.IsDecryptSelected) { + privateKeyLocationPanel.ErrorMessage = null; + } + else { + privateKeyLocationPanel.ErrorMessage = UI.Validate.Location( + privateKeyLocationPanel.KeyLocation, getAttachment); + } }; } + protected override void OnLoad(EventArgs e) + { + base.OnLoad(e); + UpdateDecryptEnabled(); + } + + private void UpdateDecryptEnabled() + { + var canDecrypt = false; + + if (!string.IsNullOrEmpty(passphrase)) { + try { + var loc = privateKeyLocationPanel.KeyLocation; + if (loc != null && + loc.SelectedType == EntrySettings.LocationType.Attachment && + !string.IsNullOrEmpty(loc.AttachmentName) && + Attachments != null) { + var item = Attachments.FirstOrDefault(a => a.Key == loc.AttachmentName); + if (item.Value != null) { + using (var ms = new MemoryStream(item.Value.ReadData())) { + var key = SshPrivateKey.Read(ms); + canDecrypt = key.IsEncrypted; + } + } + } + } + catch { + // If we can't read the key, decrypt option stays disabled. + } + } + + privateKeyLocationPanel.SetDecryptEnabled(canDecrypt); + } + + /// + /// Sets the passphrase used to decrypt an encrypted key attachment. + /// + public string Passphrase { + set { passphrase = value; } + } + /// /// Gets and sets the attachments data source. /// @@ -58,6 +112,13 @@ public AttachmentBindingList Attachments { } } + /// + /// Sets the default comment pre-filled in the Generate option. + /// + public string DefaultComment { + set { privateKeyLocationPanel.GenerateComment = value; } + } + /// /// Gets and sets the private key location data source. /// @@ -69,5 +130,110 @@ public EntrySettings.LocationData KeyLocation { privateKeyLocationPanel.KeyLocation = value; } } + + private void okButton_Click(object sender, EventArgs e) + { + if (privateKeyLocationPanel.IsDecryptSelected) { + var attachName = privateKeyLocationPanel.DecryptAttachmentName; + + if (string.IsNullOrEmpty(attachName)) { + MessageService.ShowWarning("KeeAgent: No key attachment selected."); + return; + } + + var item = Attachments == null + ? default(KeyValuePair) + : Attachments.FirstOrDefault(a => a.Key == attachName); + + if (item.Value == null) { + MessageService.ShowWarning("KeeAgent: Selected attachment not found."); + return; + } + + byte[] privateKeyBytes, publicKeyBytes; + + try { + SshKeyGenerator.Decrypt( + item.Value.ReadData(), + passphrase, + out privateKeyBytes, + out publicKeyBytes); + } + catch (Exception ex) { + MessageService.ShowWarning( + "KeeAgent: Failed to decrypt SSH key:", ex.Message); + return; + } + + var pubAttachName = attachName + ".pub"; + var snapshot = new List>(Attachments); + snapshot.RemoveAll(a => a.Key == attachName || a.Key == pubAttachName); + snapshot.Add(new KeyValuePair( + attachName, new ProtectedBinary(false, privateKeyBytes))); + snapshot.Add(new KeyValuePair( + pubAttachName, new ProtectedBinary(false, publicKeyBytes))); + Attachments.Clear(); + foreach (var entry in snapshot) { + Attachments.Add(entry); + } + + KeyLocation = new EntrySettings.LocationData { + SelectedType = EntrySettings.LocationType.Attachment, + AttachmentName = attachName, + }; + + DialogResult = DialogResult.OK; + Close(); + return; + } + + if (privateKeyLocationPanel.IsGenerateSelected) { + if (Attachments != null && Attachments.Any(a => a.Key == "id_ed25519")) { + var confirm = MessageBox.Show( + this, + "An SSH key attachment already exists. Replacing it will invalidate any " + + "servers already configured with the current public key. Continue?", + "Replace Existing Key?", + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning); + if (confirm != DialogResult.Yes) { + return; + } + } + + byte[] privateKeyBytes, publicKeyBytes; + + try { + SshKeyGenerator.Generate( + privateKeyLocationPanel.GenerateComment, + out privateKeyBytes, + out publicKeyBytes); + } + catch (Exception ex) { + MessageService.ShowWarning("KeeAgent: Failed to generate SSH key:", ex.Message); + return; + } + + // Rebuild Attachments, replacing any existing id_ed25519 pair + var snapshot = new List>(Attachments); + snapshot.RemoveAll(a => a.Key == "id_ed25519" || a.Key == "id_ed25519.pub"); + snapshot.Add(new KeyValuePair( + "id_ed25519", new ProtectedBinary(false, privateKeyBytes))); + snapshot.Add(new KeyValuePair( + "id_ed25519.pub", new ProtectedBinary(false, publicKeyBytes))); + Attachments.Clear(); + foreach (var item in snapshot) { + Attachments.Add(item); + } + + KeyLocation = new EntrySettings.LocationData { + SelectedType = EntrySettings.LocationType.Attachment, + AttachmentName = "id_ed25519", + }; + } + + DialogResult = DialogResult.OK; + Close(); + } } }