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 }