Files
age-plugin-se/Sources/Plugin.swift

415 lines
14 KiB
Swift

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<CFError>?
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)
}