from: github.com/remko/age-plugin-se

This commit is contained in:
2023-03-09 22:52:40 +08:00
parent 57fac2893c
commit 71927745a8
22 changed files with 2720 additions and 2 deletions

36
Sources/Base64.swift Normal file
View File

@@ -0,0 +1,36 @@
import Foundation
extension Data {
init?(base64RawEncoded: String) {
if base64RawEncoded.hasSuffix("=") {
return nil
}
var str = base64RawEncoded
switch base64RawEncoded.count % 4 {
case 2:
str += "=="
case 3:
str += "="
default:
()
}
guard let data = Data(base64Encoded: str) else {
return nil
}
self = data
}
var base64RawEncodedData: Data {
var s = base64EncodedData(options: [
Base64EncodingOptions.lineLength64Characters, Base64EncodingOptions.endLineWithLineFeed,
])
if let pi = s.firstIndex(of: Character("=").asciiValue!) {
s = Data(s[s.startIndex..<pi])
}
return s
}
var base64RawEncodedString: String {
return String(data: base64RawEncodedData, encoding: .utf8)!
}
}

235
Sources/Bech32.swift Normal file
View File

@@ -0,0 +1,235 @@
// Spec: https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki
// Modified version of https://github.com/0xDEADP00L/Bech32
// Copyright 2018 Evolution Group Limited
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import Foundation
/// Bech32 checksum implementation
public class Bech32 {
private let gen: [UInt32] = [0x3b6a_57b2, 0x2650_8e6d, 0x1ea1_19fa, 0x3d42_33dd, 0x2a14_62b3]
/// Bech32 checksum delimiter
private let checksumMarker: String = "1"
/// Bech32 character set for encoding
private let encCharset: Data = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".data(using: .utf8)!
/// Bech32 character set for decoding
private let decCharset: [Int8] = [
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1,
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1,
-1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1,
1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1,
]
private func convertBits(from: Int, to: Int, pad: Bool, idata: Data) throws -> Data {
var acc: Int = 0
var bits: Int = 0
let maxv: Int = (1 << to) - 1
let maxAcc: Int = (1 << (from + to - 1)) - 1
var odata = Data()
for ibyte in idata {
acc = ((acc << from) | Int(ibyte)) & maxAcc
bits += from
while bits >= to {
bits -= to
odata.append(UInt8((acc >> bits) & maxv))
}
}
if pad {
if bits != 0 {
odata.append(UInt8((acc << (to - bits)) & maxv))
}
} else if bits >= from || ((acc << (to - bits)) & maxv) != 0 {
throw DecodingError.bitsConversionFailed
}
return odata
}
/// Find the polynomial with value coefficients mod the generator as 30-bit.
private func polymod(_ values: Data) -> UInt32 {
var chk: UInt32 = 1
for v in values {
let top = (chk >> 25)
chk = (chk & 0x1ffffff) << 5 ^ UInt32(v)
for i: UInt8 in 0..<5 {
chk ^= ((top >> i) & 1) == 0 ? 0 : gen[Int(i)]
}
}
return chk
}
/// Expand a HRP for use in checksum computation.
private func expandHrp(_ hrp: String) -> Data {
guard let hrpBytes = hrp.data(using: .utf8) else { return Data() }
var result = Data(repeating: 0x00, count: hrpBytes.count * 2 + 1)
for (i, c) in hrpBytes.enumerated() {
result[i] = c >> 5
result[i + hrpBytes.count + 1] = c & 0x1f
}
result[hrp.count] = 0
return result
}
/// Verify checksum
private func verifyChecksum(hrp: String, checksum: Data) -> Bool {
var data = expandHrp(hrp)
data.append(checksum)
return polymod(data) == 1
}
/// Create checksum
private func createChecksum(hrp: String, values: Data) -> Data {
var enc = expandHrp(hrp)
enc.append(values)
enc.append(Data(repeating: 0x00, count: 6))
let mod: UInt32 = polymod(enc) ^ 1
var ret: Data = Data(repeating: 0x00, count: 6)
for i in 0..<6 {
ret[i] = UInt8((mod >> (5 * (5 - i))) & 31)
}
return ret
}
/// Encode Bech32 string
private func encodeBech32(_ hrp: String, values: Data) -> String {
let checksum = createChecksum(hrp: hrp, values: values)
var combined = values
combined.append(checksum)
guard let hrpBytes = hrp.data(using: .utf8) else { return "" }
var ret = hrpBytes
ret.append("1".data(using: .utf8)!)
for i in combined {
ret.append(encCharset[Int(i)])
}
return String(data: ret, encoding: .utf8) ?? ""
}
/// Decode Bech32 string
public func decodeBech32(_ str: String) throws -> (hrp: String, checksum: Data) {
guard let strBytes = str.data(using: .utf8) else {
throw DecodingError.nonUTF8String
}
var lower: Bool = false
var upper: Bool = false
for c in strBytes {
// printable range
if c < 33 || c > 126 {
throw DecodingError.nonPrintableCharacter
}
// 'a' to 'z'
if c >= 97 && c <= 122 {
lower = true
}
// 'A' to 'Z'
if c >= 65 && c <= 90 {
upper = true
}
}
if lower && upper {
throw DecodingError.invalidCase
}
guard let pos = str.range(of: checksumMarker, options: .backwards)?.lowerBound else {
throw DecodingError.noChecksumMarker
}
let intPos: Int = str.distance(from: str.startIndex, to: pos)
guard intPos >= 1 else {
throw DecodingError.incorrectHrpSize
}
guard intPos + 7 <= str.count else {
throw DecodingError.incorrectChecksumSize
}
let vSize: Int = str.count - 1 - intPos
var values: Data = Data(repeating: 0x00, count: vSize)
for i in 0..<vSize {
let c = strBytes[i + intPos + 1]
let decInt = decCharset[Int(c)]
if decInt == -1 {
throw DecodingError.invalidCharacter
}
values[i] = UInt8(decInt)
}
let hrp = String(str[..<pos]).lowercased()
guard verifyChecksum(hrp: hrp, checksum: values) else {
throw DecodingError.checksumMismatch
}
return (hrp, Data(values[..<(vSize - 6)]))
}
public func encode(hrp: String, data: Data) -> String {
let isUpper = hrp[hrp.startIndex].isUppercase
let result = encodeBech32(
isUpper ? hrp.lowercased() : hrp,
values: try! self.convertBits(from: 8, to: 5, pad: true, idata: data))
return isUpper ? result.uppercased() : result
}
public func decode(_ str: String) throws -> (hrp: String, data: Data) {
let isUpper = str[str.startIndex].isUppercase
let result = try decodeBech32(isUpper ? str.lowercased() : str)
return (
isUpper ? result.hrp.uppercased() : result.hrp,
try convertBits(from: 5, to: 8, pad: false, idata: result.checksum)
)
}
}
extension Bech32 {
public enum DecodingError: LocalizedError {
case nonUTF8String
case nonPrintableCharacter
case invalidCase
case noChecksumMarker
case incorrectHrpSize
case incorrectChecksumSize
case invalidCharacter
case checksumMismatch
case bitsConversionFailed
public var errorDescription: String? {
switch self {
case .bitsConversionFailed:
return "Failed to perform bits conversion"
case .checksumMismatch:
return "Checksum doesn't match"
case .incorrectChecksumSize:
return "Checksum size too low"
case .incorrectHrpSize:
return "Human-readable-part is too small or empty"
case .invalidCase:
return "String contains mixed case characters"
case .invalidCharacter:
return "Invalid character met on decoding"
case .noChecksumMarker:
return "Checksum delimiter not found"
case .nonPrintableCharacter:
return "Non printable character in input string"
case .nonUTF8String:
return "String cannot be decoded by utf8 decoder"
}
}
}
}

173
Sources/CLI.swift Normal file
View File

@@ -0,0 +1,173 @@
import Foundation
let version = "v0.0.3"
@main
struct CLI {
static func main() {
do {
let plugin = Plugin(crypto: CryptoKitCrypto(), stream: StandardIOStream())
let options = try Options.parse(CommandLine.arguments)
switch options.command {
case .help:
print(Options.help)
case .version:
print(version)
case .keygen:
let result = try plugin.generateKey(
accessControl: options.accessControl.keyAccessControl, now: Date())
if let outputFile = options.output {
FileManager.default.createFile(
atPath: FileManager.default.currentDirectoryPath + "/" + outputFile,
contents: result.0.data(using: .utf8),
attributes: [.posixPermissions: 0o600]
)
print("Public key: \(result.1)")
} else {
print(result.0)
}
case .plugin(let sm):
switch sm {
case .recipientV1:
plugin.runRecipientV1()
case .identityV1:
plugin.runIdentityV1()
}
}
} catch {
print("\(CommandLine.arguments[0]): error: \(error.localizedDescription)")
exit(-1)
}
}
}
/// Command-line options parser
struct Options {
enum Error: LocalizedError, Equatable {
case unknownOption(String)
case missingValue(String)
case invalidValue(String, String)
public var errorDescription: String? {
switch self {
case .unknownOption(let option): return "unknown option: `\(option)`"
case .missingValue(let option): return "missing value for option `\(option)`"
case .invalidValue(let option, let value):
return "invalid value for option `\(option)`: `\(value)`"
}
}
}
enum StateMachine: String {
case recipientV1 = "recipient-v1"
case identityV1 = "identity-v1"
}
enum Command: Equatable {
case help
case version
case keygen
case plugin(StateMachine)
}
var command: Command
var output: String?
enum AccessControl: String {
case none = "none"
case passcode = "passcode"
case anyBiometry = "any-biometry"
case anyBiometryOrPasscode = "any-biometry-or-passcode"
case anyBiometryAndPasscode = "any-biometry-and-passcode"
case currentBiometry = "current-biometry"
case currentBiometryAndPasscode = "current-biometry-and-passcode"
var keyAccessControl: KeyAccessControl {
switch self {
case .none: return KeyAccessControl.none
case .passcode: return KeyAccessControl.passcode
case .anyBiometry: return KeyAccessControl.anyBiometry
case .anyBiometryOrPasscode: return KeyAccessControl.anyBiometryOrPasscode
case .anyBiometryAndPasscode: return KeyAccessControl.anyBiometryAndPasscode
case .currentBiometry: return KeyAccessControl.currentBiometry
case .currentBiometryAndPasscode: return KeyAccessControl.currentBiometryAndPasscode
}
}
}
var accessControl = AccessControl.anyBiometryOrPasscode
static var help =
"""
Usage:
age-plugin-se keygen [-o OUTPUT] [--access-control ACCESS_CONTROL]
Options:
-o, --output OUTPUT Write the result to the file at path OUTPUT
--access-control ACCESS_CONTROL Access control for using the generated key.
When using current biometry, adding or removing a fingerprint stops the
key from working. Removing an added fingerprint enables the key again.
Supported values: none, passcode,
any-biometry, any-biometry-and-passcode, any-biometry-or-passcode,
current-biometry, current-biometry-and-passcode
Default: any-biometry-or-passcode.
Example:
$ age-plugin-se keygen -o key.txt
Public key: age1se1qg8vwwqhztnh3vpt2nf2xwn7famktxlmp0nmkfltp8lkvzp8nafkqleh258
$ tar cvz ~/data | age -r age1se1qgg72x2qfk9wg3wh0qg9u0v7l5dkq4jx69fv80p6wdus3ftg6flwg5dz2dp > data.tar.gz.age
$ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz
"""
static func parse(_ args: [String]) throws -> Options {
var opts = Options(command: .help)
var i = 1
while i < args.count {
let arg = args[i]
if arg == "keygen" {
opts.command = .keygen
} else if ["--help", "-h"].contains(arg) {
opts.command = .help
break
} else if ["--version"].contains(arg) {
opts.command = .version
break
} else if [
"--age-plugin", "-o", "--output", "--access-control",
].contains(where: {
arg == $0 || arg.hasPrefix($0 + "=")
}) {
let argps = arg.split(separator: "=", maxSplits: 1)
let value: String
if argps.count == 1 {
i += 1
if i >= args.count {
throw Error.missingValue(arg)
}
value = args[i]
} else {
value = String(argps[1])
}
let arg = String(argps[0])
switch arg {
case "--age-plugin":
opts.command = try .plugin(
StateMachine(rawValue: value) ?? { throw Error.invalidValue(arg, value) }())
case "-o", "--output":
opts.output = value
case "--access-control":
opts.accessControl =
try AccessControl(rawValue: value) ?? { throw Error.invalidValue(arg, value) }()
default:
assert(false)
}
} else {
throw Error.unknownOption(arg)
}
i += 1
}
return opts
}
}

78
Sources/Crypto.swift Normal file
View File

@@ -0,0 +1,78 @@
import Foundation
#if !os(Linux) && !os(Windows)
import CryptoKit
import LocalAuthentication
#else
import Crypto
struct SecAccessControl {}
#endif
/// Abstraction for random/unpredictable/system-specific crypto operations
protocol Crypto {
var isSecureEnclaveAvailable: Bool { get }
func newSecureEnclavePrivateKey(dataRepresentation: Data) throws -> SecureEnclavePrivateKey
func newSecureEnclavePrivateKey(accessControl: SecAccessControl) throws -> SecureEnclavePrivateKey
func newEphemeralPrivateKey() -> P256.KeyAgreement.PrivateKey
}
protocol SecureEnclavePrivateKey {
var publicKey: P256.KeyAgreement.PublicKey { get }
var dataRepresentation: Data { get }
func sharedSecretFromKeyAgreement(with publicKeyShare: P256.KeyAgreement.PublicKey) throws
-> SharedSecret
}
#if !os(Linux) && !os(Windows)
class CryptoKitCrypto: Crypto {
let context = LAContext()
var isSecureEnclaveAvailable: Bool {
return SecureEnclave.isAvailable
}
func newSecureEnclavePrivateKey(dataRepresentation: Data) throws -> SecureEnclavePrivateKey {
return try SecureEnclave.P256.KeyAgreement.PrivateKey(
dataRepresentation: dataRepresentation, authenticationContext: context)
}
func newSecureEnclavePrivateKey(accessControl: SecAccessControl) throws
-> SecureEnclavePrivateKey
{
return try SecureEnclave.P256.KeyAgreement.PrivateKey(
accessControl: accessControl, authenticationContext: context)
}
func newEphemeralPrivateKey() -> P256.KeyAgreement.PrivateKey {
return P256.KeyAgreement.PrivateKey()
}
}
extension SecureEnclave.P256.KeyAgreement.PrivateKey: SecureEnclavePrivateKey {
}
#else
class CryptoKitCrypto: Crypto {
var isSecureEnclaveAvailable: Bool {
return false
}
func newSecureEnclavePrivateKey(dataRepresentation: Data) throws -> SecureEnclavePrivateKey {
throw Plugin.Error.seUnsupported
}
func newSecureEnclavePrivateKey(accessControl: SecAccessControl) throws
-> SecureEnclavePrivateKey
{
throw Plugin.Error.seUnsupported
}
func newEphemeralPrivateKey() -> P256.KeyAgreement.PrivateKey {
return P256.KeyAgreement.PrivateKey()
}
}
#endif

414
Sources/Plugin.swift Normal file
View 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)
}

19
Sources/Stream.swift Normal file
View File

@@ -0,0 +1,19 @@
import Foundation
/// Abstraction of a line-based communication stream
protocol Stream {
func readLine() -> String?
func writeLine(_: String)
}
class StandardIOStream: Stream {
func readLine() -> String? {
return Swift.readLine(strippingNewline: true)
}
func writeLine(_ line: String) {
FileHandle.standardOutput.write(line.data(using: .utf8)!)
FileHandle.standardOutput.write(Data([0xa]))
fflush(stdout)
}
}