import Foundation #if !os(Linux) && !os(Windows) import CryptoKit #else import Crypto #endif class Plugin { var crypto: Crypto var stream: Stream init(crypto: Crypto, stream: Stream) { self.crypto = crypto self.stream = stream } func generateKey(accessControl: KeyAccessControl, now: Date) throws -> (String, String) { if !crypto.isSecureEnclaveAvailable { throw Error.seUnsupported } #if !os(Linux) && !os(Windows) let createdAt = now.ISO8601Format() var accessControlFlags: SecAccessControlCreateFlags = [.privateKeyUsage] if accessControl == .anyBiometry || accessControl == .anyBiometryAndPasscode { accessControlFlags.insert(.biometryAny) } if accessControl == .currentBiometry || accessControl == .currentBiometryAndPasscode { accessControlFlags.insert(.biometryCurrentSet) } if accessControl == .passcode || accessControl == .anyBiometryAndPasscode || accessControl == .currentBiometryAndPasscode { accessControlFlags.insert(.devicePasscode) } if accessControl == .anyBiometryOrPasscode { accessControlFlags.insert(.userPresence) } var error: Unmanaged? guard let secAccessControl = SecAccessControlCreateWithFlags( kCFAllocatorDefault, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, accessControlFlags, &error) else { throw error!.takeRetainedValue() as Swift.Error } #else // FIXME: ISO8601Format currently not supported on Linux: // https://github.com/apple/swift-corelibs-foundation/issues/4618 // This code is only reached in unit tests on Linux anyway let createdAt = "1997-02-02T02:26:51Z" let secAccessControl = SecAccessControl() #endif let privateKey = try crypto.newSecureEnclavePrivateKey(accessControl: secAccessControl) let recipient = privateKey.publicKey.ageRecipient let identity = privateKey.ageIdentity let accessControlStr: String switch accessControl { case .none: accessControlStr = "none" case .passcode: accessControlStr = "passcode" case .anyBiometry: accessControlStr = "any biometry" case .anyBiometryOrPasscode: accessControlStr = "any biometry or passcode" case .anyBiometryAndPasscode: accessControlStr = "any biometry and passcode" case .currentBiometry: accessControlStr = "current biometry" case .currentBiometryAndPasscode: accessControlStr = "current biometry and passcode" } let contents = """ # created: \(createdAt) # access control: \(accessControlStr) # public key: \(recipient) \(identity) """ return (contents, recipient) } func runRecipientV1() { var recipients: [String] = [] var identities: [String] = [] var fileKeys: [Data] = [] // Phase 1 loop: while true { let stanza = try! Stanza.readFrom(stream: stream) switch stanza.type { case "add-recipient": recipients.append(stanza.args[0]) case "add-identity": identities.append(stanza.args[0]) case "wrap-file-key": fileKeys.append(stanza.body) case "done": break loop default: continue } } // Phase 2 var stanzas: [Stanza] = [] var errors: [Stanza] = [] var recipientKeys: [P256.KeyAgreement.PublicKey] = [] recipients.enumerated().forEach { (index, recipient) in do { recipientKeys.append(try P256.KeyAgreement.PublicKey(ageRecipient: recipient)) } catch { errors.append( Stanza(error: "recipient", args: [String(index)], message: error.localizedDescription)) } } identities.enumerated().forEach { (index, identity) in do { recipientKeys.append( (try newSecureEnclavePrivateKey(ageIdentity: identity, crypto: crypto)).publicKey) } catch { errors.append( Stanza(error: "identity", args: [String(index)], message: error.localizedDescription)) } } fileKeys.enumerated().forEach { (index, fileKey) in for recipientKey in recipientKeys { do { let ephemeralSecretKey = self.crypto.newEphemeralPrivateKey() let ephemeralPublicKeyBytes = ephemeralSecretKey.publicKey.compressedRepresentation // CryptoKit PublicKeys can be the identity point by construction (see CryptoTests), but // these keys can't be used in any operation. This is undocumented, but a documentation request // has been filed as FB11989432. // Swift Crypto PublicKeys cannot be the identity point by construction. // Compresed representation cannot be the identity point anyway (?) // Therefore, the shared secret cannot be all 0x00 bytes, so we don't need // to explicitly check this here. let sharedSecret = try ephemeralSecretKey.sharedSecretFromKeyAgreement(with: recipientKey) let salt = ephemeralPublicKeyBytes + recipientKey.compressedRepresentation let wrapKey = sharedSecret.hkdfDerivedSymmetricKey( using: SHA256.self, salt: salt, sharedInfo: "piv-p256".data(using: .utf8)!, outputByteCount: 32 ) let sealedBox = try ChaChaPoly.seal( fileKey, using: wrapKey, nonce: try! ChaChaPoly.Nonce(data: Data(count: 12))) stanzas.append( Stanza( type: "recipient-stanza", args: [ String(index), "piv-p256", recipientKey.tag.base64RawEncodedString, ephemeralPublicKeyBytes.base64RawEncodedString, ], body: sealedBox.ciphertext + sealedBox.tag )) } catch { errors.append( Stanza(error: "internal", args: [], message: error.localizedDescription)) } } } for stanza in (errors.isEmpty ? stanzas : errors) { stanza.writeTo(stream: stream) let resp = try! Stanza.readFrom(stream: stream) assert(resp.type == "ok") } Stanza(type: "done").writeTo(stream: stream) } func runIdentityV1() { // Phase 1 var identities: [String] = [] var recipientStanzas: [Stanza] = [] loop: while true { let stanza = try! Stanza.readFrom(stream: stream) switch stanza.type { case "add-identity": identities.append(stanza.args[0]) case "recipient-stanza": recipientStanzas.append(stanza) case "done": break loop default: continue } } // Phase 2 var identityKeys: [SecureEnclavePrivateKey] = [] var errors: [Stanza] = [] // Construct identities identities.enumerated().forEach { (index, identity) in do { identityKeys.append( (try newSecureEnclavePrivateKey(ageIdentity: identity, crypto: crypto))) } catch { errors.append( Stanza(error: "identity", args: [String(index)], message: error.localizedDescription)) } } var fileResponses: [Int: Stanza] = [:] if errors.isEmpty { // Check structural validity recipientStanzas.enumerated().forEach { (index, recipientStanza) in let fileIndex = Int(recipientStanza.args[0])! switch recipientStanza.args[1] { case "piv-p256": if recipientStanza.args.count != 4 { fileResponses[fileIndex] = Stanza( error: "stanza", args: [String(fileIndex)], message: "incorrect argument count") return } let tag = Data(base64RawEncoded: recipientStanza.args[2]) if tag == nil || tag!.count != 4 { fileResponses[fileIndex] = Stanza( error: "stanza", args: [String(fileIndex)], message: "invalid tag") return } let share = Data(base64RawEncoded: recipientStanza.args[3]) if share == nil || share!.count != 33 { fileResponses[fileIndex] = Stanza( error: "stanza", args: [String(fileIndex)], message: "invalid share") return } if recipientStanza.body.count != 32 { fileResponses[fileIndex] = Stanza( error: "stanza", args: [String(fileIndex)], message: "invalid body") return } default: return } } // Unwrap keys recipientStanzas.enumerated().forEach { (index, recipientStanza) in let fileIndex = Int(recipientStanza.args[0])! if fileResponses[fileIndex] != nil { return } let type = recipientStanza.args[1] if type != "piv-p256" { return } let tag = recipientStanza.args[2] let share = recipientStanza.args[3] for identity in identityKeys { if identity.publicKey.tag.base64RawEncodedString != tag { continue } do { let shareKeyData = Data(base64RawEncoded: share)! let shareKey: P256.KeyAgreement.PublicKey = try P256.KeyAgreement.PublicKey( compressedRepresentation: shareKeyData) // CryptoKit PublicKeys can be the identity point by construction (see CryptoTests), but // these keys can't be used in any operation. This is undocumented, but a documentation request // has been filed as FB11989432. // Swift Crypto PublicKeys cannot be the identity point by construction. // Compresed representation cannot be the identity point anyway (?) // Therefore, the shared secret cannot be all 0x00 bytes, so we don't need // to explicitly check this here. let sharedSecret: SharedSecret = try identity.sharedSecretFromKeyAgreement( with: shareKey) let salt = shareKey.compressedRepresentation + identity.publicKey.compressedRepresentation let wrapKey = sharedSecret.hkdfDerivedSymmetricKey( using: SHA256.self, salt: salt, sharedInfo: "piv-p256".data(using: .utf8)!, outputByteCount: 32 ) let unwrappedKey = try ChaChaPoly.open( ChaChaPoly.SealedBox( combined: try! ChaChaPoly.Nonce(data: Data(count: 12)) + recipientStanza.body), using: wrapKey) fileResponses[fileIndex] = Stanza( type: "file-key", args: [String(fileIndex)], body: unwrappedKey ) } catch { Stanza(type: "msg", body: error.localizedDescription.data(using: .utf8)!).writeTo( stream: stream) let resp = try! Stanza.readFrom(stream: self.stream) assert(resp.type == "ok") // continue } } } } let responses = fileResponses.keys.sorted().map({ k in fileResponses[k]! }) for stanza in (errors.isEmpty ? responses : errors) { stanza.writeTo(stream: stream) let resp = try! Stanza.readFrom(stream: stream) assert(resp.type == "ok") } Stanza(type: "done").writeTo(stream: stream) } enum Error: LocalizedError, Equatable { case seUnsupported case incompleteStanza case invalidStanza case unknownHRP(String) public var errorDescription: String? { switch self { case .seUnsupported: return "Secure Enclave not supported on this device" case .incompleteStanza: return "incomplete stanza" case .invalidStanza: return "invalid stanza" case .unknownHRP(let hrp): return "unknown HRP: \(hrp)" } } } } ////////////////////////////////////////////////////////////////////////////////////////// struct Stanza: Equatable { var type: String var args: [String] = [] var body = Data() static func readFrom(stream: Stream) throws -> Stanza { guard let header = stream.readLine() else { throw Plugin.Error.incompleteStanza } let headerParts = header.components(separatedBy: " ") if headerParts.count < 2 { throw Plugin.Error.invalidStanza } if headerParts[0] != "->" { throw Plugin.Error.invalidStanza } var body = Data() while true { guard let line = stream.readLine() else { throw Plugin.Error.incompleteStanza } guard let lineData = Data(base64RawEncoded: line) else { throw Plugin.Error.invalidStanza } if lineData.count > 48 { throw Plugin.Error.invalidStanza } body.append(lineData) if lineData.count < 48 { break } } return Stanza(type: headerParts[1], args: Array(headerParts[2...]), body: body) } func writeTo(stream: Stream) { let parts = ([type] + args).joined(separator: " ") stream.writeLine("-> \(parts)\n\(body.base64RawEncodedString)") } } extension Stanza { init(error type: String, args: [String] = [], message: String) { self.type = "error" self.args = [type] + args self.body = message.data(using: .utf8)! } } enum KeyAccessControl { case none case passcode case anyBiometry case anyBiometryOrPasscode case anyBiometryAndPasscode case currentBiometry case currentBiometryAndPasscode } extension P256.KeyAgreement.PublicKey { init(ageRecipient: String) throws { let id = try Bech32().decode(ageRecipient) if id.hrp != "age1se" { throw Plugin.Error.unknownHRP(id.hrp) } self = try P256.KeyAgreement.PublicKey(compressedRepresentation: id.data) } var tag: Data { return Data(SHA256.hash(data: compressedRepresentation).prefix(4)) } var ageRecipient: String { return Bech32().encode(hrp: "age1se", data: self.compressedRepresentation) } } extension SecureEnclavePrivateKey { var ageIdentity: String { return Bech32().encode( hrp: "AGE-PLUGIN-SE-", data: self.dataRepresentation) } } func newSecureEnclavePrivateKey(ageIdentity: String, crypto: Crypto) throws -> SecureEnclavePrivateKey { let id = try Bech32().decode(ageIdentity) if id.hrp != "AGE-PLUGIN-SE-" { throw Plugin.Error.unknownHRP(id.hrp) } return try crypto.newSecureEnclavePrivateKey(dataRepresentation: id.data) }