apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

RadrootsIdentityCryptography.swift (12107B)


      1 import CryptoKit
      2 import Foundation
      3 import P256K
      4 import Security
      5 
      6 public struct RadrootsIdentityPortabilityEnvelope: Sendable, CustomDebugStringConvertible {
      7     private let serialized: Data
      8 
      9     public init(serializedRepresentation: Data) throws {
     10         _ = try RadrootsIdentityPortabilityCodec.decodeWire(serializedRepresentation)
     11         serialized = serializedRepresentation
     12     }
     13 
     14     public var serializedRepresentation: Data {
     15         serialized
     16     }
     17 
     18     public var version: UInt16 {
     19         (try? RadrootsIdentityPortabilityCodec.decodeWire(serialized).version) ?? 0
     20     }
     21 
     22     public var debugDescription: String {
     23         "RadrootsIdentityPortabilityEnvelope(version: \(version), encryptedPayload: <redacted>)"
     24     }
     25 }
     26 
     27 struct RadrootsIdentityCryptography: Sendable {
     28     func generateSecret() throws -> Data {
     29         do {
     30             return try P256K.Schnorr.PrivateKey().dataRepresentation
     31         } catch {
     32             throw RadrootsIdentityCustodyError.cryptographyFailed
     33         }
     34     }
     35 
     36     func publicKeyHex(for secret: Data) throws -> String {
     37         do {
     38             return try Self.hex(P256K.Schnorr.PrivateKey(dataRepresentation: secret).xonly.bytes)
     39         } catch {
     40             throw RadrootsIdentityCustodyError.invalidSecret
     41         }
     42     }
     43 
     44     func identityHandle(forPublicKeyHex publicKeyHex: String) throws -> String {
     45         guard let bytes = Self.decodeHex(publicKeyHex), bytes.count == 32 else {
     46             throw RadrootsIdentityCustodyError.invalidMetadata
     47         }
     48         return "rrid1_\(Self.hex(CryptoKit.SHA256.hash(data: bytes)))"
     49     }
     50 
     51     func sign(secret: Data, digest: Data) throws -> Data {
     52         guard digest.count == 32 else {
     53             throw RadrootsIdentityCustodyError.invalidSignRequest
     54         }
     55         do {
     56             let key = try P256K.Schnorr.PrivateKey(dataRepresentation: secret)
     57             var message = [UInt8](digest)
     58             var auxiliary = [UInt8](repeating: 0, count: 32)
     59             guard SecRandomCopyBytes(kSecRandomDefault, auxiliary.count, &auxiliary) == errSecSuccess
     60             else {
     61                 throw RadrootsIdentityCustodyError.cryptographyFailed
     62             }
     63             let signature = try auxiliary.withUnsafeMutableBytes { buffer in
     64                 try key.signature(
     65                     message: &message,
     66                     auxiliaryRand: buffer.baseAddress,
     67                     strict: true
     68                 )
     69             }
     70             let signatureData = signature.dataRepresentation
     71             guard key.xonly.isValid(signature, for: &message) else {
     72                 throw RadrootsIdentityCustodyError.invalidSignature
     73             }
     74             return signatureData
     75         } catch let error as RadrootsIdentityCustodyError {
     76             throw error
     77         } catch {
     78             throw RadrootsIdentityCustodyError.cryptographyFailed
     79         }
     80     }
     81 
     82     func verify(signature: Data, digest: Data, publicKeyHex: String) -> Bool {
     83         guard signature.count == 64,
     84               digest.count == 32,
     85               let publicKey = Self.decodeHex(publicKeyHex),
     86               publicKey.count == 32,
     87               let parsedSignature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: signature)
     88         else {
     89             return false
     90         }
     91         let key = P256K.Schnorr.XonlyKey(dataRepresentation: publicKey)
     92         var message = [UInt8](digest)
     93         return key.isValid(parsedSignature, for: &message)
     94     }
     95 
     96     static func hex(_ bytes: some Sequence<UInt8>) -> String {
     97         bytes.map { String(format: "%02x", $0) }.joined()
     98     }
     99 
    100     static func decodeHex(_ value: String) -> Data? {
    101         guard value.count.isMultiple(of: 2),
    102               value.unicodeScalars.allSatisfy({
    103                   (48 ... 57).contains($0.value) || (97 ... 102).contains($0.value)
    104               })
    105         else {
    106             return nil
    107         }
    108         var output = Data(capacity: value.count / 2)
    109         var index = value.startIndex
    110         while index < value.endIndex {
    111             let next = value.index(index, offsetBy: 2)
    112             guard let byte = UInt8(value[index ..< next], radix: 16) else {
    113                 return nil
    114             }
    115             output.append(byte)
    116             index = next
    117         }
    118         return output
    119     }
    120 }
    121 
    122 enum RadrootsIdentityPortabilityCodec {
    123     static let version: UInt16 = 1
    124     static let kdf = "pbkdf2-hmac-sha256"
    125     static let cipher = "aes-256-gcm"
    126     static let iterations: UInt32 = 210_000
    127     static let maximumEnvelopeBytes = 128 * 1024
    128 
    129     struct Wire: Codable {
    130         let version: UInt16
    131         let kdf: String
    132         let iterations: UInt32
    133         let cipher: String
    134         let publicKeyHex: String
    135         let salt: Data
    136         let nonce: Data
    137         let ciphertext: Data
    138         let tag: Data
    139     }
    140 
    141     struct Plaintext: Codable {
    142         let version: UInt16
    143         let secret: Data
    144         let publicKeyHex: String
    145         let label: String?
    146         let createdAtUnixMilliseconds: UInt64
    147     }
    148 
    149     static func seal(
    150         secret: Data,
    151         record: RadrootsIdentityPublicRecord,
    152         passphrase: RadrootsIdentityPassphrase
    153     ) throws -> RadrootsIdentityPortabilityEnvelope {
    154         var salt = [UInt8](repeating: 0, count: 16)
    155         guard SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt) == errSecSuccess else {
    156             throw RadrootsIdentityCustodyError.cryptographyFailed
    157         }
    158         let plaintext = Plaintext(
    159             version: version,
    160             secret: secret,
    161             publicKeyHex: record.publicKeyHex,
    162             label: record.label,
    163             createdAtUnixMilliseconds: record.createdAtUnixMilliseconds
    164         )
    165         var cleartext = try encoder().encode(plaintext)
    166         defer { cleartext.resetBytes(in: cleartext.startIndex ..< cleartext.endIndex) }
    167         do {
    168             let key = try deriveKey(
    169                 passphrase: passphrase.copyBytes(),
    170                 salt: Data(salt),
    171                 iterations: iterations
    172             )
    173             let nonce = AES.GCM.Nonce()
    174             let sealed = try AES.GCM.seal(
    175                 cleartext,
    176                 using: key,
    177                 nonce: nonce,
    178                 authenticating: aad(
    179                     version: version,
    180                     kdf: kdf,
    181                     iterations: iterations,
    182                     cipher: cipher,
    183                     publicKeyHex: record.publicKeyHex
    184                 )
    185             )
    186             let wire = Wire(
    187                 version: version,
    188                 kdf: kdf,
    189                 iterations: iterations,
    190                 cipher: cipher,
    191                 publicKeyHex: record.publicKeyHex,
    192                 salt: Data(salt),
    193                 nonce: nonce.withUnsafeBytes { Data($0) },
    194                 ciphertext: sealed.ciphertext,
    195                 tag: sealed.tag
    196             )
    197             return try RadrootsIdentityPortabilityEnvelope(
    198                 serializedRepresentation: encoder().encode(wire)
    199             )
    200         } catch let error as RadrootsIdentityCustodyError {
    201             throw error
    202         } catch {
    203             throw RadrootsIdentityCustodyError.cryptographyFailed
    204         }
    205     }
    206 
    207     static func open(
    208         _ envelope: RadrootsIdentityPortabilityEnvelope,
    209         passphrase: RadrootsIdentityPassphrase
    210     ) throws -> (RadrootsIdentitySecretMaterial, String?, UInt64) {
    211         let wire = try decodeWire(envelope.serializedRepresentation)
    212         do {
    213             let key = try deriveKey(
    214                 passphrase: passphrase.copyBytes(),
    215                 salt: wire.salt,
    216                 iterations: wire.iterations
    217             )
    218             let nonce = try AES.GCM.Nonce(data: wire.nonce)
    219             let box = try AES.GCM.SealedBox(
    220                 nonce: nonce,
    221                 ciphertext: wire.ciphertext,
    222                 tag: wire.tag
    223             )
    224             var cleartext = try AES.GCM.open(
    225                 box,
    226                 using: key,
    227                 authenticating: aad(
    228                     version: wire.version,
    229                     kdf: wire.kdf,
    230                     iterations: wire.iterations,
    231                     cipher: wire.cipher,
    232                     publicKeyHex: wire.publicKeyHex
    233                 )
    234             )
    235             defer { cleartext.resetBytes(in: cleartext.startIndex ..< cleartext.endIndex) }
    236             let plaintext = try decoder().decode(Plaintext.self, from: cleartext)
    237             guard plaintext.version == version,
    238                   plaintext.publicKeyHex == wire.publicKeyHex,
    239                   plaintext.createdAtUnixMilliseconds > 0
    240             else {
    241                 throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed
    242             }
    243             let material = try RadrootsIdentitySecretMaterial(rawRepresentation: plaintext.secret)
    244             let derived = try RadrootsIdentityCryptography().publicKeyHex(for: plaintext.secret)
    245             guard derived == wire.publicKeyHex else {
    246                 throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed
    247             }
    248             return (material, plaintext.label, plaintext.createdAtUnixMilliseconds)
    249         } catch let error as RadrootsIdentityCustodyError {
    250             throw error
    251         } catch {
    252             throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed
    253         }
    254     }
    255 
    256     static func decodeWire(_ serialized: Data) throws -> Wire {
    257         guard !serialized.isEmpty, serialized.count <= maximumEnvelopeBytes else {
    258             throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope
    259         }
    260         do {
    261             let wire = try decoder().decode(Wire.self, from: serialized)
    262             guard wire.version == version,
    263                   wire.kdf == kdf,
    264                   wire.iterations == iterations,
    265                   wire.cipher == cipher,
    266                   RadrootsIdentityPublicRecord.validHex(wire.publicKeyHex, byteCount: 32),
    267                   wire.salt.count == 16,
    268                   wire.nonce.count == 12,
    269                   !wire.ciphertext.isEmpty,
    270                   wire.ciphertext.count <= 64 * 1024,
    271                   wire.tag.count == 16
    272             else {
    273                 throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope
    274             }
    275             return wire
    276         } catch let error as RadrootsIdentityCustodyError {
    277             throw error
    278         } catch {
    279             throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope
    280         }
    281     }
    282 
    283     private static func aad(
    284         version: UInt16,
    285         kdf: String,
    286         iterations: UInt32,
    287         cipher: String,
    288         publicKeyHex: String
    289     ) -> Data {
    290         Data(
    291             "org.radroots.identity.portability|\(version)|\(kdf)|\(iterations)|\(cipher)|\(publicKeyHex)"
    292                 .utf8
    293         )
    294     }
    295 
    296     private static func deriveKey(
    297         passphrase: Data,
    298         salt: Data,
    299         iterations: UInt32
    300     ) throws -> SymmetricKey {
    301         guard !passphrase.isEmpty, iterations == Self.iterations else {
    302             throw RadrootsIdentityCustodyError.invalidPassphrase
    303         }
    304         let key = SymmetricKey(data: passphrase)
    305         var block = salt
    306         block.append(contentsOf: [0, 0, 0, 1])
    307         var previous = Data(HMAC<CryptoKit.SHA256>.authenticationCode(for: block, using: key))
    308         var output = previous
    309         if iterations > 1 {
    310             for _ in 2 ... iterations {
    311                 previous = Data(HMAC<CryptoKit.SHA256>.authenticationCode(for: previous, using: key))
    312                 for index in output.indices {
    313                     output[index] ^= previous[index]
    314                 }
    315             }
    316         }
    317         defer {
    318             previous.resetBytes(in: previous.startIndex ..< previous.endIndex)
    319             output.resetBytes(in: output.startIndex ..< output.endIndex)
    320         }
    321         return SymmetricKey(data: output)
    322     }
    323 
    324     private static func encoder() -> JSONEncoder {
    325         let encoder = JSONEncoder()
    326         encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
    327         return encoder
    328     }
    329 
    330     private static func decoder() -> JSONDecoder {
    331         JSONDecoder()
    332     }
    333 }