Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Packages/SpecttyKeychain/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
),
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@ 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.
public enum GeneratedKeyType: String, Sendable {
case ed25519
case ecdsaP256
case secureEnclaveP256
case ecdsaP384
}

// MARK: - KeyGenerator
Expand Down Expand Up @@ -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()
Expand All @@ -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 <EC point>`
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 <EC point>`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading