RadrootsIdentityCustodyTypes.swift (14657B)
1 import Foundation 2 3 public enum RadrootsProtectedDataState: String, Codable, Sendable { 4 case available 5 case locked 6 case unavailable 7 } 8 9 public struct RadrootsProtectedDataProvider: Sendable { 10 private let readState: @Sendable () -> RadrootsProtectedDataState 11 12 public init(readState: @escaping @Sendable () -> RadrootsProtectedDataState) { 13 self.readState = readState 14 } 15 16 public func currentState() -> RadrootsProtectedDataState { 17 readState() 18 } 19 20 public static let available = Self { .available } 21 } 22 23 public enum RadrootsIdentityMetadataSlot: String, Sendable { 24 case activeIdentity 25 case transactionJournal 26 case quarantinedMetadata 27 } 28 29 public protocol RadrootsIdentityMetadataStore: AnyObject, Sendable { 30 func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? 31 func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws 32 func delete(_ slot: RadrootsIdentityMetadataSlot) throws 33 } 34 35 public struct RadrootsIdentitySecretMaterial: Sendable, CustomDebugStringConvertible { 36 private let bytes: Data 37 38 public init(rawRepresentation: Data) throws { 39 guard rawRepresentation.count == 32 else { 40 throw RadrootsIdentityCustodyError.invalidSecret 41 } 42 bytes = rawRepresentation 43 } 44 45 public init(importText: String) throws { 46 let normalized = importText.trimmingCharacters(in: .whitespacesAndNewlines) 47 if normalized.count == 64, let decoded = Self.decodeHex(normalized) { 48 try self.init(rawRepresentation: decoded) 49 return 50 } 51 guard let decoded = Self.decodeNsec(normalized) else { 52 throw RadrootsIdentityCustodyError.invalidSecret 53 } 54 try self.init(rawRepresentation: decoded) 55 } 56 57 public var debugDescription: String { 58 "RadrootsIdentitySecretMaterial(<redacted>)" 59 } 60 61 func copyBytes() -> Data { 62 bytes 63 } 64 65 private static func decodeHex(_ value: String) -> Data? { 66 guard 67 value.unicodeScalars.allSatisfy({ 68 (48 ... 57).contains($0.value) || (65 ... 70).contains($0.value) 69 || (97 ... 102).contains($0.value) 70 }) 71 else { 72 return nil 73 } 74 var output = Data(capacity: 32) 75 var index = value.startIndex 76 for _ in 0 ..< 32 { 77 let next = value.index(index, offsetBy: 2) 78 guard let byte = UInt8(value[index ..< next], radix: 16) else { 79 return nil 80 } 81 output.append(byte) 82 index = next 83 } 84 return output 85 } 86 87 private static func decodeNsec(_ value: String) -> Data? { 88 guard value == value.lowercased(), 89 value.count <= 90, 90 let separator = value.lastIndex(of: "1"), 91 value[..<separator] == "nsec" 92 else { 93 return nil 94 } 95 let payloadStart = value.index(after: separator) 96 let encoded = value[payloadStart...] 97 guard encoded.count >= 6 else { 98 return nil 99 } 100 let alphabet = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l") 101 let reverse = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt8($0)) }) 102 var values = [UInt8]() 103 values.reserveCapacity(encoded.count) 104 for character in encoded { 105 guard let value = reverse[character] else { 106 return nil 107 } 108 values.append(value) 109 } 110 guard bech32Polymod(hrp: "nsec", values: values) == 1 else { 111 return nil 112 } 113 return convertBits(Array(values.dropLast(6)), from: 5, to: 8, pad: false) 114 } 115 116 private static func bech32Polymod(hrp: String, values: [UInt8]) -> UInt32 { 117 let generators: [UInt32] = [0x3B6A_57B2, 0x2650_8E6D, 0x1EA1_19FA, 0x3D42_33DD, 0x2A14_62B3] 118 let expanded = hrp.utf8.map { $0 >> 5 } + [0] + hrp.utf8.map { $0 & 31 } + values 119 var checksum: UInt32 = 1 120 for value in expanded { 121 let top = checksum >> 25 122 checksum = (checksum & 0x01FF_FFFF) << 5 ^ UInt32(value) 123 for (index, generator) in generators.enumerated() where ((top >> index) & 1) == 1 { 124 checksum ^= generator 125 } 126 } 127 return checksum 128 } 129 130 private static func convertBits( 131 _ values: [UInt8], 132 from sourceBits: Int, 133 to targetBits: Int, 134 pad: Bool 135 ) -> Data? { 136 var accumulator = 0 137 var bitCount = 0 138 let maxValue = (1 << targetBits) - 1 139 var output = Data() 140 for value in values { 141 guard Int(value) >> sourceBits == 0 else { 142 return nil 143 } 144 accumulator = (accumulator << sourceBits) | Int(value) 145 bitCount += sourceBits 146 while bitCount >= targetBits { 147 bitCount -= targetBits 148 output.append(UInt8((accumulator >> bitCount) & maxValue)) 149 } 150 } 151 if pad, bitCount > 0 { 152 output.append(UInt8((accumulator << (targetBits - bitCount)) & maxValue)) 153 } else if bitCount >= sourceBits || ((accumulator << (targetBits - bitCount)) & maxValue) != 0 { 154 return nil 155 } 156 return output.count == 32 ? output : nil 157 } 158 } 159 160 public struct RadrootsIdentityPassphrase: Sendable, CustomDebugStringConvertible { 161 private let bytes: Data 162 163 public init(_ value: String) throws { 164 let bytes = Data(value.utf8) 165 guard (12 ... 1024).contains(bytes.count), 166 !value.unicodeScalars.contains(where: { $0.value == 0 }) 167 else { 168 throw RadrootsIdentityCustodyError.invalidPassphrase 169 } 170 self.bytes = bytes 171 } 172 173 public var debugDescription: String { 174 "RadrootsIdentityPassphrase(<redacted>)" 175 } 176 177 func copyBytes() -> Data { 178 bytes 179 } 180 } 181 182 public struct RadrootsIdentityPublicRecord: Codable, Equatable, Hashable, Sendable { 183 public let identityHandle: String 184 public let publicKeyHex: String 185 public let label: String? 186 public let createdAtUnixMilliseconds: UInt64 187 public let updatedAtUnixMilliseconds: UInt64 188 189 public init( 190 identityHandle: String, 191 publicKeyHex: String, 192 label: String?, 193 createdAtUnixMilliseconds: UInt64, 194 updatedAtUnixMilliseconds: UInt64 195 ) throws { 196 guard Self.validHandle(identityHandle), 197 Self.validHex(publicKeyHex, byteCount: 32), 198 createdAtUnixMilliseconds > 0, 199 updatedAtUnixMilliseconds >= createdAtUnixMilliseconds 200 else { 201 throw RadrootsIdentityCustodyError.invalidMetadata 202 } 203 self.identityHandle = identityHandle 204 self.publicKeyHex = publicKeyHex 205 self.label = try Self.normalizedLabel(label) 206 self.createdAtUnixMilliseconds = createdAtUnixMilliseconds 207 self.updatedAtUnixMilliseconds = updatedAtUnixMilliseconds 208 } 209 210 static func normalizedLabel(_ value: String?) throws -> String? { 211 guard let value else { 212 return nil 213 } 214 let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) 215 guard !normalized.isEmpty, 216 normalized.utf8.count <= 128, 217 !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) 218 else { 219 throw RadrootsIdentityCustodyError.invalidMetadata 220 } 221 return normalized 222 } 223 224 static func validHandle(_ value: String) -> Bool { 225 value.hasPrefix("rrid1_") && value.count == 70 226 && validHex(String(value.dropFirst(6)), byteCount: 32) 227 } 228 229 static func validHex(_ value: String, byteCount: Int) -> Bool { 230 value.count == byteCount * 2 231 && value.unicodeScalars.allSatisfy { 232 (48 ... 57).contains($0.value) || (97 ... 102).contains($0.value) 233 } 234 } 235 } 236 237 public enum RadrootsIdentityState: String, Codable, Sendable { 238 case absent 239 case locked 240 case unlocked 241 case protectedDataUnavailable 242 case recoveryRequired 243 case corrupt 244 } 245 246 public struct RadrootsIdentitySnapshot: Equatable, Sendable { 247 public let state: RadrootsIdentityState 248 public let identity: RadrootsIdentityPublicRecord? 249 public let signerHandle: String? 250 public let recoveryCode: String? 251 252 public init( 253 state: RadrootsIdentityState, 254 identity: RadrootsIdentityPublicRecord?, 255 signerHandle: String?, 256 recoveryCode: String? 257 ) { 258 self.state = state 259 self.identity = identity 260 self.signerHandle = signerHandle 261 self.recoveryCode = recoveryCode 262 } 263 } 264 265 public enum RadrootsOpaqueSignPurpose: String, Codable, Sendable { 266 case nostrEvent = "nostr_event" 267 case blossomUpload = "blossom_upload" 268 } 269 270 public struct RadrootsOpaqueSignRequest: Sendable, CustomDebugStringConvertible { 271 public let operationID: String 272 public let signerHandle: String 273 public let publicKeyHex: String 274 public let digest: Data 275 public let purpose: RadrootsOpaqueSignPurpose 276 public let deadlineUnixMilliseconds: UInt64 277 278 public init( 279 operationID: String, 280 signerHandle: String, 281 publicKeyHex: String, 282 digest: Data, 283 purpose: RadrootsOpaqueSignPurpose, 284 deadlineUnixMilliseconds: UInt64 285 ) throws { 286 guard Self.canonicalUUID(operationID), 287 Self.canonicalUUID(signerHandle), 288 RadrootsIdentityPublicRecord.validHex(publicKeyHex, byteCount: 32), 289 digest.count == 32, 290 deadlineUnixMilliseconds > 0 291 else { 292 throw RadrootsIdentityCustodyError.invalidSignRequest 293 } 294 self.operationID = operationID 295 self.signerHandle = signerHandle 296 self.publicKeyHex = publicKeyHex 297 self.digest = digest 298 self.purpose = purpose 299 self.deadlineUnixMilliseconds = deadlineUnixMilliseconds 300 } 301 302 public var debugDescription: String { 303 "RadrootsOpaqueSignRequest(operationID: \(operationID), purpose: \(purpose.rawValue), payload: <redacted>)" 304 } 305 306 static func canonicalUUID(_ value: String) -> Bool { 307 UUID(uuidString: value)?.uuidString.lowercased() == value 308 } 309 } 310 311 public struct RadrootsOpaqueSignature: Equatable, Sendable, CustomDebugStringConvertible { 312 public let operationID: String 313 public let publicKeyHex: String 314 public let signature: Data 315 public let purpose: RadrootsOpaqueSignPurpose 316 317 public init( 318 operationID: String, 319 publicKeyHex: String, 320 signature: Data, 321 purpose: RadrootsOpaqueSignPurpose 322 ) throws { 323 guard RadrootsOpaqueSignRequest.canonicalUUID(operationID), 324 RadrootsIdentityPublicRecord.validHex(publicKeyHex, byteCount: 32), 325 signature.count == 64 326 else { 327 throw RadrootsIdentityCustodyError.invalidSignature 328 } 329 self.operationID = operationID 330 self.publicKeyHex = publicKeyHex 331 self.signature = signature 332 self.purpose = purpose 333 } 334 335 public var debugDescription: String { 336 "RadrootsOpaqueSignature(operationID: \(operationID), purpose: \(purpose.rawValue), signature: <redacted>)" 337 } 338 } 339 340 public enum RadrootsIdentityCustodyError: String, Error, Sendable { 341 case invalidConfiguration = "identity.invalid_configuration" 342 case invalidSecret = "identity.invalid_secret" 343 case invalidPassphrase = "identity.invalid_passphrase" 344 case invalidMetadata = "identity.invalid_metadata" 345 case corruptMetadata = "identity.corrupt_metadata" 346 case inconsistentState = "identity.inconsistent_state" 347 case identityAlreadyExists = "identity.already_exists" 348 case identityNotFound = "identity.not_found" 349 case identityLocked = "identity.locked" 350 case protectedDataUnavailable = "identity.protected_data_unavailable" 351 case userPresenceRequired = "identity.user_presence_required" 352 case invalidSignRequest = "identity.invalid_sign_request" 353 case staleSigner = "identity.stale_signer" 354 case duplicateOperation = "identity.duplicate_operation" 355 case signingSaturated = "identity.signing_saturated" 356 case cancelled = "identity.cancelled" 357 case timedOut = "identity.timed_out" 358 case invalidSignature = "identity.invalid_signature" 359 case recoveryRequired = "identity.recovery_required" 360 case unsupportedPortabilityEnvelope = "identity.unsupported_portability_envelope" 361 case portabilityAuthenticationFailed = "identity.portability_authentication_failed" 362 case storageUnavailable = "identity.storage_unavailable" 363 case cryptographyFailed = "identity.cryptography_failed" 364 365 public var code: String { 366 rawValue 367 } 368 } 369 370 extension RadrootsIdentityCustodyError: LocalizedError { 371 public var errorDescription: String? { 372 switch self { 373 case .invalidConfiguration: "Identity storage configuration is invalid." 374 case .invalidSecret: "The identity secret is invalid." 375 case .invalidPassphrase: "The portability passphrase is invalid." 376 case .invalidMetadata: "Identity metadata is invalid." 377 case .corruptMetadata: "Identity metadata is corrupt and requires recovery." 378 case .inconsistentState: "Identity storage is inconsistent and requires recovery." 379 case .identityAlreadyExists: "A local identity already exists." 380 case .identityNotFound: "No local identity is available." 381 case .identityLocked: "The local identity is locked." 382 case .protectedDataUnavailable: "Protected identity data is unavailable." 383 case .userPresenceRequired: "User presence was not verified." 384 case .invalidSignRequest: "The signing request is invalid." 385 case .staleSigner: "The signer handle is no longer active." 386 case .duplicateOperation: "The signing operation is already active." 387 case .signingSaturated: "The signer is temporarily at capacity." 388 case .cancelled: "The signing operation was cancelled." 389 case .timedOut: "The signing operation timed out." 390 case .invalidSignature: "The signing result failed verification." 391 case .recoveryRequired: "Identity recovery must complete before continuing." 392 case .unsupportedPortabilityEnvelope: "The encrypted identity envelope is unsupported." 393 case .portabilityAuthenticationFailed: 394 "The encrypted identity envelope could not be authenticated." 395 case .storageUnavailable: "Identity storage is unavailable." 396 case .cryptographyFailed: "The identity cryptography operation failed." 397 } 398 } 399 }