from: github.com/remko/age-plugin-se
This commit is contained in:
414
Sources/Plugin.swift
Normal file
414
Sources/Plugin.swift
Normal file
@@ -0,0 +1,414 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user