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

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 }