diff --git a/Packages/SpecttyKeychain/Package.swift b/Packages/SpecttyKeychain/Package.swift index 26388f5..4ca6c7c 100644 --- a/Packages/SpecttyKeychain/Package.swift +++ b/Packages/SpecttyKeychain/Package.swift @@ -4,11 +4,15 @@ import PackageDescription let package = Package( name: "SpecttyKeychain", - platforms: [.iOS(.v18)], + platforms: [.iOS(.v18), .macOS(.v14)], products: [ .library(name: "SpecttyKeychain", targets: ["SpecttyKeychain"]), ], targets: [ .target(name: "SpecttyKeychain"), + .testTarget( + name: "SpecttyKeychainTests", + dependencies: ["SpecttyKeychain"] + ), ] ) diff --git a/Packages/SpecttyKeychain/Sources/SpecttyKeychain/KeyGenerator.swift b/Packages/SpecttyKeychain/Sources/SpecttyKeychain/KeyGenerator.swift index 8371472..02de4a1 100644 --- a/Packages/SpecttyKeychain/Sources/SpecttyKeychain/KeyGenerator.swift +++ b/Packages/SpecttyKeychain/Sources/SpecttyKeychain/KeyGenerator.swift @@ -11,6 +11,12 @@ public struct GeneratedKeyPair: Sendable { public let publicKeyData: Data /// The SSH key type that was generated. public let keyType: GeneratedKeyType + + public init(privateKeyData: Data, publicKeyData: Data, keyType: GeneratedKeyType) { + self.privateKeyData = privateKeyData + self.publicKeyData = publicKeyData + self.keyType = keyType + } } /// The algorithm used when generating a key pair. @@ -18,6 +24,7 @@ public enum GeneratedKeyType: String, Sendable { case ed25519 case ecdsaP256 case secureEnclaveP256 + case ecdsaP384 } // MARK: - KeyGenerator @@ -105,6 +112,10 @@ public struct KeyGenerator: Sendable { case .ecdsaP256, .secureEnclaveP256: algorithmName = "ecdsa-sha2-nistp256" blob = Self.buildECDSAP256Blob(publicKey: keyPair.publicKeyData) + + case .ecdsaP384: + algorithmName = "ecdsa-sha2-nistp384" + blob = Self.buildECDSAP384Blob(publicKey: keyPair.publicKeyData) } let base64 = blob.base64EncodedString() @@ -128,6 +139,17 @@ public struct KeyGenerator: Sendable { return blob } + /// Build the SSH wire-format blob for an ECDSA P-384 public key. + /// + /// Layout: `string "ecdsa-sha2-nistp384"` || `string "nistp384"` || `string ` + private static func buildECDSAP384Blob(publicKey: Data) -> Data { + var blob = Data() + blob.appendSSHString("ecdsa-sha2-nistp384") + blob.appendSSHString("nistp384") + blob.appendSSHBytes(publicKey) + return blob + } + /// Build the SSH wire-format blob for an ECDSA P-256 public key. /// /// Layout: `string "ecdsa-sha2-nistp256"` || `string "nistp256"` || `string ` diff --git a/Packages/SpecttyKeychain/Sources/SpecttyKeychain/SSHKeyImporter.swift b/Packages/SpecttyKeychain/Sources/SpecttyKeychain/SSHKeyImporter.swift index 5d95a12..9999a0a 100644 --- a/Packages/SpecttyKeychain/Sources/SpecttyKeychain/SSHKeyImporter.swift +++ b/Packages/SpecttyKeychain/Sources/SpecttyKeychain/SSHKeyImporter.swift @@ -246,16 +246,61 @@ public struct SSHKeyImporter: Sendable { // curve identifier _ = try reader.readString() let publicKey = try reader.readBytes() - let privateKey = try reader.readBytes() + let privateKeyRaw = try reader.readBytes() // comment (ignored) _ = try? reader.readString() + // Determine the expected sizes for this curve. + let scalarLength: Int + let uncompressedPointLength: Int + switch keyType { + case .ecdsaP256: + scalarLength = 32 + uncompressedPointLength = 65 // 0x04 + 32 + 32 + case .ecdsaP384: + scalarLength = 48 + uncompressedPointLength = 97 // 0x04 + 48 + 48 + default: + throw SSHKeyImportError.invalidKeyFormat + } + + // Validate the uncompressed EC public key point size. + guard publicKey.count == uncompressedPointLength else { + throw SSHKeyImportError.invalidKeyFormat + } + + // OpenSSH encodes the private scalar as an mpint (SSH bignum2): + // - A leading 0x00 byte is prepended when the high bit is set (~50% of keys). + // - Leading zero bytes may be stripped (rare). + // Normalize to the exact fixed-width scalar CryptoKit expects. + let privateKey = normalizeMpint(privateKeyRaw, toLength: scalarLength) + guard privateKey.count == scalarLength else { + throw SSHKeyImportError.invalidKeyFormat + } + return ParsedSSHKey( keyType: keyType, publicKeyData: publicKey, privateKeyData: privateKey ) } + + /// Normalize an SSH mpint-encoded scalar to a fixed-width byte array. + /// + /// Strips a leading `0x00` sign byte (added when the high bit is set) and + /// left-pads short representations to the required length. + private static func normalizeMpint(_ data: Data, toLength length: Int) -> Data { + var trimmed = data[...] + // Strip leading zero bytes that exceed the target length (mpint sign padding). + while trimmed.count > length, trimmed.first == 0x00 { + trimmed = trimmed.dropFirst() + } + // Left-pad if the scalar is shorter than expected (rare but valid). + if trimmed.count < length { + return Data(repeating: 0, count: length - trimmed.count) + trimmed + } + return Data(trimmed) + } } // MARK: - SSHDataReader diff --git a/Packages/SpecttyKeychain/Tests/SpecttyKeychainTests/SSHKeyImporterTests.swift b/Packages/SpecttyKeychain/Tests/SpecttyKeychainTests/SSHKeyImporterTests.swift new file mode 100644 index 0000000..977fe4d --- /dev/null +++ b/Packages/SpecttyKeychain/Tests/SpecttyKeychainTests/SSHKeyImporterTests.swift @@ -0,0 +1,645 @@ +import Testing +import Foundation +import CryptoKit +@testable import SpecttyKeychain + +// MARK: - OpenSSH Blob Builder + +/// Helpers for constructing synthetic OpenSSH private key PEMs in tests. +/// This gives precise control over the binary encoding (e.g. mpint padding) +/// without depending on external tools. +private enum OpenSSHBlobBuilder { + + static func appendUInt32(_ data: inout Data, _ value: UInt32) { + var big = value.bigEndian + data.append(Data(bytes: &big, count: 4)) + } + + static func appendSSHBytes(_ data: inout Data, _ bytes: Data) { + appendUInt32(&data, UInt32(bytes.count)) + data.append(bytes) + } + + static func appendSSHString(_ data: inout Data, _ string: String) { + appendSSHBytes(&data, Data(string.utf8)) + } + + /// Build a complete OpenSSH PEM from public key blob and private section blob. + static func buildPEM(publicKeyBlob: Data, privateSectionBlob: Data) -> String { + var data = Data() + // Magic + data.append(Data("openssh-key-v1\0".utf8)) + // ciphername: "none" + appendSSHString(&data, "none") + // kdfname: "none" + appendSSHString(&data, "none") + // kdfoptions: empty + appendSSHBytes(&data, Data()) + // number of keys: 1 + appendUInt32(&data, 1) + // public key blob + appendSSHBytes(&data, publicKeyBlob) + // private section blob + appendSSHBytes(&data, privateSectionBlob) + + let b64 = data.base64EncodedString(options: [.lineLength76Characters, .endLineWithLineFeed]) + return "-----BEGIN OPENSSH PRIVATE KEY-----\n\(b64)\n-----END OPENSSH PRIVATE KEY-----\n" + } + + /// Build an Ed25519 PEM from raw key material. + static func ed25519PEM(publicKey: Data, seed: Data) -> String { + // Public key blob: type string + public key + var pubBlob = Data() + appendSSHString(&pubBlob, "ssh-ed25519") + appendSSHBytes(&pubBlob, publicKey) + + // Private section: check ints + type + pub + priv (seed||pub) + comment + padding + let checkInt: UInt32 = 0x12345678 + var privSection = Data() + appendUInt32(&privSection, checkInt) + appendUInt32(&privSection, checkInt) + appendSSHString(&privSection, "ssh-ed25519") + appendSSHBytes(&privSection, publicKey) + appendSSHBytes(&privSection, seed + publicKey) // OpenSSH stores seed(32) || public(32) + appendSSHString(&privSection, "test@spectty") + // Pad to 8-byte alignment + let padLen = (8 - (privSection.count % 8)) % 8 + for i in 0.. String { + // Public key blob: type string + curve name + EC point + var pubBlob = Data() + appendSSHString(&pubBlob, keyTypeName) + appendSSHString(&pubBlob, curveName) + appendSSHBytes(&pubBlob, publicPoint) + + // Private section + let checkInt: UInt32 = 0x12345678 + var privSection = Data() + appendUInt32(&privSection, checkInt) + appendUInt32(&privSection, checkInt) + appendSSHString(&privSection, keyTypeName) + appendSSHString(&privSection, curveName) + appendSSHBytes(&privSection, publicPoint) + appendSSHBytes(&privSection, privateScalar) + appendSSHString(&privSection, "test@spectty") + // Pad to 8-byte alignment + let padLen = (8 - (privSection.count % 8)) % 8 + for i in 0.. String { + ecdsaPEM( + keyTypeName: "ecdsa-sha2-nistp256", + curveName: "nistp256", + publicPoint: publicPoint, + privateScalar: privateScalar + ) + } + + static func ecdsaP384PEM(publicPoint: Data, privateScalar: Data) -> String { + ecdsaPEM( + keyTypeName: "ecdsa-sha2-nistp384", + curveName: "nistp384", + publicPoint: publicPoint, + privateScalar: privateScalar + ) + } + + /// Build a PEM with mismatched check integers (for corrupted-data error test). + static func corruptedCheckIntsPEM() -> String { + let key = Curve25519.Signing.PrivateKey() + let publicKey = Data(key.publicKey.rawRepresentation) + let seed = Data(key.rawRepresentation) + + var pubBlob = Data() + appendSSHString(&pubBlob, "ssh-ed25519") + appendSSHBytes(&pubBlob, publicKey) + + var privSection = Data() + appendUInt32(&privSection, 0xAAAAAAAA) + appendUInt32(&privSection, 0xBBBBBBBB) // Mismatch! + appendSSHString(&privSection, "ssh-ed25519") + appendSSHBytes(&privSection, publicKey) + appendSSHBytes(&privSection, seed + publicKey) + appendSSHString(&privSection, "test") + + return buildPEM(publicKeyBlob: pubBlob, privateSectionBlob: privSection) + } +} + +// MARK: - Ed25519 Import Tests + +@Suite("SSHKeyImporter — Ed25519") +struct Ed25519ImportTests { + + @Test("Imports CryptoKit-generated Ed25519 key via synthetic PEM") + func importSyntheticEd25519() throws { + let original = Curve25519.Signing.PrivateKey() + let seed = Data(original.rawRepresentation) + let publicKey = Data(original.publicKey.rawRepresentation) + + let pem = OpenSSHBlobBuilder.ed25519PEM(publicKey: publicKey, seed: seed) + let parsed = try SSHKeyImporter.importKey(from: pem) + + #expect(parsed.keyType == .ed25519) + #expect(parsed.publicKeyData == publicKey) + #expect(parsed.privateKeyData == seed) + #expect(parsed.privateKeyData.count == 32) + } + + @Test("Imported Ed25519 key produces valid CryptoKit key") + func ed25519CryptoKitRoundTrip() throws { + let original = Curve25519.Signing.PrivateKey() + let seed = Data(original.rawRepresentation) + let publicKey = Data(original.publicKey.rawRepresentation) + + let pem = OpenSSHBlobBuilder.ed25519PEM(publicKey: publicKey, seed: seed) + let parsed = try SSHKeyImporter.importKey(from: pem) + + let reconstructed = try Curve25519.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + #expect(Data(reconstructed.publicKey.rawRepresentation) == publicKey) + } + + @Test("Imports real ssh-keygen Ed25519 key") + func importRealEd25519Key() throws { + let pem = """ + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW + QyNTUxOQAAACCCe7ztSqE/WbDn/Ru+j9QjSHhp7D/6OnbCrRl0R+w0awAAAJDMZW0szGVt + LAAAAAtzc2gtZWQyNTUxOQAAACCCe7ztSqE/WbDn/Ru+j9QjSHhp7D/6OnbCrRl0R+w0aw + AAAEDvwyTjwyUukWJIhB99y11HfH67Ac6lnXgFA+7Bh/ITxoJ7vO1KoT9ZsOf9G76P1CNI + eGnsP/o6dsKtGXRH7DRrAAAADHRlc3RAc3BlY3R0eQE= + -----END OPENSSH PRIVATE KEY----- + """ + let parsed = try SSHKeyImporter.importKey(from: pem) + + #expect(parsed.keyType == .ed25519) + #expect(parsed.publicKeyData.count == 32) + #expect(parsed.privateKeyData.count == 32) + + // Verify CryptoKit accepts the key material + let key = try Curve25519.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + #expect(Data(key.publicKey.rawRepresentation) == parsed.publicKeyData) + } +} + +// MARK: - ECDSA P-256 Import Tests + +@Suite("SSHKeyImporter — ECDSA P-256") +struct ECDSAP256ImportTests { + + @Test("Imports P-256 key with exact 32-byte scalar") + func importExactWidthScalar() throws { + let original = P256.Signing.PrivateKey() + let rawScalar = Data(original.rawRepresentation) // 32 bytes + let publicPoint = Data(original.publicKey.x963Representation) // 65 bytes + + let pem = OpenSSHBlobBuilder.ecdsaP256PEM( + publicPoint: publicPoint, + privateScalar: rawScalar + ) + + let parsed = try SSHKeyImporter.importKey(from: pem) + + #expect(parsed.keyType == .ecdsaP256) + #expect(parsed.privateKeyData.count == 32) + #expect(parsed.privateKeyData == rawScalar) + #expect(parsed.publicKeyData.count == 65) + #expect(parsed.publicKeyData == publicPoint) + } + + @Test("Normalizes 33-byte mpint-padded scalar to 32 bytes") + func normalizeMpintPaddedScalar() throws { + let original = P256.Signing.PrivateKey() + let rawScalar = Data(original.rawRepresentation) // 32 bytes + let publicPoint = Data(original.publicKey.x963Representation) // 65 bytes + + // Simulate OpenSSH mpint encoding: prepend 0x00 (happens when high bit is set) + let mpintScalar = Data([0x00]) + rawScalar // 33 bytes + + let pem = OpenSSHBlobBuilder.ecdsaP256PEM( + publicPoint: publicPoint, + privateScalar: mpintScalar + ) + + let parsed = try SSHKeyImporter.importKey(from: pem) + + #expect(parsed.keyType == .ecdsaP256) + #expect(parsed.privateKeyData.count == 32) + #expect(parsed.privateKeyData == rawScalar) + } + + @Test("Mpint-normalized P-256 key produces valid CryptoKit key") + func mpintNormalizedCryptoKitRoundTrip() throws { + let original = P256.Signing.PrivateKey() + let rawScalar = Data(original.rawRepresentation) + let publicPoint = Data(original.publicKey.x963Representation) + + // 33-byte mpint + let mpintScalar = Data([0x00]) + rawScalar + + let pem = OpenSSHBlobBuilder.ecdsaP256PEM( + publicPoint: publicPoint, + privateScalar: mpintScalar + ) + + let parsed = try SSHKeyImporter.importKey(from: pem) + + // Must produce a valid CryptoKit key — this is the exact codepath that + // failed before the mpint normalization fix. + let reconstructed = try P256.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + #expect(Data(reconstructed.publicKey.x963Representation) == publicPoint) + } + + @Test("Left-pads short scalar to 32 bytes") + func leftPadShortScalar() throws { + let original = P256.Signing.PrivateKey() + let rawScalar = Data(original.rawRepresentation) + let publicPoint = Data(original.publicKey.x963Representation) + + // Simulate a scalar with a leading zero byte stripped (rare but valid mpint) + // We strip the first byte (if non-zero, the normalization should left-pad it back) + // To make this test deterministic, construct a scalar with known leading zero. + var shortScalar = rawScalar + // Replace first byte with 0, then drop it to simulate stripped leading zero + shortScalar[shortScalar.startIndex] = 0x00 + let stripped = shortScalar.dropFirst() // 31 bytes + + let pem = OpenSSHBlobBuilder.ecdsaP256PEM( + publicPoint: publicPoint, + privateScalar: Data(stripped) + ) + + let parsed = try SSHKeyImporter.importKey(from: pem) + + #expect(parsed.privateKeyData.count == 32) + // First byte should be zero-padded back + #expect(parsed.privateKeyData.first == 0x00) + } + + @Test("Rejects public key point with wrong size") + func rejectsWrongPublicPointSize() { + let original = P256.Signing.PrivateKey() + let rawScalar = Data(original.rawRepresentation) + // Use a truncated public point (64 bytes instead of 65) + let truncatedPoint = Data(original.publicKey.x963Representation.prefix(64)) + + let pem = OpenSSHBlobBuilder.ecdsaP256PEM( + publicPoint: truncatedPoint, + privateScalar: rawScalar + ) + + #expect { + try SSHKeyImporter.importKey(from: pem) + } throws: { error in + (error as? SSHKeyImportError).flatMap { + if case .invalidKeyFormat = $0 { return true } + return nil + } ?? false + } + } + + @Test("Imports real ssh-keygen ECDSA P-256 key") + func importRealP256Key() throws { + let pem = """ + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS + 1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTUhEIYX54oko+NIaGlC2C2pMaOM7ar + 0eeEpm8/Wv8sXi4/R8cRPE9UcDfxarQD6i/5IslDt0ruwzqvx9VUhQDUAAAAqBMsGs0TLB + rNAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNSEQhhfniiSj40h + oaULYLakxo4ztqvR54Smbz9a/yxeLj9HxxE8T1RwN/FqtAPqL/kiyUO3Su7DOq/H1VSFAN + QAAAAhALNRTUymLunsxiELTNAHi5AGwGC1CstEoyR8nNg/ekomAAAADHRlc3RAc3BlY3R0 + eQECAw== + -----END OPENSSH PRIVATE KEY----- + """ + let parsed = try SSHKeyImporter.importKey(from: pem) + + #expect(parsed.keyType == .ecdsaP256) + #expect(parsed.publicKeyData.count == 65) + #expect(parsed.privateKeyData.count == 32) + + // Verify CryptoKit accepts the key material + let key = try P256.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + #expect(Data(key.publicKey.x963Representation) == parsed.publicKeyData) + } +} + +// MARK: - ECDSA P-384 Import Tests + +@Suite("SSHKeyImporter — ECDSA P-384") +struct ECDSAP384ImportTests { + + @Test("Imports P-384 key with exact 48-byte scalar") + func importExactWidthScalar() throws { + let original = P384.Signing.PrivateKey() + let rawScalar = Data(original.rawRepresentation) // 48 bytes + let publicPoint = Data(original.publicKey.x963Representation) // 97 bytes + + let pem = OpenSSHBlobBuilder.ecdsaP384PEM( + publicPoint: publicPoint, + privateScalar: rawScalar + ) + + let parsed = try SSHKeyImporter.importKey(from: pem) + + #expect(parsed.keyType == .ecdsaP384) + #expect(parsed.privateKeyData.count == 48) + #expect(parsed.privateKeyData == rawScalar) + #expect(parsed.publicKeyData.count == 97) + #expect(parsed.publicKeyData == publicPoint) + } + + @Test("Normalizes 49-byte mpint-padded scalar to 48 bytes") + func normalizeMpintPaddedScalar() throws { + let original = P384.Signing.PrivateKey() + let rawScalar = Data(original.rawRepresentation) // 48 bytes + let publicPoint = Data(original.publicKey.x963Representation) // 97 bytes + + // Simulate OpenSSH mpint encoding: prepend 0x00 + let mpintScalar = Data([0x00]) + rawScalar // 49 bytes + + let pem = OpenSSHBlobBuilder.ecdsaP384PEM( + publicPoint: publicPoint, + privateScalar: mpintScalar + ) + + let parsed = try SSHKeyImporter.importKey(from: pem) + + #expect(parsed.keyType == .ecdsaP384) + #expect(parsed.privateKeyData.count == 48) + #expect(parsed.privateKeyData == rawScalar) + } + + @Test("Mpint-normalized P-384 key produces valid CryptoKit key") + func mpintNormalizedCryptoKitRoundTrip() throws { + let original = P384.Signing.PrivateKey() + let rawScalar = Data(original.rawRepresentation) + let publicPoint = Data(original.publicKey.x963Representation) + + let mpintScalar = Data([0x00]) + rawScalar + + let pem = OpenSSHBlobBuilder.ecdsaP384PEM( + publicPoint: publicPoint, + privateScalar: mpintScalar + ) + + let parsed = try SSHKeyImporter.importKey(from: pem) + + let reconstructed = try P384.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + #expect(Data(reconstructed.publicKey.x963Representation) == publicPoint) + } + + @Test("Rejects public key point with wrong size") + func rejectsWrongPublicPointSize() { + let original = P384.Signing.PrivateKey() + let rawScalar = Data(original.rawRepresentation) + // Use 65-byte point (P-256 size) instead of 97-byte (P-384 size) + let wrongSizePoint = Data(repeating: 0x04, count: 65) + + let pem = OpenSSHBlobBuilder.ecdsaP384PEM( + publicPoint: wrongSizePoint, + privateScalar: rawScalar + ) + + #expect { + try SSHKeyImporter.importKey(from: pem) + } throws: { error in + (error as? SSHKeyImportError).flatMap { + if case .invalidKeyFormat = $0 { return true } + return nil + } ?? false + } + } + + @Test("Imports real ssh-keygen ECDSA P-384 key") + func importRealP384Key() throws { + let pem = """ + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAiAAAABNlY2RzYS + 1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQRsjn+/8SQJ8CpCG0XhHiwBiCWfWsvG + NiW9IFYInCbFkiF8NSKC2RVHYgqkl9x0vLotw8LzU+8JsF+I/8eMfQKPyUgfN7bUvqsOYn + 3Rwk9bb1dv4qv2QBb54ikc9oIyByMAAADYT3bzwE9288AAAAATZWNkc2Etc2hhMi1uaXN0 + cDM4NAAAAAhuaXN0cDM4NAAAAGEEbI5/v/EkCfAqQhtF4R4sAYgln1rLxjYlvSBWCJwmxZ + IhfDUigtkVR2IKpJfcdLy6LcPC81PvCbBfiP/HjH0Cj8lIHze21L6rDmJ90cJPW29Xb+Kr + 9kAW+eIpHPaCMgcjAAAAMHMxYvcsZdYkW0+S5pACFVZSHl31NMLPQrxl5JaYv8+vOAamnq + YOTnjEsU/VG6+WJwAAAAx0ZXN0QHNwZWN0dHkBAgME + -----END OPENSSH PRIVATE KEY----- + """ + let parsed = try SSHKeyImporter.importKey(from: pem) + + #expect(parsed.keyType == .ecdsaP384) + #expect(parsed.publicKeyData.count == 97) + #expect(parsed.privateKeyData.count == 48) + + // Verify CryptoKit accepts the key material + let key = try P384.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + #expect(Data(key.publicKey.x963Representation) == parsed.publicKeyData) + } +} + +// MARK: - Error Handling Tests + +@Suite("SSHKeyImporter — Error handling") +struct ImportErrorTests { + + @Test("Rejects text without PEM markers") + func rejectsMissingPEMMarkers() { + #expect { + try SSHKeyImporter.importKey(from: "this is not a PEM key") + } throws: { error in + (error as? SSHKeyImportError).flatMap { + if case .invalidPEMFormat = $0 { return true } + return nil + } ?? false + } + } + + @Test("Rejects invalid base64 within PEM markers") + func rejectsInvalidBase64() { + let pem = """ + -----BEGIN OPENSSH PRIVATE KEY----- + !!!not-valid-base64!!! + -----END OPENSSH PRIVATE KEY----- + """ + #expect { + try SSHKeyImporter.importKey(from: pem) + } throws: { error in + (error as? SSHKeyImportError).flatMap { + if case .base64DecodingFailed = $0 { return true } + return nil + } ?? false + } + } + + @Test("Rejects encrypted keys") + func rejectsEncryptedKeys() { + let pem = """ + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABBItPmB+S + BVmxI1/lf6aQ/fAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAICMO4HcX6AyvXrZ2 + MRTVAw51W9lS+9H8Sf1LvvrfVs9VAAAAkITYFxZFA6ogMBJqLxHPEhVcFHsY0NtlrV/CdS + T8IJDlpdSMlOaN0lMBvGWUo7cyIytn3SDEGxswH3VEvA05g3drfLBQtdy1lQM6aQ3udrk4 + yYtR+LxtXz/2TzqkCul/brqbOwIVYYEt02YBj7EGKtBc7VlHz1LO29yvM+tdy02WkCxgZq + wO33rJwepfGNt1DQ== + -----END OPENSSH PRIVATE KEY----- + """ + #expect { + try SSHKeyImporter.importKey(from: pem) + } throws: { error in + (error as? SSHKeyImportError).flatMap { + if case .encryptedKeysNotSupported = $0 { return true } + return nil + } ?? false + } + } + + @Test("Rejects RSA keys") + func rejectsRSAKeys() { + let pem = """ + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAlwAAAAdzc2gtcn + NhAAAAAwEAAQAAAIEA12h9FPjbIxE/dB1IQ1/sXvGJ27BxKYYMQA91dyw/OW7cxFu9e+DQ + dTegqOR2eIwS124bbfbFQu7yagmrUs1IYlNwjv3Hg5YpTvYS5gxeTOlq7l9LRFrQxpmeif + +9/fpHgtIm8JK1MvTqKRTDGo4XELMc/uEilSs3GqZXlztoO+sAAAIIVt5iCVbeYgkAAAAH + c3NoLXJzYQAAAIEA12h9FPjbIxE/dB1IQ1/sXvGJ27BxKYYMQA91dyw/OW7cxFu9e+DQdT + egqOR2eIwS124bbfbFQu7yagmrUs1IYlNwjv3Hg5YpTvYS5gxeTOlq7l9LRFrQxpmeif+9 + /fpHgtIm8JK1MvTqKRTDGo4XELMc/uEilSs3GqZXlztoO+sAAAADAQABAAAAgQCcFMMlch + he9X1z5k/ZOeUs+nl4rQWiH9Y6iLkFrBL3y6O9x/epjkGd3bvVBQ3u1RhF7yuC518R28/d + E7qHGeYKvLPuZGkCfO6z2DCWayvVtV5xORrTZJWIh8cRS6mO6kFFArttERb9MVeXNtwOdT + Yvl0L1dLyLXGWGfaCCnFcN+QAAAEAjUaanI5dimT27q1eu78vmxQUj04aQQ5FQdDF1U+zH + oH1sFG2hDeER182uy3sooWhjltDLCyrR0A9RuGKoZwCXAAAAQQDtegUaJnQ3P0kaS2NfZv + cQK5x1844rmwLmcCnzxn2gx0HIOf0p7zzBjuZQtE2P6IAf3LwjZ3A9jOfdMnnIg/wNAAAA + QQDoNc0IF2LzzOQysJyfo2ukpEMl66L5aKNiiOOJ8XthUn/oc2m3XLv4n45E4OPEqZIzay + Ww4mfwLXlXdeyQgYHXAAAADHRlc3RAc3BlY3R0eQECAwQFBg== + -----END OPENSSH PRIVATE KEY----- + """ + #expect { + try SSHKeyImporter.importKey(from: pem) + } throws: { error in + (error as? SSHKeyImportError).flatMap { + if case .rsaNotSupported = $0 { return true } + return nil + } ?? false + } + } + + @Test("Rejects mismatched check integers") + func rejectsMismatchedCheckInts() { + let pem = OpenSSHBlobBuilder.corruptedCheckIntsPEM() + #expect { + try SSHKeyImporter.importKey(from: pem) + } throws: { error in + (error as? SSHKeyImportError).flatMap { + if case .corruptedKeyData = $0 { return true } + return nil + } ?? false + } + } + + @Test("Rejects data without OpenSSH magic") + func rejectsMissingMagic() { + // Valid base64 but not an OpenSSH key (just "hello world") + let bogusB64 = Data("hello world, this is not a key".utf8).base64EncodedString() + let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\n\(bogusB64)\n-----END OPENSSH PRIVATE KEY-----\n" + #expect { + try SSHKeyImporter.importKey(from: pem) + } throws: { error in + (error as? SSHKeyImportError).flatMap { + if case .invalidKeyFormat = $0 { return true } + return nil + } ?? false + } + } + + @Test("Rejects empty PEM content") + func rejectsEmptyPEM() { + #expect { + try SSHKeyImporter.importKey(from: "") + } throws: { error in + error is SSHKeyImportError + } + } +} + +// MARK: - End-to-End CryptoKit Compatibility Tests + +@Suite("SSHKeyImporter — CryptoKit end-to-end") +struct CryptoKitCompatibilityTests { + + @Test("Ed25519 imported key can sign and verify") + func ed25519SignVerify() throws { + let original = Curve25519.Signing.PrivateKey() + let pem = OpenSSHBlobBuilder.ed25519PEM( + publicKey: Data(original.publicKey.rawRepresentation), + seed: Data(original.rawRepresentation) + ) + + let parsed = try SSHKeyImporter.importKey(from: pem) + let key = try Curve25519.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + + let message = Data("test message".utf8) + let signature = try key.signature(for: message) + let valid = original.publicKey.isValidSignature(signature, for: message) + #expect(valid) + } + + @Test("P-256 imported key (mpint-normalized) can sign and verify") + func p256MpintSignVerify() throws { + let original = P256.Signing.PrivateKey() + let rawScalar = Data(original.rawRepresentation) + let publicPoint = Data(original.publicKey.x963Representation) + + // Use mpint-padded scalar (33 bytes) + let pem = OpenSSHBlobBuilder.ecdsaP256PEM( + publicPoint: publicPoint, + privateScalar: Data([0x00]) + rawScalar + ) + + let parsed = try SSHKeyImporter.importKey(from: pem) + let key = try P256.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + + let message = Data("test message".utf8) + let signature = try key.signature(for: SHA256.hash(data: message)) + let valid = original.publicKey.isValidSignature(signature, for: SHA256.hash(data: message)) + #expect(valid) + } + + @Test("P-384 imported key (mpint-normalized) can sign and verify") + func p384MpintSignVerify() throws { + let original = P384.Signing.PrivateKey() + let rawScalar = Data(original.rawRepresentation) + let publicPoint = Data(original.publicKey.x963Representation) + + // Use mpint-padded scalar (49 bytes) + let pem = OpenSSHBlobBuilder.ecdsaP384PEM( + publicPoint: publicPoint, + privateScalar: Data([0x00]) + rawScalar + ) + + let parsed = try SSHKeyImporter.importKey(from: pem) + let key = try P384.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + + let message = Data("test message".utf8) + let signature = try key.signature(for: SHA384.hash(data: message)) + let valid = original.publicKey.isValidSignature(signature, for: SHA384.hash(data: message)) + #expect(valid) + } +} diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshSessionState.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshSessionState.swift index 809d17a..37cf387 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshSessionState.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshSessionState.swift @@ -22,6 +22,9 @@ public struct MoshSessionState: Codable, Sendable { public let sshPort: Int public let sshUsername: String + // Auth method for resumption; nil defaults to password. + public let authMethodType: SSHAuthMethodType? + // Staleness check public let savedAt: Date @@ -38,6 +41,7 @@ public struct MoshSessionState: Codable, Sendable { sshHost: String, sshPort: Int, sshUsername: String, + authMethodType: SSHAuthMethodType? = nil, savedAt: Date = Date() ) { self.sessionID = sessionID @@ -52,6 +56,7 @@ public struct MoshSessionState: Codable, Sendable { self.sshHost = sshHost self.sshPort = sshPort self.sshUsername = sshUsername + self.authMethodType = authMethodType self.savedAt = savedAt } } diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshTransport.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshTransport.swift index 28434f7..1da58e0 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshTransport.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/Mosh/MoshTransport.swift @@ -207,6 +207,10 @@ public final class MoshTransport: ResumableTransport, @unchecked Sendable { return nil } let sspState = ssp.exportState() + let authType: SSHAuthMethodType? = switch config.authMethod { + case .password: nil + case .publicKey: .publicKey + } return MoshSessionState( sessionID: sessionID, connectionID: connectionID, @@ -220,6 +224,7 @@ public final class MoshTransport: ResumableTransport, @unchecked Sendable { sshHost: config.host, sshPort: config.port, sshUsername: config.username, + authMethodType: authType, savedAt: Date() ) } diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift index 9c00a17..64cad4a 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHAuthentication.swift @@ -2,6 +2,12 @@ import Foundation import NIOCore import NIOSSH +/// Lightweight tag identifying the SSH auth method (for serialisation). +public enum SSHAuthMethodType: String, Codable, Sendable { + case password + case publicKey +} + /// SSH authentication method offered by the client. public enum SSHAuthMethod: Sendable { case password(String) diff --git a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHTransport.swift b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHTransport.swift index 31f56c4..4e9b67e 100644 --- a/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHTransport.swift +++ b/Packages/SpecttyTransport/Sources/SpecttyTransport/SSH/SSHTransport.swift @@ -31,7 +31,7 @@ public enum SSHTransportError: Error, LocalizedError { switch self { case .notConnected: return "SSH transport is not connected" case .alreadyConnected: return "SSH transport is already connected" - case .authenticationFailed: return "SSH authentication failed — check username and password" + case .authenticationFailed: return "SSH authentication failed — check your credentials" case .channelCreationFailed: return "Failed to create SSH channel" case .connectionClosed: return "SSH connection was closed" case .connectionFailed(let detail): return "SSH connection failed: \(detail)" diff --git a/Spectty/Models/ServerConnection.swift b/Spectty/Models/ServerConnection.swift index 2722497..3f39a50 100644 --- a/Spectty/Models/ServerConnection.swift +++ b/Spectty/Models/ServerConnection.swift @@ -12,6 +12,11 @@ enum AuthMethod: String, Codable, CaseIterable, Sendable { case password = "Password" case publicKey = "Public Key" case keyboardInteractive = "Keyboard Interactive" + + /// Cases shown in the connection editor picker. + /// `keyboardInteractive` is kept for SwiftData backwards compatibility + /// but not offered in the UI (it behaves identically to `.password`). + static let visibleCases: [AuthMethod] = [.password, .publicKey] } /// Persistent model for a saved server connection. @@ -48,6 +53,11 @@ final class ServerConnection { @Transient var password: String = "" + /// Transient private key PEM — not persisted to SwiftData, only lives in memory + /// for the duration of an editing session. + @Transient + var privateKeyPEM: String = "" + init( name: String = "", host: String = "", diff --git a/Spectty/ViewModels/SessionManager.swift b/Spectty/ViewModels/SessionManager.swift index ac56c34..c1cd060 100644 --- a/Spectty/ViewModels/SessionManager.swift +++ b/Spectty/ViewModels/SessionManager.swift @@ -1,4 +1,6 @@ import Foundation +import CryptoKit +import NIOSSH import SpecttyTransport import SpecttyKeychain @@ -20,20 +22,35 @@ final class SessionManager { /// Create and start a new session for a server connection. func connect(to connection: ServerConnection) async throws -> TerminalSession { - // Resolve password: use transient value if set, otherwise load from Keychain. - var password = connection.password - if password.isEmpty, connection.authMethod == .password { - let account = "password-\(connection.id.uuidString)" - if let data = try? await keychain.load(account: account) { - password = String(data: data, encoding: .utf8) ?? "" + let authMethod: SSHAuthMethod + + switch connection.authMethod { + case .publicKey: + let account = "private-key-\(connection.id.uuidString)" + guard let pemData = try? await keychain.load(account: account), + let pemString = String(data: pemData, encoding: .utf8) else { + throw SSHTransportError.authenticationFailed + } + let parsedKey = try SSHKeyImporter.importKey(from: pemString) + let nioKey = try Self.makeNIOSSHPrivateKey(from: parsedKey) + authMethod = .publicKey(nioKey) + + case .password, .keyboardInteractive: + var password = connection.password + if password.isEmpty { + let account = "password-\(connection.id.uuidString)" + if let data = try? await keychain.load(account: account) { + password = String(data: data, encoding: .utf8) ?? "" + } } + authMethod = .password(password) } let config = SSHConnectionConfig( host: connection.host, port: connection.port, username: connection.username, - authMethod: .password(password) + authMethod: authMethod ) let transport: any TerminalTransport @@ -46,8 +63,9 @@ final class SessionManager { } let scrollbackLines = UserDefaults.standard.integer(forKey: "scrollbackLines") + let transportType = connection.transport let transportFactory: @Sendable () -> any TerminalTransport = { - switch connection.transport { + switch transportType { case .ssh: return SSHTransport(config: config) case .mosh: @@ -137,18 +155,34 @@ final class SessionManager { /// Resume a saved mosh session. func resume(_ savedState: MoshSessionState) async throws -> TerminalSession { - // Look up SSH password from Keychain by connectionID - var password = "" - let account = "password-\(savedState.connectionID)" - if let data = try? await keychain.load(account: account) { - password = String(data: data, encoding: .utf8) ?? "" + let authMethod: SSHAuthMethod + + if savedState.authMethodType == .publicKey { + let account = "private-key-\(savedState.connectionID)" + if let pemData = try? await keychain.load(account: account), + let pemString = String(data: pemData, encoding: .utf8), + let parsedKey = try? SSHKeyImporter.importKey(from: pemString), + let nioKey = try? Self.makeNIOSSHPrivateKey(from: parsedKey) { + authMethod = .publicKey(nioKey) + } else { + throw SSHTransportError.connectionFailed( + "Private key no longer available in Keychain. Please re-enter your key in the connection editor." + ) + } + } else { + var password = "" + let account = "password-\(savedState.connectionID)" + if let data = try? await keychain.load(account: account) { + password = String(data: data, encoding: .utf8) ?? "" + } + authMethod = .password(password) } let config = SSHConnectionConfig( host: savedState.sshHost, port: savedState.sshPort, username: savedState.sshUsername, - authMethod: .password(password) + authMethod: authMethod ) let transport = MoshTransport(resuming: savedState, config: config) @@ -182,6 +216,31 @@ final class SessionManager { return session } + // MARK: - Key Conversion + + /// Convert a parsed SSH key into a NIOSSHPrivateKey for use with NIOSSH. + private static func makeNIOSSHPrivateKey(from parsedKey: ParsedSSHKey) throws -> NIOSSHPrivateKey { + switch parsedKey.keyType { + case .ed25519: + let privateKey = try Curve25519.Signing.PrivateKey( + rawRepresentation: parsedKey.privateKeyData + ) + return NIOSSHPrivateKey(ed25519Key: privateKey) + case .ecdsaP256: + let privateKey = try P256.Signing.PrivateKey( + rawRepresentation: parsedKey.privateKeyData + ) + return NIOSSHPrivateKey(p256Key: privateKey) + case .ecdsaP384: + let privateKey = try P384.Signing.PrivateKey( + rawRepresentation: parsedKey.privateKeyData + ) + return NIOSSHPrivateKey(p384Key: privateKey) + case .rsa: + throw SSHTransportError.authenticationFailed + } + } + /// Save all active mosh sessions for later resumption. /// Must complete synchronously (or await) before returning — iOS may /// suspend the app immediately after the scenePhase goes to .background. diff --git a/Spectty/Views/ConnectionEditorView.swift b/Spectty/Views/ConnectionEditorView.swift index 8d5c80a..81a19fa 100644 --- a/Spectty/Views/ConnectionEditorView.swift +++ b/Spectty/Views/ConnectionEditorView.swift @@ -1,4 +1,5 @@ import SwiftUI +import CryptoKit import SpecttyKeychain struct ConnectionEditorView: View { @@ -7,6 +8,10 @@ struct ConnectionEditorView: View { let onSave: (ServerConnection) -> Void @Environment(\.dismiss) private var dismiss + @State private var keyValidationError: String? + @State private var derivedPublicKey: String? + @State private var hasExistingKey = false + var body: some View { NavigationStack { Form { @@ -32,7 +37,7 @@ struct ConnectionEditorView: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() Picker("Method", selection: $connection.authMethod) { - ForEach(AuthMethod.allCases, id: \.self) { method in + ForEach(AuthMethod.visibleCases, id: \.self) { method in Text(method.rawValue).tag(method) } } @@ -42,6 +47,68 @@ struct ConnectionEditorView: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() } + + if connection.authMethod == .publicKey { + if hasExistingKey && connection.privateKeyPEM.isEmpty { + // Key is in Keychain — don't expose it, just show status. + HStack { + Label("Private key stored", systemImage: "key.fill") + .font(.body) + .foregroundStyle(.secondary) + Spacer() + Button("Replace") { + hasExistingKey = false + } + .font(.caption) + } + } else { + TextEditor(text: $connection.privateKeyPEM) + .font(.system(.caption, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .frame(minHeight: 120) + .overlay(alignment: .topLeading) { + if connection.privateKeyPEM.isEmpty { + Text("Paste private key (PEM format)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + .padding(.top, 8) + .padding(.leading, 4) + .allowsHitTesting(false) + } + } + .onChange(of: connection.privateKeyPEM) { + validatePrivateKey() + } + + if let error = keyValidationError { + Label(error, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.red) + } + + if let pubKey = derivedPublicKey { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Public Key") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Button { + UIPasteboard.general.string = pubKey + } label: { + Label("Copy", systemImage: "doc.on.doc") + .font(.caption) + } + } + Text(pubKey) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } + } } Section("Transport") { @@ -75,17 +142,29 @@ struct ConnectionEditorView: View { ToolbarItem(placement: .confirmationAction) { Button("Save") { Task { - await savePasswordToKeychain() + await saveCredentialsToKeychain() onSave(connection) dismiss() } } - .disabled(connection.host.isEmpty || connection.username.isEmpty) + .disabled(!isSaveEnabled) } } + .onAppear { + loadExistingKey() + } } } + private var isSaveEnabled: Bool { + guard !connection.host.isEmpty, !connection.username.isEmpty else { return false } + if connection.authMethod == .publicKey { + // Allow save if a key is already in Keychain or a new valid key was pasted. + return hasExistingKey || (!connection.privateKeyPEM.isEmpty && keyValidationError == nil && derivedPublicKey != nil) + } + return true + } + private var startupCommandBinding: Binding { Binding( get: { connection.startupCommand ?? "" }, @@ -93,13 +172,111 @@ struct ConnectionEditorView: View { ) } - private func savePasswordToKeychain() async { - guard connection.authMethod == .password, !connection.password.isEmpty else { return } + private func validatePrivateKey() { + let pem = connection.privateKeyPEM.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pem.isEmpty else { + keyValidationError = nil + derivedPublicKey = nil + return + } + + do { + let parsed = try SSHKeyImporter.importKey(from: pem) + + // Trial CryptoKit construction — surface encoding errors at edit time, + // not at connection time — and resolve the GeneratedKeyType in one pass. + let generatedKeyType: GeneratedKeyType + switch parsed.keyType { + case .ed25519: + _ = try Curve25519.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + generatedKeyType = .ed25519 + case .ecdsaP256: + _ = try P256.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + generatedKeyType = .ecdsaP256 + case .ecdsaP384: + _ = try P384.Signing.PrivateKey(rawRepresentation: parsed.privateKeyData) + generatedKeyType = .ecdsaP384 + case .rsa: + // Unreachable: SSHKeyImporter.importKey already throws .rsaNotSupported + keyValidationError = "RSA keys are not supported. Use Ed25519 or ECDSA." + derivedPublicKey = nil + return + } + + keyValidationError = nil + + let keyPair = GeneratedKeyPair( + privateKeyData: parsed.privateKeyData, + publicKeyData: parsed.publicKeyData, + keyType: generatedKeyType + ) + derivedPublicKey = KeyGenerator.openSSHPublicKey(for: keyPair) + } catch let error as SSHKeyImportError { + derivedPublicKey = nil + switch error { + case .invalidPEMFormat: + keyValidationError = "Invalid format. Paste the full private key including -----BEGIN OPENSSH PRIVATE KEY----- markers." + case .base64DecodingFailed: + keyValidationError = "Invalid key data — base64 decoding failed." + case .invalidKeyFormat: + keyValidationError = "Invalid OpenSSH key format." + case .encryptedKeysNotSupported: + keyValidationError = "Encrypted keys are not supported. Decrypt with:\nssh-keygen -p -f your_key" + case .rsaNotSupported: + keyValidationError = "RSA keys are not supported. Use Ed25519 or ECDSA." + case .unsupportedKeyType(let type): + keyValidationError = "Unsupported key type: \(type)" + case .corruptedKeyData: + keyValidationError = "Key data appears corrupted." + } + } catch { + derivedPublicKey = nil + keyValidationError = "Failed to parse key: \(error.localizedDescription)" + } + } + + private func loadExistingKey() { + guard !isNew, + connection.authMethod == .publicKey, + connection.privateKeyKeychainAccount != nil else { return } + hasExistingKey = true + connection.privateKeyPEM = "" + } + + private func saveCredentialsToKeychain() async { let keychain = KeychainManager() - let account = "password-\(connection.id.uuidString)" - try? await keychain.saveOrUpdate( - key: Data(connection.password.utf8), - account: account - ) + let uuid = connection.id.uuidString + + switch connection.authMethod { + case .password: + // Clean up the key from the other auth method. + try? await keychain.delete(account: "private-key-\(uuid)") + connection.privateKeyKeychainAccount = nil + + guard !connection.password.isEmpty else { return } + let account = "password-\(uuid)" + try? await keychain.saveOrUpdate( + key: Data(connection.password.utf8), + account: account + ) + + case .publicKey: + // Clean up the credential from the other auth method. + try? await keychain.delete(account: "password-\(uuid)") + + guard !connection.privateKeyPEM.isEmpty else { return } + let account = "private-key-\(uuid)" + try? await keychain.saveOrUpdate( + key: Data(connection.privateKeyPEM.utf8), + account: account + ) + connection.privateKeyKeychainAccount = account + + case .keyboardInteractive: + // Clean up credentials from both other methods. + try? await keychain.delete(account: "password-\(uuid)") + try? await keychain.delete(account: "private-key-\(uuid)") + connection.privateKeyKeychainAccount = nil + } } } diff --git a/Spectty/Views/ConnectionListView.swift b/Spectty/Views/ConnectionListView.swift index 74dd899..3457962 100644 --- a/Spectty/Views/ConnectionListView.swift +++ b/Spectty/Views/ConnectionListView.swift @@ -223,24 +223,38 @@ struct ConnectionListView: View { } private func connectTo(_ connection: ServerConnection) { - // If password auth and no transient password, check Keychain. If missing, prompt. - if connection.authMethod == .password && connection.password.isEmpty { - let account = "password-\(connection.id.uuidString)" + switch connection.authMethod { + case .publicKey: + // Verify the private key is stored in Keychain before connecting. + let account = "private-key-\(connection.id.uuidString)" Task { let keychain = KeychainManager() let stored = try? await keychain.load(account: account) if stored == nil { - // No stored password — prompt user. - connectPassword = "" - pendingConnection = connection - showPasswordPrompt = true + connectionError = "No private key found. Edit the connection to add one." return } - // Password is in Keychain, SessionManager will load it. await doConnect(connection) } - } else { - Task { await doConnect(connection) } + + case .password, .keyboardInteractive: + // If password auth and no transient password, check Keychain. If missing, prompt. + if connection.password.isEmpty { + let account = "password-\(connection.id.uuidString)" + Task { + let keychain = KeychainManager() + let stored = try? await keychain.load(account: account) + if stored == nil { + connectPassword = "" + pendingConnection = connection + showPasswordPrompt = true + return + } + await doConnect(connection) + } + } else { + Task { await doConnect(connection) } + } } }