This commit is contained in:
Max Goedjen 2025-08-24 14:42:43 -07:00
parent 7d6223a327
commit 698b7571c8
No known key found for this signature in database
13 changed files with 170 additions and 85 deletions

View File

@ -9,7 +9,8 @@ public final class Agent: Sendable {
private let storeList: SecretStoreList
private let witness: SigningWitness?
private let writer = OpenSSHKeyWriter()
private let publicKeyWriter = OpenSSHPublicKeyWriter()
private let signatureWriter = OpenSSHSignatureWriter()
private let requestTracer = SigningRequestTracer()
private let certificateHandler = OpenSSHCertificateHandler()
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "Agent")
@ -43,7 +44,7 @@ extension Agent {
guard data.count > 4 else { return false}
let requestTypeInt = data[4]
guard let requestType = SSHAgent.RequestType(rawValue: requestTypeInt) else {
writer.write(OpenSSHKeyWriter().lengthAndData(of: SSHAgent.ResponseType.agentFailure.data))
writer.write(SSHAgent.ResponseType.agentFailure.data.lengthAndData)
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
return true
}
@ -75,8 +76,7 @@ extension Agent {
response.append(SSHAgent.ResponseType.agentFailure.data)
logger.debug("Agent returned \(SSHAgent.ResponseType.agentFailure.debugDescription)")
}
let full = OpenSSHKeyWriter().lengthAndData(of: response)
return full
return response.lengthAndData
}
}
@ -92,14 +92,14 @@ extension Agent {
var keyData = Data()
for secret in secrets {
let keyBlob = writer.data(secret: secret)
let curveData = Data(writer.curveType(for: secret.keyType).utf8)
keyData.append(writer.lengthAndData(of: keyBlob))
keyData.append(writer.lengthAndData(of: curveData))
let keyBlob = publicKeyWriter.data(secret: secret)
let curveData = publicKeyWriter.openSSHIdentifier(for: secret.keyType)
keyData.append(keyBlob.lengthAndData)
keyData.append(curveData.lengthAndData)
if let (certificateData, name) = try? await certificateHandler.keyBlobAndName(for: secret) {
keyData.append(writer.lengthAndData(of: certificateData))
keyData.append(writer.lengthAndData(of: name))
keyData.append(certificateData.lengthAndData)
keyData.append(name.lengthAndData)
count += 1
}
}
@ -137,7 +137,7 @@ extension Agent {
let dataToSign = reader.readNextChunk()
let rawRepresentation = try await store.sign(data: dataToSign, with: secret, for: provenance)
let curveData = Data(writer.curveType(for: secret.keyType).utf8)
let curveData = publicKeyWriter.openSSHIdentifier(for: secret.keyType)
let signedData: Data
if secret.keyType.algorithm == .ecdsa {
@ -155,20 +155,20 @@ extension Agent {
}
var signatureChunk = Data()
signatureChunk.append(writer.lengthAndData(of: r))
signatureChunk.append(writer.lengthAndData(of: s))
signatureChunk.append(r.lengthAndData)
signatureChunk.append(s.lengthAndData)
var mutSignedData = Data()
var sub = Data()
sub.append(writer.lengthAndData(of: curveData))
sub.append(writer.lengthAndData(of: signatureChunk))
mutSignedData.append(writer.lengthAndData(of: sub))
sub.append(curveData.lengthAndData)
sub.append(signatureChunk.lengthAndData)
mutSignedData.append(sub.lengthAndData)
signedData = mutSignedData
} else {
var mutSignedData = Data()
var sub = Data()
sub.append(writer.lengthAndData(of: Data("rsa-sha2-512".utf8)))
sub.append(writer.lengthAndData(of: rawRepresentation))
mutSignedData.append(writer.lengthAndData(of: sub))
sub.append("rsa-sha2-512".lengthAndData)
sub.append(rawRepresentation.lengthAndData)
mutSignedData.append(sub.lengthAndData)
signedData = mutSignedData
}
@ -203,7 +203,7 @@ extension Agent {
func secret(matching hash: Data) async -> (AnySecretStore, AnySecret)? {
for store in await storeList.stores {
let allMatching = await store.secrets.filter { secret in
hash == writer.data(secret: secret)
hash == publicKeyWriter.data(secret: secret)
}
if let matching = allMatching.first {
return (store, matching)

View File

@ -22,7 +22,7 @@ SecretKit is a collection of protocols describing secrets and stores.
### OpenSSH
- ``OpenSSHKeyWriter``
- ``OpenSSHPublicKeyWriter``
- ``OpenSSHReader``
### Signing Process

View File

@ -52,16 +52,16 @@ public extension SecretStore {
/// - Parameters:
/// - secret: The secret which will be used for signing.
/// - Returns: The appropriate algorithm.
func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm {
func signatureAlgorithm(for secret: SecretType) -> SecKeyAlgorithm? {
switch (secret.keyType.algorithm, secret.keyType.size) {
case (.ecdsa, 256):
return .ecdsaSignatureMessageX962SHA256
.ecdsaSignatureMessageX962SHA256
case (.ecdsa, 384):
return .ecdsaSignatureMessageX962SHA384
.ecdsaSignatureMessageX962SHA384
case (.rsa, 2048):
return .rsaSignatureMessagePKCS1v15SHA512
.rsaSignatureMessagePKCS1v15SHA512
default:
fatalError()
nil
}
}

View File

@ -0,0 +1,23 @@
import Foundation
extension Data {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Returns: OpenSSH data.
package var lengthAndData: Data {
let rawLength = UInt32(count)
var endian = rawLength.bigEndian
return Data(bytes: &endian, count: UInt32.bitWidth/8) + self
}
}
extension String {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Returns: OpenSSH data.
package var lengthAndData: Data {
Data(utf8).lengthAndData
}
}

View File

@ -6,7 +6,7 @@ public actor OpenSSHCertificateHandler: Sendable {
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory())
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "OpenSSHCertificateHandler")
private let writer = OpenSSHKeyWriter()
private let writer = OpenSSHPublicKeyWriter()
private var keyBlobsAndNames: [AnySecret: (Data, Data)] = [:]
/// Initializes an OpenSSHCertificateHandler.
@ -40,10 +40,10 @@ public actor OpenSSHCertificateHandler: Sendable {
let curveIdentifier = reader.readNextChunk()
let publicKey = reader.readNextChunk()
let curveType = Data(certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "").utf8)
return writer.lengthAndData(of: curveType) +
writer.lengthAndData(of: curveIdentifier) +
writer.lengthAndData(of: publicKey)
let openSSHIdentifier = certType.replacingOccurrences(of: "-cert-v01@openssh.com", with: "")
return openSSHIdentifier.lengthAndData +
curveIdentifier.lengthAndData +
publicKey.lengthAndData
default:
return nil
}

View File

@ -1,8 +1,8 @@
import Foundation
import CryptoKit
/// Generates OpenSSH representations of Secrets.
public struct OpenSSHKeyWriter: Sendable {
/// Generates OpenSSH representations of the public key sof secrets.
public struct OpenSSHPublicKeyWriter: Sendable {
/// Initializes the writer.
public init() {
@ -13,15 +13,18 @@ public struct OpenSSHKeyWriter: Sendable {
public func data<SecretType: Secret>(secret: SecretType) -> Data {
switch secret.keyType.algorithm {
case .ecdsa:
lengthAndData(of: Data(curveType(for: secret.keyType).utf8)) +
lengthAndData(of: Data(curveIdentifier(for: secret.keyType).utf8)) +
lengthAndData(of: secret.publicKey)
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
openSSHIdentifier(for: secret.keyType).lengthAndData +
("nistp" + String(describing: secret.keyType.size)).lengthAndData +
secret.publicKey.lengthAndData
case .mldsa:
lengthAndData(of: Data(curveType(for: secret.keyType).utf8)) +
lengthAndData(of: secret.publicKey)
// https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt
openSSHIdentifier(for: secret.keyType).lengthAndData +
secret.publicKey.lengthAndData
case .rsa:
lengthAndData(of: Data(curveType(for: secret.keyType).utf8)) +
rsa(secret: secret)
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
openSSHIdentifier(for: secret.keyType).lengthAndData +
rsaPublicKeyBlob(secret: secret)
}
}
@ -39,7 +42,7 @@ public struct OpenSSHKeyWriter: Sendable {
.replacingOccurrences(of: " ", with: "-")
resolvedComment = "\(dashedKeyName)@\(dashedHostName)"
}
return [curveType(for: secret.keyType), data(secret: secret).base64EncodedString(), resolvedComment]
return [openSSHIdentifier(for: secret.keyType), data(secret: secret).base64EncodedString(), resolvedComment]
.compactMap { $0 }
.joined(separator: " ")
}
@ -64,63 +67,43 @@ public struct OpenSSHKeyWriter: Sendable {
}
extension OpenSSHKeyWriter {
/// Creates an OpenSSH protocol style data object, which has a length header, followed by the data payload.
/// - Parameter data: The data payload.
/// - Returns: OpenSSH data.
public func lengthAndData(of data: Data) -> Data {
let rawLength = UInt32(data.count)
var endian = rawLength.bigEndian
return Data(bytes: &endian, count: UInt32.bitWidth/8) + data
}
extension OpenSSHPublicKeyWriter {
/// The fully qualified OpenSSH identifier for the algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
public func curveType(for keyType: KeyType) -> String {
public func openSSHIdentifier(for keyType: KeyType) -> String {
switch (keyType.algorithm, keyType.size) {
case (.ecdsa, 256), (.ecdsa, 384):
"ecdsa-sha2-nistp" + String(describing: keyType.size)
case (.mldsa, 65), (.mldsa, 87):
"ssh-mldsa" + String(describing: keyType.size)
"ssh-mldsa-" + String(describing: keyType.size)
case (.rsa, _):
"ssh-rsa"
"ssh-rsa"
default:
"unknown"
}
}
/// The OpenSSH identifier for an algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
private func curveIdentifier(for keyType: KeyType) -> String {
switch keyType.algorithm {
case .ecdsa:
"nistp" + String(describing: keyType.size)
case .mldsa:
"mldsa" + String(describing: keyType.size)
default:
fatalError()
}
}
}
public func rsa<SecretType: Secret>(secret: SecretType) -> Data {
extension OpenSSHPublicKeyWriter {
public func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data {
// Cheap way to pull out e and n as defined in https://datatracker.ietf.org/doc/html/rfc4253
// Keychain stores it as a thin ASN.1 wrapper with this format:
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e]
// Rather than parse out the whole ASN.1 blob, we know how this should be formatted, so pull values directly.
// Rather than parse out the whole ASN.1 blob, we'll cheat and pull values directly since
// we only support one key type, and the keychain always gives it in a specific format.
let keySize = secret.keyType.size
guard secret.keyType.algorithm == .rsa && keySize == 2048 else { fatalError() }
let length = secret.keyType.size/8
let data = secret.publicKey
let n = Data(data[8..<(9+length)])
let e = Data(data[(2+9+length)...])
return lengthAndData(of: e) + lengthAndData(of: n)
return e.lengthAndData + n.lengthAndData
}
}

View File

@ -0,0 +1,69 @@
import Foundation
import CryptoKit
/// Generates OpenSSH representations of Secrets.
public struct OpenSSHSignatureWriter: Sendable {
/// Initializes the writer.
public init() {
}
/// Generates an OpenSSH data payload identifying the secret.
/// - Returns: OpenSSH data payload identifying the secret.
public func data<SecretType: Secret>(secret: SecretType, signature: Data) -> Data {
switch secret.keyType.algorithm {
case .ecdsa:
// https://datatracker.ietf.org/doc/html/rfc5656#section-3.1
fatalError()
case .mldsa:
// https://www.ietf.org/archive/id/draft-sfluhrer-ssh-mldsa-04.txt
fatalError()
case .rsa:
// https://datatracker.ietf.org/doc/html/rfc4253#section-6.6
fatalError()
}
}
}
extension OpenSSHSignatureWriter {
/// The fully qualified OpenSSH identifier for the algorithm.
/// - Parameters:
/// - algorithm: The algorithm to identify.
/// - length: The key length of the algorithm.
/// - Returns: The OpenSSH identifier for the algorithm.
public func openSSHIdentifier(for keyType: KeyType) -> String {
switch (keyType.algorithm, keyType.size) {
case (.ecdsa, 256), (.ecdsa, 384):
"ecdsa-sha2-nistp" + String(describing: keyType.size)
case (.mldsa, 65), (.mldsa, 87):
"ssh-mldsa-" + String(describing: keyType.size)
case (.rsa, _):
"ssh-rsa"
default:
"unknown"
}
}
}
extension OpenSSHSignatureWriter {
public func rsaPublicKeyBlob<SecretType: Secret>(secret: SecretType) -> Data {
// Cheap way to pull out e and n as defined in https://datatracker.ietf.org/doc/html/rfc4253
// Keychain stores it as a thin ASN.1 wrapper with this format:
// [4 byte prefix][2 byte prefix][n][2 byte prefix][e]
// Rather than parse out the whole ASN.1 blob, we'll cheat and pull values directly since
// we only support one key type, and the keychain always gives it in a specific format.
let keySize = secret.keyType.size
guard secret.keyType.algorithm == .rsa && keySize == 2048 else { fatalError() }
let length = secret.keyType.size/8
let data = secret.publicKey
let n = Data(data[8..<(9+length)])
let e = Data(data[(2+9+length)...])
return e.lengthAndData + n.lengthAndData
}
}

View File

@ -6,7 +6,7 @@ public final class PublicKeyFileStoreController: Sendable {
private let logger = Logger(subsystem: "com.maxgoedjen.secretive.secretagent", category: "PublicKeyFileStoreController")
private let directory: String
private let keyWriter = OpenSSHKeyWriter()
private let keyWriter = OpenSSHPublicKeyWriter()
/// Initializes a PublicKeyFileStoreController.
public init(homeDirectory: String) {

View File

@ -79,7 +79,8 @@ extension SmartCard {
}
let key = untypedSafe as! SecKey
var signError: SecurityError?
guard let signature = SecKeyCreateSignature(key, signatureAlgorithm(for: secret), data as CFData, &signError) else {
guard let algorithm = signatureAlgorithm(for: secret) else { throw UnsupportKeyType() }
guard let signature = SecKeyCreateSignature(key, algorithm, data as CFData, &signError) else {
throw SigningError(error: signError)
}
return signature as Data
@ -153,7 +154,7 @@ extension SmartCard.Store {
var untyped: CFTypeRef?
SecItemCopyMatching(attributes, &untyped)
guard let typed = untyped as? [[CFString: Any]] else { return }
let wrapped = typed.map {
let wrapped: [SecretType] = typed.compactMap {
let name = $0[kSecAttrLabel] as? String ?? String(localized: .unnamedSecret)
let tokenID = $0[kSecAttrApplicationLabel] as! Data
let algorithmSecAttr = $0[kSecAttrKeyType] as! NSNumber
@ -163,7 +164,9 @@ extension SmartCard.Store {
let publicKeyAttributes = SecKeyCopyAttributes(publicKeySecRef) as! [CFString: Any]
let publicKey = publicKeyAttributes[kSecValueData] as! Data
let attributes = Attributes(keyType: KeyType(secAttr: algorithmSecAttr, size: keySize)!, authentication: .unknown)
return SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
let secret = SmartCard.Secret(id: tokenID, name: name, publicKey: publicKey, attributes: attributes)
guard signatureAlgorithm(for: secret) != nil else { return nil }
return secret
}
state.secrets.append(contentsOf: wrapped)
}
@ -178,3 +181,9 @@ extension TKTokenWatcher {
}
}
extension SmartCard {
public struct UnsupportKeyType: Error {}
}

View File

@ -45,7 +45,7 @@ extension Stub {
let privateData = (privateAttributes[kSecValueData] as! Data)
let secret = Secret(keySize: size, publicKey: publicData, privateKey: privateData)
print(secret)
print("Public Key OpenSSH: \(OpenSSHKeyWriter().openSSHString(secret: secret))")
print("Public Key OpenSSH: \(OpenSSHPublicKeyWriter().openSSHString(secret: secret))")
}
public func sign(data: Data, with secret: Secret, for provenance: SigningRequestProvenance) throws -> Data {

View File

@ -4,11 +4,12 @@ import Testing
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct AnySecretTests {
@Test func eraser() {
let data = Data(UUID().uuidString.utf8)
let secret = SmartCard.Secret(id: data, name: "Name", publicKey: data, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256)))
let secret = SmartCard.Secret(id: data, name: "Name", publicKey: data, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired))
let erased = AnySecret(secret)
#expect(erased.id == secret.id as AnyHashable)
#expect(erased.name == secret.name)

View File

@ -4,9 +4,9 @@ import Testing
@testable import SecureEnclaveSecretKit
@testable import SmartCardSecretKit
@Suite struct OpenSSHWriterTests {
@Suite struct OpenSSHPublicKeyWriterTests {
let writer = OpenSSHKeyWriter()
let writer = OpenSSHPublicKeyWriter()
@Test func ecdsa256MD5Fingerprint() {
#expect(writer.openSSHMD5Fingerprint(secret: Constants.ecdsa256Secret) == "dc:60:4d:ff:c2:d9:18:8b:2f:24:40:b5:7f:43:47:e5")
@ -44,11 +44,11 @@ import Testing
}
extension OpenSSHWriterTests {
extension OpenSSHPublicKeyWriterTests {
enum Constants {
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), publicKeyAttribution: "test@example.com"))
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), publicKeyAttribution: "test@example.com"))
static let ecdsa256Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 256)", publicKey: Data(base64Encoded: "BOVEjgAA5PHqRgwykjN5qM21uWCHFSY/Sqo5gkHAkn+e1MMQKHOLga7ucB9b3mif33MBid59GRK9GEPVlMiSQwo=")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 256), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
static let ecdsa384Secret = SmartCard.Secret(id: Data(), name: "Test Key (ECDSA 384)", publicKey: Data(base64Encoded: "BG2MNc/C5OTHFE2tBvbZCVcpOGa8vBMquiTLkH4lwkeqOPxhi+PyYUfQZMTRJNPiTyWPoMBqNiCIFRVv60yPN/AHufHaOgbdTP42EgMlMMImkAjYUEv9DESHTVIs2PW1yQ==")!, attributes: Attributes(keyType: KeyType(algorithm: .ecdsa, size: 384), authentication: .notRequired, publicKeyAttribution: "test@example.com"))
}

View File

@ -5,7 +5,7 @@ struct SecretDetailView<SecretType: Secret>: View {
let secret: SecretType
private let keyWriter = OpenSSHKeyWriter()
private let keyWriter = OpenSSHPublicKeyWriter()
private let publicKeyFileStoreController = PublicKeyFileStoreController(homeDirectory: NSHomeDirectory().replacingOccurrences(of: Bundle.main.hostBundleID, with: Bundle.main.agentBundleID))
var body: some View {