commit 80f40b55cdf903c1b6ebf46fef4f0da3437e8469
parent eda7033bb978ad72e976860ce6f528afccc3bb37
Author: triesap <tyson@radroots.org>
Date: Fri, 7 Aug 2026 19:31:37 +0000
feat(identity): add native opaque signer custody
- keep one active Nostr identity in Apple-native protected storage
- bind opaque signing to operation, signer, public key, purpose, and deadline
- recover interrupted metadata and Keychain replacement or deletion transactions
- support authenticated encrypted portability with native and fault-injection tests
Diffstat:
8 files changed, 2227 insertions(+), 0 deletions(-)
diff --git a/Package.resolved b/Package.resolved
@@ -0,0 +1,14 @@
+{
+ "originHash" : "5534f00c4fa123da227258814ec4154b45a69dd0d21cf8a595075c7c18367a20",
+ "pins" : [
+ {
+ "identity" : "swift-secp256k1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/21-DOT-DEV/swift-secp256k1.git",
+ "state" : {
+ "revision" : "e70a10e036a55fffea31568f0af92d69b6d449cd"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
@@ -17,9 +17,18 @@ let package = Package(
targets: ["RadrootsKitTesting"]
)
],
+ dependencies: [
+ .package(
+ url: "https://github.com/21-DOT-DEV/swift-secp256k1.git",
+ revision: "e70a10e036a55fffea31568f0af92d69b6d449cd"
+ )
+ ],
targets: [
.target(
name: "RadrootsKit",
+ dependencies: [
+ .product(name: "P256K", package: "swift-secp256k1")
+ ],
linkerSettings: [
.linkedFramework("Security"),
.linkedFramework("LocalAuthentication"),
diff --git a/Sources/RadrootsKit/RadrootsAppleIdentityMetadataStore.swift b/Sources/RadrootsKit/RadrootsAppleIdentityMetadataStore.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+public final class RadrootsAppleIdentityMetadataStore: RadrootsIdentityMetadataStore,
+ @unchecked Sendable
+{
+ private let lock = NSLock()
+ private let userDefaults: UserDefaults
+ private let keyPrefix: String
+
+ public init(
+ namespace: String,
+ userDefaults: UserDefaults = .standard,
+ keyPrefix: String = "org.radroots.kit.identity"
+ ) throws {
+ self.userDefaults = userDefaults
+ self.keyPrefix = try Self.normalizedPrefix(keyPrefix, namespace: namespace)
+ }
+
+ public func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? {
+ lock.lock()
+ defer { lock.unlock() }
+ return userDefaults.data(forKey: key(for: slot))
+ }
+
+ public func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws {
+ guard !data.isEmpty, data.count <= 64 * 1_024 else {
+ throw RadrootsIdentityCustodyError.invalidMetadata
+ }
+ lock.lock()
+ userDefaults.set(data, forKey: key(for: slot))
+ lock.unlock()
+ }
+
+ public func delete(_ slot: RadrootsIdentityMetadataSlot) throws {
+ lock.lock()
+ userDefaults.removeObject(forKey: key(for: slot))
+ lock.unlock()
+ }
+
+ func key(for slot: RadrootsIdentityMetadataSlot) -> String {
+ "\(keyPrefix).\(slot.rawValue)"
+ }
+
+ private static func normalizedPrefix(_ value: String, namespace: String) throws -> String {
+ let prefix = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ let namespace = namespace.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !prefix.isEmpty,
+ prefix.utf8.count <= 128,
+ !namespace.isEmpty,
+ namespace.utf8.count <= 128,
+ !prefix.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
+ !namespace.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains)
+ else {
+ throw RadrootsIdentityCustodyError.invalidConfiguration
+ }
+ return "\(prefix).\(namespace)"
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsIdentityCryptography.swift b/Sources/RadrootsKit/RadrootsIdentityCryptography.swift
@@ -0,0 +1,332 @@
+import CryptoKit
+import Foundation
+import P256K
+import Security
+
+public struct RadrootsIdentityPortabilityEnvelope: Sendable, CustomDebugStringConvertible {
+ private let serialized: Data
+
+ public init(serializedRepresentation: Data) throws {
+ _ = try RadrootsIdentityPortabilityCodec.decodeWire(serializedRepresentation)
+ self.serialized = serializedRepresentation
+ }
+
+ public var serializedRepresentation: Data {
+ serialized
+ }
+
+ public var version: UInt16 {
+ (try? RadrootsIdentityPortabilityCodec.decodeWire(serialized).version) ?? 0
+ }
+
+ public var debugDescription: String {
+ "RadrootsIdentityPortabilityEnvelope(version: \(version), encryptedPayload: <redacted>)"
+ }
+}
+
+struct RadrootsIdentityCryptography: Sendable {
+ func generateSecret() throws -> Data {
+ do {
+ return try P256K.Schnorr.PrivateKey().dataRepresentation
+ } catch {
+ throw RadrootsIdentityCustodyError.cryptographyFailed
+ }
+ }
+
+ func publicKeyHex(for secret: Data) throws -> String {
+ do {
+ return Self.hex(try P256K.Schnorr.PrivateKey(dataRepresentation: secret).xonly.bytes)
+ } catch {
+ throw RadrootsIdentityCustodyError.invalidSecret
+ }
+ }
+
+ func identityHandle(forPublicKeyHex publicKeyHex: String) throws -> String {
+ guard let bytes = Self.decodeHex(publicKeyHex), bytes.count == 32 else {
+ throw RadrootsIdentityCustodyError.invalidMetadata
+ }
+ return "rrid1_\(Self.hex(CryptoKit.SHA256.hash(data: bytes)))"
+ }
+
+ func sign(secret: Data, digest: Data) throws -> Data {
+ guard digest.count == 32 else {
+ throw RadrootsIdentityCustodyError.invalidSignRequest
+ }
+ do {
+ let key = try P256K.Schnorr.PrivateKey(dataRepresentation: secret)
+ var message = [UInt8](digest)
+ var auxiliary = [UInt8](repeating: 0, count: 32)
+ guard SecRandomCopyBytes(kSecRandomDefault, auxiliary.count, &auxiliary) == errSecSuccess
+ else {
+ throw RadrootsIdentityCustodyError.cryptographyFailed
+ }
+ let signature = try auxiliary.withUnsafeMutableBytes { buffer in
+ try key.signature(
+ message: &message,
+ auxiliaryRand: buffer.baseAddress,
+ strict: true
+ )
+ }
+ let signatureData = signature.dataRepresentation
+ guard key.xonly.isValid(signature, for: &message) else {
+ throw RadrootsIdentityCustodyError.invalidSignature
+ }
+ return signatureData
+ } catch let error as RadrootsIdentityCustodyError {
+ throw error
+ } catch {
+ throw RadrootsIdentityCustodyError.cryptographyFailed
+ }
+ }
+
+ func verify(signature: Data, digest: Data, publicKeyHex: String) -> Bool {
+ guard signature.count == 64,
+ digest.count == 32,
+ let publicKey = Self.decodeHex(publicKeyHex),
+ publicKey.count == 32,
+ let parsedSignature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: signature)
+ else {
+ return false
+ }
+ let key = P256K.Schnorr.XonlyKey(dataRepresentation: publicKey)
+ var message = [UInt8](digest)
+ return key.isValid(parsedSignature, for: &message)
+ }
+
+ static func hex<S: Sequence>(_ bytes: S) -> String where S.Element == UInt8 {
+ bytes.map { String(format: "%02x", $0) }.joined()
+ }
+
+ static func decodeHex(_ value: String) -> Data? {
+ guard value.count.isMultiple(of: 2),
+ value.unicodeScalars.allSatisfy({
+ (48...57).contains($0.value) || (97...102).contains($0.value)
+ })
+ else {
+ return nil
+ }
+ var output = Data(capacity: value.count / 2)
+ var index = value.startIndex
+ while index < value.endIndex {
+ let next = value.index(index, offsetBy: 2)
+ guard let byte = UInt8(value[index..<next], radix: 16) else {
+ return nil
+ }
+ output.append(byte)
+ index = next
+ }
+ return output
+ }
+}
+
+enum RadrootsIdentityPortabilityCodec {
+ static let version: UInt16 = 1
+ static let kdf = "pbkdf2-hmac-sha256"
+ static let cipher = "aes-256-gcm"
+ static let iterations: UInt32 = 210_000
+ static let maximumEnvelopeBytes = 128 * 1_024
+
+ struct Wire: Codable {
+ let version: UInt16
+ let kdf: String
+ let iterations: UInt32
+ let cipher: String
+ let publicKeyHex: String
+ let salt: Data
+ let nonce: Data
+ let ciphertext: Data
+ let tag: Data
+ }
+
+ struct Plaintext: Codable {
+ let version: UInt16
+ let secret: Data
+ let publicKeyHex: String
+ let label: String?
+ let createdAtUnixMilliseconds: UInt64
+ }
+
+ static func seal(
+ secret: Data,
+ record: RadrootsIdentityPublicRecord,
+ passphrase: RadrootsIdentityPassphrase
+ ) throws -> RadrootsIdentityPortabilityEnvelope {
+ var salt = [UInt8](repeating: 0, count: 16)
+ guard SecRandomCopyBytes(kSecRandomDefault, salt.count, &salt) == errSecSuccess else {
+ throw RadrootsIdentityCustodyError.cryptographyFailed
+ }
+ let plaintext = Plaintext(
+ version: version,
+ secret: secret,
+ publicKeyHex: record.publicKeyHex,
+ label: record.label,
+ createdAtUnixMilliseconds: record.createdAtUnixMilliseconds
+ )
+ var cleartext = try encoder().encode(plaintext)
+ defer { cleartext.resetBytes(in: cleartext.startIndex..<cleartext.endIndex) }
+ do {
+ let key = try deriveKey(
+ passphrase: passphrase.copyBytes(),
+ salt: Data(salt),
+ iterations: iterations
+ )
+ let nonce = AES.GCM.Nonce()
+ let sealed = try AES.GCM.seal(
+ cleartext,
+ using: key,
+ nonce: nonce,
+ authenticating: aad(
+ version: version,
+ kdf: kdf,
+ iterations: iterations,
+ cipher: cipher,
+ publicKeyHex: record.publicKeyHex
+ )
+ )
+ let wire = Wire(
+ version: version,
+ kdf: kdf,
+ iterations: iterations,
+ cipher: cipher,
+ publicKeyHex: record.publicKeyHex,
+ salt: Data(salt),
+ nonce: nonce.withUnsafeBytes { Data($0) },
+ ciphertext: sealed.ciphertext,
+ tag: sealed.tag
+ )
+ return try RadrootsIdentityPortabilityEnvelope(
+ serializedRepresentation: encoder().encode(wire)
+ )
+ } catch let error as RadrootsIdentityCustodyError {
+ throw error
+ } catch {
+ throw RadrootsIdentityCustodyError.cryptographyFailed
+ }
+ }
+
+ static func open(
+ _ envelope: RadrootsIdentityPortabilityEnvelope,
+ passphrase: RadrootsIdentityPassphrase
+ ) throws -> (RadrootsIdentitySecretMaterial, String?, UInt64) {
+ let wire = try decodeWire(envelope.serializedRepresentation)
+ do {
+ let key = try deriveKey(
+ passphrase: passphrase.copyBytes(),
+ salt: wire.salt,
+ iterations: wire.iterations
+ )
+ let nonce = try AES.GCM.Nonce(data: wire.nonce)
+ let box = try AES.GCM.SealedBox(
+ nonce: nonce,
+ ciphertext: wire.ciphertext,
+ tag: wire.tag
+ )
+ var cleartext = try AES.GCM.open(
+ box,
+ using: key,
+ authenticating: aad(
+ version: wire.version,
+ kdf: wire.kdf,
+ iterations: wire.iterations,
+ cipher: wire.cipher,
+ publicKeyHex: wire.publicKeyHex
+ )
+ )
+ defer { cleartext.resetBytes(in: cleartext.startIndex..<cleartext.endIndex) }
+ let plaintext = try decoder().decode(Plaintext.self, from: cleartext)
+ guard plaintext.version == version,
+ plaintext.publicKeyHex == wire.publicKeyHex,
+ plaintext.createdAtUnixMilliseconds > 0
+ else {
+ throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed
+ }
+ let material = try RadrootsIdentitySecretMaterial(rawRepresentation: plaintext.secret)
+ let derived = try RadrootsIdentityCryptography().publicKeyHex(for: plaintext.secret)
+ guard derived == wire.publicKeyHex else {
+ throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed
+ }
+ return (material, plaintext.label, plaintext.createdAtUnixMilliseconds)
+ } catch let error as RadrootsIdentityCustodyError {
+ throw error
+ } catch {
+ throw RadrootsIdentityCustodyError.portabilityAuthenticationFailed
+ }
+ }
+
+ static func decodeWire(_ serialized: Data) throws -> Wire {
+ guard !serialized.isEmpty, serialized.count <= maximumEnvelopeBytes else {
+ throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope
+ }
+ do {
+ let wire = try decoder().decode(Wire.self, from: serialized)
+ guard wire.version == version,
+ wire.kdf == kdf,
+ wire.iterations == iterations,
+ wire.cipher == cipher,
+ RadrootsIdentityPublicRecord.validHex(wire.publicKeyHex, byteCount: 32),
+ wire.salt.count == 16,
+ wire.nonce.count == 12,
+ !wire.ciphertext.isEmpty,
+ wire.ciphertext.count <= 64 * 1_024,
+ wire.tag.count == 16
+ else {
+ throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope
+ }
+ return wire
+ } catch let error as RadrootsIdentityCustodyError {
+ throw error
+ } catch {
+ throw RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope
+ }
+ }
+
+ private static func aad(
+ version: UInt16,
+ kdf: String,
+ iterations: UInt32,
+ cipher: String,
+ publicKeyHex: String
+ ) -> Data {
+ Data(
+ "org.radroots.identity.portability|\(version)|\(kdf)|\(iterations)|\(cipher)|\(publicKeyHex)"
+ .utf8)
+ }
+
+ private static func deriveKey(
+ passphrase: Data,
+ salt: Data,
+ iterations: UInt32
+ ) throws -> SymmetricKey {
+ guard !passphrase.isEmpty, iterations == Self.iterations else {
+ throw RadrootsIdentityCustodyError.invalidPassphrase
+ }
+ let key = SymmetricKey(data: passphrase)
+ var block = salt
+ block.append(contentsOf: [0, 0, 0, 1])
+ var previous = Data(HMAC<CryptoKit.SHA256>.authenticationCode(for: block, using: key))
+ var output = previous
+ if iterations > 1 {
+ for _ in 2...iterations {
+ previous = Data(HMAC<CryptoKit.SHA256>.authenticationCode(for: previous, using: key))
+ for index in output.indices {
+ output[index] ^= previous[index]
+ }
+ }
+ }
+ defer {
+ previous.resetBytes(in: previous.startIndex..<previous.endIndex)
+ output.resetBytes(in: output.startIndex..<output.endIndex)
+ }
+ return SymmetricKey(data: output)
+ }
+
+ private static func encoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ return encoder
+ }
+
+ private static func decoder() -> JSONDecoder {
+ JSONDecoder()
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsIdentityCustody.swift b/Sources/RadrootsKit/RadrootsIdentityCustody.swift
@@ -0,0 +1,886 @@
+import Foundation
+
+public struct RadrootsIdentityCustodyConfiguration: Sendable {
+ public let namespace: String
+ public let secretPolicy: RadrootsSecretAccessPolicy
+
+ public init(
+ namespace: String,
+ secretPolicy: RadrootsSecretAccessPolicy = .userPresenceLocalSecret
+ ) throws {
+ let normalized = namespace.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalized.isEmpty,
+ normalized.utf8.count <= 128,
+ !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains)
+ else {
+ throw RadrootsIdentityCustodyError.invalidConfiguration
+ }
+ self.namespace = normalized
+ self.secretPolicy = secretPolicy
+ }
+}
+
+public actor RadrootsIdentityCustody {
+ private struct UnlockedSession {
+ let record: RadrootsIdentityPublicRecord
+ let signerHandle: String
+ let generation: UInt64
+ }
+
+ private enum TransactionKind: String, Codable {
+ case replace
+ case delete
+ }
+
+ private enum TransactionPhase: String, Codable {
+ case prepared
+ case activeSecretCommitted
+ case metadataCommitted
+ case activeSecretRemoved
+ case metadataRemoved
+ }
+
+ private struct TransactionJournal: Codable {
+ let version: UInt16
+ let operationID: String
+ let kind: TransactionKind
+ var phase: TransactionPhase
+ let previous: RadrootsIdentityPublicRecord?
+ let candidate: RadrootsIdentityPublicRecord?
+ let startedAtUnixMilliseconds: UInt64
+
+ func validated() throws -> Self {
+ guard version == 1,
+ RadrootsOpaqueSignRequest.canonicalUUID(operationID),
+ startedAtUnixMilliseconds > 0,
+ (kind == .replace) == (candidate != nil)
+ else {
+ throw RadrootsIdentityCustodyError.corruptMetadata
+ }
+ if let previous {
+ _ = try RadrootsIdentityPublicRecord(
+ identityHandle: previous.identityHandle,
+ publicKeyHex: previous.publicKeyHex,
+ label: previous.label,
+ createdAtUnixMilliseconds: previous.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: previous.updatedAtUnixMilliseconds
+ )
+ }
+ if let candidate {
+ _ = try RadrootsIdentityPublicRecord(
+ identityHandle: candidate.identityHandle,
+ publicKeyHex: candidate.publicKeyHex,
+ label: candidate.label,
+ createdAtUnixMilliseconds: candidate.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: candidate.updatedAtUnixMilliseconds
+ )
+ }
+ return self
+ }
+ }
+
+ private let configuration: RadrootsIdentityCustodyConfiguration
+ private let secureStore: any RadrootsSecureStore
+ private let metadataStore: any RadrootsIdentityMetadataStore
+ private let userPresence: any RadrootsUserPresence
+ private let protectedData: RadrootsProtectedDataProvider
+ private let now: @Sendable () -> UInt64
+ private let cryptography = RadrootsIdentityCryptography()
+ private var session: UnlockedSession?
+ private var generation: UInt64 = 0
+ private var activeOperations = Set<String>()
+ private var cancelledOperations: [String: UInt64] = [:]
+ private let maximumActiveSigningOperations = 8
+
+ public init(
+ configuration: RadrootsIdentityCustodyConfiguration,
+ secureStore: any RadrootsSecureStore,
+ metadataStore: any RadrootsIdentityMetadataStore,
+ userPresence: any RadrootsUserPresence,
+ protectedData: RadrootsProtectedDataProvider = .available,
+ now: @escaping @Sendable () -> UInt64 = {
+ UInt64(Date().timeIntervalSince1970 * 1_000)
+ }
+ ) {
+ self.configuration = configuration
+ self.secureStore = secureStore
+ self.metadataStore = metadataStore
+ self.userPresence = userPresence
+ self.protectedData = protectedData
+ self.now = now
+ }
+
+ public func snapshot() -> RadrootsIdentitySnapshot {
+ do {
+ let record = try loadRecord()
+ if try loadJournal() != nil {
+ return Self.snapshot(.recoveryRequired, record, nil, "identity.transaction_pending")
+ }
+ let hasSecret = try secureStore.contains(secretKey(.active))
+ guard record != nil || hasSecret else {
+ return Self.snapshot(.absent, nil, nil, nil)
+ }
+ guard let record, hasSecret else {
+ return Self.snapshot(.corrupt, record, nil, "identity.inconsistent_state")
+ }
+ guard protectedData.currentState() == .available else {
+ return Self.snapshot(
+ .protectedDataUnavailable, record, nil, "identity.protected_data_unavailable")
+ }
+ if let session, session.record == record {
+ return Self.snapshot(.unlocked, record, session.signerHandle, nil)
+ }
+ return Self.snapshot(.locked, record, nil, nil)
+ } catch RadrootsIdentityCustodyError.corruptMetadata {
+ return Self.snapshot(.corrupt, nil, nil, "identity.corrupt_metadata")
+ } catch {
+ return Self.snapshot(.recoveryRequired, nil, nil, "identity.storage_unavailable")
+ }
+ }
+
+ @discardableResult
+ public func recover() throws -> RadrootsIdentitySnapshot {
+ try requireProtectedData()
+ guard var journal = try loadJournal() else {
+ try cleanupTransactionSecrets()
+ return snapshot()
+ }
+ session = nil
+ generation &+= 1
+ do {
+ switch journal.kind {
+ case .replace:
+ try recoverReplace(&journal)
+ case .delete:
+ try recoverDelete(&journal)
+ }
+ return snapshot()
+ } catch let error as RadrootsIdentityCustodyError {
+ throw error
+ } catch {
+ throw RadrootsIdentityCustodyError.recoveryRequired
+ }
+ }
+
+ @discardableResult
+ public func createIdentity(label: String? = nil) async throws -> RadrootsIdentitySnapshot {
+ _ = try recover()
+ guard try loadRecord() == nil, try !secureStore.contains(secretKey(.active)) else {
+ throw RadrootsIdentityCustodyError.identityAlreadyExists
+ }
+ let expectedGeneration = generation
+ try await requireUserPresence(reason: "Create your local Nostr identity.")
+ guard generation == expectedGeneration else {
+ throw RadrootsIdentityCustodyError.recoveryRequired
+ }
+ let secret = try cryptography.generateSecret()
+ return try commitReplacement(
+ material: RadrootsIdentitySecretMaterial(rawRepresentation: secret),
+ label: label,
+ importedCreatedAt: nil,
+ replaceExisting: false
+ )
+ }
+
+ @discardableResult
+ public func importIdentity(
+ _ material: RadrootsIdentitySecretMaterial,
+ label: String? = nil,
+ replaceExisting: Bool = false
+ ) async throws -> RadrootsIdentitySnapshot {
+ _ = try recover()
+ _ = try cryptography.publicKeyHex(for: material.copyBytes())
+ let existing = try loadRecord()
+ let hasActive = try secureStore.contains(secretKey(.active))
+ guard (existing != nil) == hasActive else {
+ throw RadrootsIdentityCustodyError.inconsistentState
+ }
+ if existing != nil, !replaceExisting {
+ throw RadrootsIdentityCustodyError.identityAlreadyExists
+ }
+ let expectedGeneration = generation
+ try await requireUserPresence(reason: "Import your local Nostr identity.")
+ guard generation == expectedGeneration else {
+ throw RadrootsIdentityCustodyError.recoveryRequired
+ }
+ return try commitReplacement(
+ material: material,
+ label: label,
+ importedCreatedAt: nil,
+ replaceExisting: replaceExisting
+ )
+ }
+
+ @discardableResult
+ public func importPortableIdentity(
+ _ envelope: RadrootsIdentityPortabilityEnvelope,
+ passphrase: RadrootsIdentityPassphrase,
+ replaceExisting: Bool = false
+ ) async throws -> RadrootsIdentitySnapshot {
+ let opened = try RadrootsIdentityPortabilityCodec.open(envelope, passphrase: passphrase)
+ _ = try recover()
+ let existing = try loadRecord()
+ let hasActive = try secureStore.contains(secretKey(.active))
+ guard (existing != nil) == hasActive else {
+ throw RadrootsIdentityCustodyError.inconsistentState
+ }
+ if existing != nil, !replaceExisting {
+ throw RadrootsIdentityCustodyError.identityAlreadyExists
+ }
+ let expectedGeneration = generation
+ try await requireUserPresence(reason: "Import your encrypted Nostr identity.")
+ guard generation == expectedGeneration else {
+ throw RadrootsIdentityCustodyError.recoveryRequired
+ }
+ return try commitReplacement(
+ material: opened.0,
+ label: opened.1,
+ importedCreatedAt: opened.2,
+ replaceExisting: replaceExisting
+ )
+ }
+
+ public func exportPortableIdentity(
+ passphrase: RadrootsIdentityPassphrase
+ ) async throws -> RadrootsIdentityPortabilityEnvelope {
+ try requireProtectedData()
+ guard let session else {
+ throw RadrootsIdentityCustodyError.identityLocked
+ }
+ let expectedGeneration = session.generation
+ try await requireUserPresence(reason: "Export your encrypted Nostr identity.")
+ guard let current = self.session,
+ current.generation == expectedGeneration,
+ current.signerHandle == session.signerHandle
+ else {
+ throw RadrootsIdentityCustodyError.staleSigner
+ }
+ var secret = try readSecret(.active)
+ defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) }
+ guard try cryptography.publicKeyHex(for: secret) == current.record.publicKeyHex else {
+ throw RadrootsIdentityCustodyError.inconsistentState
+ }
+ return try RadrootsIdentityPortabilityCodec.seal(
+ secret: secret,
+ record: current.record,
+ passphrase: passphrase
+ )
+ }
+
+ @discardableResult
+ public func migrateLegacyIdentity(
+ from legacyKey: RadrootsSecureStoreKey,
+ label: String? = nil
+ ) async throws -> RadrootsIdentitySnapshot {
+ if let existing = try loadRecord(), try secureStore.contains(secretKey(.active)) {
+ guard let legacy = try secureStore.get(legacyKey) else {
+ return snapshot()
+ }
+ guard let text = String(data: legacy, encoding: .utf8),
+ let material = try? RadrootsIdentitySecretMaterial(importText: text),
+ try cryptography.publicKeyHex(for: material.copyBytes()) == existing.publicKeyHex
+ else {
+ throw RadrootsIdentityCustodyError.inconsistentState
+ }
+ try secureStore.delete(legacyKey)
+ return snapshot()
+ }
+ guard let legacy = try secureStore.get(legacyKey),
+ let text = String(data: legacy, encoding: .utf8)
+ else {
+ throw RadrootsIdentityCustodyError.identityNotFound
+ }
+ let material = try RadrootsIdentitySecretMaterial(importText: text)
+ let result = try await importIdentity(material, label: label)
+ do {
+ try secureStore.delete(legacyKey)
+ } catch {
+ throw RadrootsIdentityCustodyError.recoveryRequired
+ }
+ return result
+ }
+
+ @discardableResult
+ public func unlockIdentity() async throws -> RadrootsIdentitySnapshot {
+ _ = try recover()
+ try requireProtectedData()
+ let record = try requiredRecord()
+ let expectedGeneration = generation
+ try await requireUserPresence(reason: "Unlock your local Nostr identity.")
+ guard generation == expectedGeneration else {
+ throw RadrootsIdentityCustodyError.staleSigner
+ }
+ var secret = try readSecret(.active)
+ defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) }
+ guard try cryptography.publicKeyHex(for: secret) == record.publicKeyHex else {
+ throw RadrootsIdentityCustodyError.inconsistentState
+ }
+ generation &+= 1
+ let handle = UUID().uuidString.lowercased()
+ session = UnlockedSession(record: record, signerHandle: handle, generation: generation)
+ return Self.snapshot(.unlocked, record, handle, nil)
+ }
+
+ @discardableResult
+ public func selectIdentity(identityHandle: String) async throws -> RadrootsIdentitySnapshot {
+ guard RadrootsIdentityPublicRecord.validHandle(identityHandle) else {
+ throw RadrootsIdentityCustodyError.invalidMetadata
+ }
+ let record = try requiredRecord()
+ guard record.identityHandle == identityHandle else {
+ throw RadrootsIdentityCustodyError.identityNotFound
+ }
+ return try await unlockIdentity()
+ }
+
+ public func lockIdentity() {
+ generation &+= 1
+ session = nil
+ let cancellationTime = now()
+ for operationID in activeOperations {
+ cancelledOperations[operationID] = cancellationTime
+ }
+ pruneCancellations()
+ }
+
+ @discardableResult
+ public func deleteIdentity() async throws -> RadrootsIdentitySnapshot {
+ _ = try recover()
+ try requireProtectedData()
+ let record = try requiredRecord()
+ let expectedGeneration = generation
+ try await requireUserPresence(reason: "Delete your local Nostr identity.")
+ guard generation == expectedGeneration,
+ try requiredRecord() == record
+ else {
+ throw RadrootsIdentityCustodyError.recoveryRequired
+ }
+ lockIdentity()
+ let operationID = UUID().uuidString.lowercased()
+ let backup = try readSecret(.active)
+ try secureStore.put(backup, for: secretKey(.backup), policy: configuration.secretPolicy)
+ var journal = TransactionJournal(
+ version: 1,
+ operationID: operationID,
+ kind: .delete,
+ phase: .prepared,
+ previous: record,
+ candidate: nil,
+ startedAtUnixMilliseconds: now()
+ )
+ try writeJournal(journal)
+ do {
+ try secureStore.delete(secretKey(.active))
+ journal.phase = .activeSecretRemoved
+ try writeJournal(journal)
+ try metadataStore.delete(.activeIdentity)
+ journal.phase = .metadataRemoved
+ try writeJournal(journal)
+ try cleanupTransactionSecrets()
+ try metadataStore.delete(.transactionJournal)
+ return Self.snapshot(.absent, nil, nil, nil)
+ } catch {
+ throw RadrootsIdentityCustodyError.recoveryRequired
+ }
+ }
+
+ @discardableResult
+ public func repairCorruptMetadata(label: String? = nil) async throws -> RadrootsIdentitySnapshot {
+ try requireProtectedData()
+ guard try loadJournal() == nil, try secureStore.contains(secretKey(.active)) else {
+ throw RadrootsIdentityCustodyError.recoveryRequired
+ }
+ let expectedGeneration = generation
+ try await requireUserPresence(reason: "Repair your local Nostr identity.")
+ guard generation == expectedGeneration else {
+ throw RadrootsIdentityCustodyError.staleSigner
+ }
+ var secret = try readSecret(.active)
+ defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) }
+ let publicKey = try cryptography.publicKeyHex(for: secret)
+ let timestamp = now()
+ let record = try makeRecord(
+ publicKeyHex: publicKey,
+ label: label,
+ createdAt: timestamp,
+ updatedAt: timestamp
+ )
+ try saveRecord(record)
+ try metadataStore.delete(.quarantinedMetadata)
+ generation &+= 1
+ let handle = UUID().uuidString.lowercased()
+ session = UnlockedSession(record: record, signerHandle: handle, generation: generation)
+ return Self.snapshot(.unlocked, record, handle, nil)
+ }
+
+ public func sign(_ request: RadrootsOpaqueSignRequest) async throws -> RadrootsOpaqueSignature {
+ try requireProtectedData()
+ guard now() <= request.deadlineUnixMilliseconds else {
+ throw RadrootsIdentityCustodyError.timedOut
+ }
+ guard cancelledOperations.removeValue(forKey: request.operationID) == nil else {
+ throw RadrootsIdentityCustodyError.cancelled
+ }
+ guard activeOperations.count < maximumActiveSigningOperations else {
+ throw RadrootsIdentityCustodyError.signingSaturated
+ }
+ guard activeOperations.insert(request.operationID).inserted else {
+ throw RadrootsIdentityCustodyError.duplicateOperation
+ }
+ defer { activeOperations.remove(request.operationID) }
+ guard let session else {
+ throw RadrootsIdentityCustodyError.identityLocked
+ }
+ guard session.signerHandle == request.signerHandle,
+ session.record.publicKeyHex == request.publicKeyHex
+ else {
+ throw RadrootsIdentityCustodyError.staleSigner
+ }
+ let expectedGeneration = session.generation
+ try await requireUserPresence(reason: signingReason(request.purpose))
+ guard cancelledOperations.removeValue(forKey: request.operationID) == nil else {
+ throw RadrootsIdentityCustodyError.cancelled
+ }
+ guard now() <= request.deadlineUnixMilliseconds else {
+ throw RadrootsIdentityCustodyError.timedOut
+ }
+ guard let current = self.session,
+ current.generation == expectedGeneration,
+ current.signerHandle == request.signerHandle,
+ current.record.publicKeyHex == request.publicKeyHex
+ else {
+ throw RadrootsIdentityCustodyError.staleSigner
+ }
+ var secret = try readSecret(.active)
+ defer { secret.resetBytes(in: secret.startIndex..<secret.endIndex) }
+ guard try cryptography.publicKeyHex(for: secret) == request.publicKeyHex else {
+ throw RadrootsIdentityCustodyError.inconsistentState
+ }
+ let signature = try cryptography.sign(secret: secret, digest: request.digest)
+ guard
+ cryptography.verify(
+ signature: signature,
+ digest: request.digest,
+ publicKeyHex: request.publicKeyHex
+ )
+ else {
+ throw RadrootsIdentityCustodyError.invalidSignature
+ }
+ return try RadrootsOpaqueSignature(
+ operationID: request.operationID,
+ publicKeyHex: request.publicKeyHex,
+ signature: signature,
+ purpose: request.purpose
+ )
+ }
+
+ public func cancelSigning(operationID: String) throws {
+ guard RadrootsOpaqueSignRequest.canonicalUUID(operationID) else {
+ throw RadrootsIdentityCustodyError.invalidSignRequest
+ }
+ cancelledOperations[operationID] = now()
+ pruneCancellations()
+ }
+
+ private func commitReplacement(
+ material: RadrootsIdentitySecretMaterial,
+ label: String?,
+ importedCreatedAt: UInt64?,
+ replaceExisting: Bool
+ ) throws -> RadrootsIdentitySnapshot {
+ try requireProtectedData()
+ let previous = try loadRecord()
+ let hasActive = try secureStore.contains(secretKey(.active))
+ guard (previous != nil) == hasActive else {
+ throw RadrootsIdentityCustodyError.inconsistentState
+ }
+ if previous != nil, !replaceExisting {
+ throw RadrootsIdentityCustodyError.identityAlreadyExists
+ }
+ let candidateSecret = material.copyBytes()
+ let publicKey = try cryptography.publicKeyHex(for: candidateSecret)
+ let timestamp = now()
+ let createdAt: UInt64
+ if let previous, previous.publicKeyHex == publicKey {
+ createdAt = previous.createdAtUnixMilliseconds
+ } else {
+ createdAt = importedCreatedAt ?? timestamp
+ }
+ let candidate = try makeRecord(
+ publicKeyHex: publicKey,
+ label: label,
+ createdAt: createdAt,
+ updatedAt: max(timestamp, createdAt)
+ )
+ if hasActive {
+ let current = try readSecret(.active)
+ try secureStore.put(current, for: secretKey(.backup), policy: configuration.secretPolicy)
+ } else {
+ try secureStore.delete(secretKey(.backup))
+ }
+ try secureStore.put(
+ candidateSecret, for: secretKey(.candidate), policy: configuration.secretPolicy)
+ var journal = TransactionJournal(
+ version: 1,
+ operationID: UUID().uuidString.lowercased(),
+ kind: .replace,
+ phase: .prepared,
+ previous: previous,
+ candidate: candidate,
+ startedAtUnixMilliseconds: timestamp
+ )
+ try writeJournal(journal)
+ do {
+ try secureStore.put(
+ candidateSecret, for: secretKey(.active), policy: configuration.secretPolicy)
+ journal.phase = .activeSecretCommitted
+ try writeJournal(journal)
+ try saveRecord(candidate)
+ journal.phase = .metadataCommitted
+ try writeJournal(journal)
+ try cleanupTransactionSecrets()
+ try metadataStore.delete(.transactionJournal)
+ } catch {
+ session = nil
+ generation &+= 1
+ throw RadrootsIdentityCustodyError.recoveryRequired
+ }
+ generation &+= 1
+ let handle = UUID().uuidString.lowercased()
+ session = UnlockedSession(record: candidate, signerHandle: handle, generation: generation)
+ return Self.snapshot(.unlocked, candidate, handle, nil)
+ }
+
+ private func recoverReplace(_ journal: inout TransactionJournal) throws {
+ guard let candidate = journal.candidate else {
+ throw RadrootsIdentityCustodyError.corruptMetadata
+ }
+ var activeMatches =
+ try secret(.active).map {
+ try cryptography.publicKeyHex(for: $0) == candidate.publicKeyHex
+ } ?? false
+ if !activeMatches {
+ if let candidateSecret = try secret(.candidate) {
+ guard try cryptography.publicKeyHex(for: candidateSecret) == candidate.publicKeyHex else {
+ throw RadrootsIdentityCustodyError.corruptMetadata
+ }
+ try secureStore.put(
+ candidateSecret,
+ for: secretKey(.active),
+ policy: configuration.secretPolicy
+ )
+ activeMatches = true
+ } else {
+ try rollbackReplacement(journal)
+ return
+ }
+ }
+ guard activeMatches else {
+ throw RadrootsIdentityCustodyError.recoveryRequired
+ }
+ journal.phase = .activeSecretCommitted
+ try writeJournal(journal)
+ try saveRecord(candidate)
+ journal.phase = .metadataCommitted
+ try writeJournal(journal)
+ try cleanupTransactionSecrets()
+ try metadataStore.delete(.transactionJournal)
+ }
+
+ private func rollbackReplacement(_ journal: TransactionJournal) throws {
+ if let previous = journal.previous {
+ guard let backup = try secret(.backup),
+ try cryptography.publicKeyHex(for: backup) == previous.publicKeyHex
+ else {
+ throw RadrootsIdentityCustodyError.recoveryRequired
+ }
+ try secureStore.put(backup, for: secretKey(.active), policy: configuration.secretPolicy)
+ try saveRecord(previous)
+ } else {
+ try secureStore.delete(secretKey(.active))
+ try metadataStore.delete(.activeIdentity)
+ }
+ try cleanupTransactionSecrets()
+ try metadataStore.delete(.transactionJournal)
+ }
+
+ private func recoverDelete(_ journal: inout TransactionJournal) throws {
+ try secureStore.delete(secretKey(.active))
+ journal.phase = .activeSecretRemoved
+ try writeJournal(journal)
+ try metadataStore.delete(.activeIdentity)
+ journal.phase = .metadataRemoved
+ try writeJournal(journal)
+ try cleanupTransactionSecrets()
+ try metadataStore.delete(.transactionJournal)
+ }
+
+ private func requireUserPresence(reason: String) async throws {
+ try requireProtectedData()
+ let request: RadrootsUserPresenceRequest
+ do {
+ request = try RadrootsUserPresenceRequest(reason: reason)
+ } catch {
+ throw RadrootsIdentityCustodyError.invalidConfiguration
+ }
+ do {
+ let result = try await userPresence.verify(request)
+ guard result.verified else {
+ throw RadrootsIdentityCustodyError.userPresenceRequired
+ }
+ } catch let error as RadrootsIdentityCustodyError {
+ throw error
+ } catch let error as RadrootsUserPresenceError {
+ switch error {
+ case .userCancelled:
+ throw RadrootsIdentityCustodyError.cancelled
+ case .timeout:
+ throw RadrootsIdentityCustodyError.timedOut
+ default:
+ throw RadrootsIdentityCustodyError.userPresenceRequired
+ }
+ } catch {
+ throw RadrootsIdentityCustodyError.userPresenceRequired
+ }
+ try requireProtectedData()
+ }
+
+ private func requireProtectedData() throws {
+ guard protectedData.currentState() == .available else {
+ throw RadrootsIdentityCustodyError.protectedDataUnavailable
+ }
+ }
+
+ private func requiredRecord() throws -> RadrootsIdentityPublicRecord {
+ guard let record = try loadRecord() else {
+ throw RadrootsIdentityCustodyError.identityNotFound
+ }
+ guard try secureStore.contains(secretKey(.active)) else {
+ throw RadrootsIdentityCustodyError.inconsistentState
+ }
+ return record
+ }
+
+ private func loadRecord() throws -> RadrootsIdentityPublicRecord? {
+ guard let data = try metadataStore.data(for: .activeIdentity) else {
+ return nil
+ }
+ do {
+ let decoded = try JSONDecoder().decode(RadrootsIdentityPublicRecord.self, from: data)
+ return try RadrootsIdentityPublicRecord(
+ identityHandle: decoded.identityHandle,
+ publicKeyHex: decoded.publicKeyHex,
+ label: decoded.label,
+ createdAtUnixMilliseconds: decoded.createdAtUnixMilliseconds,
+ updatedAtUnixMilliseconds: decoded.updatedAtUnixMilliseconds
+ )
+ } catch {
+ do {
+ try metadataStore.put(data, for: .quarantinedMetadata)
+ } catch {
+ throw RadrootsIdentityCustodyError.storageUnavailable
+ }
+ throw RadrootsIdentityCustodyError.corruptMetadata
+ }
+ }
+
+ private func saveRecord(_ record: RadrootsIdentityPublicRecord) throws {
+ do {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ try metadataStore.put(try encoder.encode(record), for: .activeIdentity)
+ } catch let error as RadrootsIdentityCustodyError {
+ throw error
+ } catch {
+ throw RadrootsIdentityCustodyError.storageUnavailable
+ }
+ }
+
+ private func loadJournal() throws -> TransactionJournal? {
+ guard let data = try metadataStore.data(for: .transactionJournal) else {
+ return nil
+ }
+ do {
+ return try JSONDecoder().decode(TransactionJournal.self, from: data).validated()
+ } catch let error as RadrootsIdentityCustodyError {
+ throw error
+ } catch {
+ throw RadrootsIdentityCustodyError.corruptMetadata
+ }
+ }
+
+ private func writeJournal(_ journal: TransactionJournal) throws {
+ do {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ try metadataStore.put(try encoder.encode(journal.validated()), for: .transactionJournal)
+ } catch let error as RadrootsIdentityCustodyError {
+ throw error
+ } catch {
+ throw RadrootsIdentityCustodyError.storageUnavailable
+ }
+ }
+
+ private enum SecretSlot: String {
+ case active = "active_secret_v1"
+ case candidate = "candidate_secret_v1"
+ case backup = "backup_secret_v1"
+ }
+
+ private func secretKey(_ slot: SecretSlot) -> RadrootsSecureStoreKey {
+ RadrootsSecureStoreKey(namespace: configuration.namespace, name: slot.rawValue)
+ }
+
+ private func secret(_ slot: SecretSlot) throws -> Data? {
+ do {
+ return try secureStore.get(secretKey(slot))
+ } catch {
+ throw RadrootsIdentityCustodyError.storageUnavailable
+ }
+ }
+
+ private func readSecret(_ slot: SecretSlot) throws -> Data {
+ guard let secret = try secret(slot) else {
+ throw RadrootsIdentityCustodyError.inconsistentState
+ }
+ guard secret.count == 32 else {
+ throw RadrootsIdentityCustodyError.invalidSecret
+ }
+ return secret
+ }
+
+ private func cleanupTransactionSecrets() throws {
+ do {
+ try secureStore.delete(secretKey(.candidate))
+ try secureStore.delete(secretKey(.backup))
+ } catch {
+ throw RadrootsIdentityCustodyError.storageUnavailable
+ }
+ }
+
+ private func makeRecord(
+ publicKeyHex: String,
+ label: String?,
+ createdAt: UInt64,
+ updatedAt: UInt64
+ ) throws -> RadrootsIdentityPublicRecord {
+ try RadrootsIdentityPublicRecord(
+ identityHandle: cryptography.identityHandle(forPublicKeyHex: publicKeyHex),
+ publicKeyHex: publicKeyHex,
+ label: label,
+ createdAtUnixMilliseconds: createdAt,
+ updatedAtUnixMilliseconds: updatedAt
+ )
+ }
+
+ private func signingReason(_ purpose: RadrootsOpaqueSignPurpose) -> String {
+ switch purpose {
+ case .nostrEvent:
+ "Sign this Nostr event with your local identity."
+ case .blossomUpload:
+ "Authorize this Blossom media upload with your local identity."
+ }
+ }
+
+ private func pruneCancellations() {
+ guard cancelledOperations.count > 128 else {
+ return
+ }
+ let ordered = cancelledOperations.sorted { $0.value < $1.value }
+ for (operationID, _) in ordered.prefix(cancelledOperations.count - 128) {
+ cancelledOperations.removeValue(forKey: operationID)
+ }
+ }
+
+ private static func snapshot(
+ _ state: RadrootsIdentityState,
+ _ identity: RadrootsIdentityPublicRecord?,
+ _ signerHandle: String?,
+ _ recoveryCode: String?
+ ) -> RadrootsIdentitySnapshot {
+ RadrootsIdentitySnapshot(
+ state: state,
+ identity: identity,
+ signerHandle: signerHandle,
+ recoveryCode: recoveryCode
+ )
+ }
+}
+
+public final class RadrootsOpaqueSignerCancellation: @unchecked Sendable {
+ private let lock = NSLock()
+ private var isCancelled = false
+ private let cancelAction: @Sendable () -> Void
+
+ init(cancelAction: @escaping @Sendable () -> Void) {
+ self.cancelAction = cancelAction
+ }
+
+ public func cancel() {
+ lock.lock()
+ guard !isCancelled else {
+ lock.unlock()
+ return
+ }
+ isCancelled = true
+ lock.unlock()
+ cancelAction()
+ }
+}
+
+public final class RadrootsOpaqueSignerBridge: Sendable {
+ private let custody: RadrootsIdentityCustody
+
+ public init(custody: RadrootsIdentityCustody) {
+ self.custody = custody
+ }
+
+ @discardableResult
+ public func submit(
+ _ request: RadrootsOpaqueSignRequest,
+ completion:
+ @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void
+ ) -> RadrootsOpaqueSignerCancellation {
+ let completionState = RadrootsOpaqueSignerCompletion(completion: completion)
+ let custody = custody
+ let task = Task {
+ do {
+ if Task.isCancelled {
+ try await custody.cancelSigning(operationID: request.operationID)
+ }
+ completionState.finish(.success(try await custody.sign(request)))
+ } catch let error as RadrootsIdentityCustodyError {
+ completionState.finish(.failure(error))
+ } catch {
+ completionState.finish(.failure(.cryptographyFailed))
+ }
+ }
+ return RadrootsOpaqueSignerCancellation {
+ task.cancel()
+ Task {
+ try? await custody.cancelSigning(operationID: request.operationID)
+ }
+ }
+ }
+}
+
+private final class RadrootsOpaqueSignerCompletion: @unchecked Sendable {
+ private let lock = NSLock()
+ private var completion:
+ (@Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void)?
+
+ init(
+ completion:
+ @escaping @Sendable (Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) -> Void
+ ) {
+ self.completion = completion
+ }
+
+ func finish(_ result: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) {
+ lock.lock()
+ let callback = completion
+ completion = nil
+ lock.unlock()
+ callback?(result)
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsIdentityCustodyTypes.swift b/Sources/RadrootsKit/RadrootsIdentityCustodyTypes.swift
@@ -0,0 +1,399 @@
+import Foundation
+
+public enum RadrootsProtectedDataState: String, Codable, Sendable {
+ case available
+ case locked
+ case unavailable
+}
+
+public struct RadrootsProtectedDataProvider: Sendable {
+ private let readState: @Sendable () -> RadrootsProtectedDataState
+
+ public init(readState: @escaping @Sendable () -> RadrootsProtectedDataState) {
+ self.readState = readState
+ }
+
+ public func currentState() -> RadrootsProtectedDataState {
+ readState()
+ }
+
+ public static let available = Self { .available }
+}
+
+public enum RadrootsIdentityMetadataSlot: String, Sendable {
+ case activeIdentity
+ case transactionJournal
+ case quarantinedMetadata
+}
+
+public protocol RadrootsIdentityMetadataStore: AnyObject, Sendable {
+ func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data?
+ func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws
+ func delete(_ slot: RadrootsIdentityMetadataSlot) throws
+}
+
+public struct RadrootsIdentitySecretMaterial: Sendable, CustomDebugStringConvertible {
+ private let bytes: Data
+
+ public init(rawRepresentation: Data) throws {
+ guard rawRepresentation.count == 32 else {
+ throw RadrootsIdentityCustodyError.invalidSecret
+ }
+ self.bytes = rawRepresentation
+ }
+
+ public init(importText: String) throws {
+ let normalized = importText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if normalized.count == 64, let decoded = Self.decodeHex(normalized) {
+ try self.init(rawRepresentation: decoded)
+ return
+ }
+ guard let decoded = Self.decodeNsec(normalized) else {
+ throw RadrootsIdentityCustodyError.invalidSecret
+ }
+ try self.init(rawRepresentation: decoded)
+ }
+
+ public var debugDescription: String {
+ "RadrootsIdentitySecretMaterial(<redacted>)"
+ }
+
+ func copyBytes() -> Data {
+ bytes
+ }
+
+ private static func decodeHex(_ value: String) -> Data? {
+ guard
+ value.unicodeScalars.allSatisfy({
+ (48...57).contains($0.value) || (65...70).contains($0.value)
+ || (97...102).contains($0.value)
+ })
+ else {
+ return nil
+ }
+ var output = Data(capacity: 32)
+ var index = value.startIndex
+ for _ in 0..<32 {
+ let next = value.index(index, offsetBy: 2)
+ guard let byte = UInt8(value[index..<next], radix: 16) else {
+ return nil
+ }
+ output.append(byte)
+ index = next
+ }
+ return output
+ }
+
+ private static func decodeNsec(_ value: String) -> Data? {
+ guard value == value.lowercased(),
+ value.count <= 90,
+ let separator = value.lastIndex(of: "1"),
+ value[..<separator] == "nsec"
+ else {
+ return nil
+ }
+ let payloadStart = value.index(after: separator)
+ let encoded = value[payloadStart...]
+ guard encoded.count >= 6 else {
+ return nil
+ }
+ let alphabet = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l")
+ let reverse = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt8($0)) })
+ var values = [UInt8]()
+ values.reserveCapacity(encoded.count)
+ for character in encoded {
+ guard let value = reverse[character] else {
+ return nil
+ }
+ values.append(value)
+ }
+ guard bech32Polymod(hrp: "nsec", values: values) == 1 else {
+ return nil
+ }
+ return convertBits(Array(values.dropLast(6)), from: 5, to: 8, pad: false)
+ }
+
+ private static func bech32Polymod(hrp: String, values: [UInt8]) -> UInt32 {
+ let generators: [UInt32] = [0x3b6a_57b2, 0x2650_8e6d, 0x1ea1_19fa, 0x3d42_33dd, 0x2a14_62b3]
+ let expanded = hrp.utf8.map { $0 >> 5 } + [0] + hrp.utf8.map { $0 & 31 } + values
+ var checksum: UInt32 = 1
+ for value in expanded {
+ let top = checksum >> 25
+ checksum = (checksum & 0x01ff_ffff) << 5 ^ UInt32(value)
+ for (index, generator) in generators.enumerated() where ((top >> index) & 1) == 1 {
+ checksum ^= generator
+ }
+ }
+ return checksum
+ }
+
+ private static func convertBits(
+ _ values: [UInt8],
+ from sourceBits: Int,
+ to targetBits: Int,
+ pad: Bool
+ ) -> Data? {
+ var accumulator = 0
+ var bitCount = 0
+ let maxValue = (1 << targetBits) - 1
+ var output = Data()
+ for value in values {
+ guard Int(value) >> sourceBits == 0 else {
+ return nil
+ }
+ accumulator = (accumulator << sourceBits) | Int(value)
+ bitCount += sourceBits
+ while bitCount >= targetBits {
+ bitCount -= targetBits
+ output.append(UInt8((accumulator >> bitCount) & maxValue))
+ }
+ }
+ if pad, bitCount > 0 {
+ output.append(UInt8((accumulator << (targetBits - bitCount)) & maxValue))
+ } else if bitCount >= sourceBits || ((accumulator << (targetBits - bitCount)) & maxValue) != 0 {
+ return nil
+ }
+ return output.count == 32 ? output : nil
+ }
+}
+
+public struct RadrootsIdentityPassphrase: Sendable, CustomDebugStringConvertible {
+ private let bytes: Data
+
+ public init(_ value: String) throws {
+ let bytes = Data(value.utf8)
+ guard (12...1_024).contains(bytes.count),
+ !value.unicodeScalars.contains(where: { $0.value == 0 })
+ else {
+ throw RadrootsIdentityCustodyError.invalidPassphrase
+ }
+ self.bytes = bytes
+ }
+
+ public var debugDescription: String {
+ "RadrootsIdentityPassphrase(<redacted>)"
+ }
+
+ func copyBytes() -> Data {
+ bytes
+ }
+}
+
+public struct RadrootsIdentityPublicRecord: Codable, Equatable, Hashable, Sendable {
+ public let identityHandle: String
+ public let publicKeyHex: String
+ public let label: String?
+ public let createdAtUnixMilliseconds: UInt64
+ public let updatedAtUnixMilliseconds: UInt64
+
+ public init(
+ identityHandle: String,
+ publicKeyHex: String,
+ label: String?,
+ createdAtUnixMilliseconds: UInt64,
+ updatedAtUnixMilliseconds: UInt64
+ ) throws {
+ guard Self.validHandle(identityHandle),
+ Self.validHex(publicKeyHex, byteCount: 32),
+ createdAtUnixMilliseconds > 0,
+ updatedAtUnixMilliseconds >= createdAtUnixMilliseconds
+ else {
+ throw RadrootsIdentityCustodyError.invalidMetadata
+ }
+ self.identityHandle = identityHandle
+ self.publicKeyHex = publicKeyHex
+ self.label = try Self.normalizedLabel(label)
+ self.createdAtUnixMilliseconds = createdAtUnixMilliseconds
+ self.updatedAtUnixMilliseconds = updatedAtUnixMilliseconds
+ }
+
+ static func normalizedLabel(_ value: String?) throws -> String? {
+ guard let value else {
+ return nil
+ }
+ let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalized.isEmpty,
+ normalized.utf8.count <= 128,
+ !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains)
+ else {
+ throw RadrootsIdentityCustodyError.invalidMetadata
+ }
+ return normalized
+ }
+
+ static func validHandle(_ value: String) -> Bool {
+ value.hasPrefix("rrid1_") && value.count == 70
+ && validHex(String(value.dropFirst(6)), byteCount: 32)
+ }
+
+ static func validHex(_ value: String, byteCount: Int) -> Bool {
+ value.count == byteCount * 2
+ && value.unicodeScalars.allSatisfy {
+ (48...57).contains($0.value) || (97...102).contains($0.value)
+ }
+ }
+}
+
+public enum RadrootsIdentityState: String, Codable, Sendable {
+ case absent
+ case locked
+ case unlocked
+ case protectedDataUnavailable
+ case recoveryRequired
+ case corrupt
+}
+
+public struct RadrootsIdentitySnapshot: Equatable, Sendable {
+ public let state: RadrootsIdentityState
+ public let identity: RadrootsIdentityPublicRecord?
+ public let signerHandle: String?
+ public let recoveryCode: String?
+
+ public init(
+ state: RadrootsIdentityState,
+ identity: RadrootsIdentityPublicRecord?,
+ signerHandle: String?,
+ recoveryCode: String?
+ ) {
+ self.state = state
+ self.identity = identity
+ self.signerHandle = signerHandle
+ self.recoveryCode = recoveryCode
+ }
+}
+
+public enum RadrootsOpaqueSignPurpose: String, Codable, Sendable {
+ case nostrEvent = "nostr_event"
+ case blossomUpload = "blossom_upload"
+}
+
+public struct RadrootsOpaqueSignRequest: Sendable, CustomDebugStringConvertible {
+ public let operationID: String
+ public let signerHandle: String
+ public let publicKeyHex: String
+ public let digest: Data
+ public let purpose: RadrootsOpaqueSignPurpose
+ public let deadlineUnixMilliseconds: UInt64
+
+ public init(
+ operationID: String,
+ signerHandle: String,
+ publicKeyHex: String,
+ digest: Data,
+ purpose: RadrootsOpaqueSignPurpose,
+ deadlineUnixMilliseconds: UInt64
+ ) throws {
+ guard Self.canonicalUUID(operationID),
+ Self.canonicalUUID(signerHandle),
+ RadrootsIdentityPublicRecord.validHex(publicKeyHex, byteCount: 32),
+ digest.count == 32,
+ deadlineUnixMilliseconds > 0
+ else {
+ throw RadrootsIdentityCustodyError.invalidSignRequest
+ }
+ self.operationID = operationID
+ self.signerHandle = signerHandle
+ self.publicKeyHex = publicKeyHex
+ self.digest = digest
+ self.purpose = purpose
+ self.deadlineUnixMilliseconds = deadlineUnixMilliseconds
+ }
+
+ public var debugDescription: String {
+ "RadrootsOpaqueSignRequest(operationID: \(operationID), purpose: \(purpose.rawValue), payload: <redacted>)"
+ }
+
+ static func canonicalUUID(_ value: String) -> Bool {
+ UUID(uuidString: value)?.uuidString.lowercased() == value
+ }
+}
+
+public struct RadrootsOpaqueSignature: Equatable, Sendable, CustomDebugStringConvertible {
+ public let operationID: String
+ public let publicKeyHex: String
+ public let signature: Data
+ public let purpose: RadrootsOpaqueSignPurpose
+
+ public init(
+ operationID: String,
+ publicKeyHex: String,
+ signature: Data,
+ purpose: RadrootsOpaqueSignPurpose
+ ) throws {
+ guard RadrootsOpaqueSignRequest.canonicalUUID(operationID),
+ RadrootsIdentityPublicRecord.validHex(publicKeyHex, byteCount: 32),
+ signature.count == 64
+ else {
+ throw RadrootsIdentityCustodyError.invalidSignature
+ }
+ self.operationID = operationID
+ self.publicKeyHex = publicKeyHex
+ self.signature = signature
+ self.purpose = purpose
+ }
+
+ public var debugDescription: String {
+ "RadrootsOpaqueSignature(operationID: \(operationID), purpose: \(purpose.rawValue), signature: <redacted>)"
+ }
+}
+
+public enum RadrootsIdentityCustodyError: String, Error, Sendable {
+ case invalidConfiguration = "identity.invalid_configuration"
+ case invalidSecret = "identity.invalid_secret"
+ case invalidPassphrase = "identity.invalid_passphrase"
+ case invalidMetadata = "identity.invalid_metadata"
+ case corruptMetadata = "identity.corrupt_metadata"
+ case inconsistentState = "identity.inconsistent_state"
+ case identityAlreadyExists = "identity.already_exists"
+ case identityNotFound = "identity.not_found"
+ case identityLocked = "identity.locked"
+ case protectedDataUnavailable = "identity.protected_data_unavailable"
+ case userPresenceRequired = "identity.user_presence_required"
+ case invalidSignRequest = "identity.invalid_sign_request"
+ case staleSigner = "identity.stale_signer"
+ case duplicateOperation = "identity.duplicate_operation"
+ case signingSaturated = "identity.signing_saturated"
+ case cancelled = "identity.cancelled"
+ case timedOut = "identity.timed_out"
+ case invalidSignature = "identity.invalid_signature"
+ case recoveryRequired = "identity.recovery_required"
+ case unsupportedPortabilityEnvelope = "identity.unsupported_portability_envelope"
+ case portabilityAuthenticationFailed = "identity.portability_authentication_failed"
+ case storageUnavailable = "identity.storage_unavailable"
+ case cryptographyFailed = "identity.cryptography_failed"
+
+ public var code: String {
+ rawValue
+ }
+}
+
+extension RadrootsIdentityCustodyError: LocalizedError {
+ public var errorDescription: String? {
+ switch self {
+ case .invalidConfiguration: "Identity storage configuration is invalid."
+ case .invalidSecret: "The identity secret is invalid."
+ case .invalidPassphrase: "The portability passphrase is invalid."
+ case .invalidMetadata: "Identity metadata is invalid."
+ case .corruptMetadata: "Identity metadata is corrupt and requires recovery."
+ case .inconsistentState: "Identity storage is inconsistent and requires recovery."
+ case .identityAlreadyExists: "A local identity already exists."
+ case .identityNotFound: "No local identity is available."
+ case .identityLocked: "The local identity is locked."
+ case .protectedDataUnavailable: "Protected identity data is unavailable."
+ case .userPresenceRequired: "User presence was not verified."
+ case .invalidSignRequest: "The signing request is invalid."
+ case .staleSigner: "The signer handle is no longer active."
+ case .duplicateOperation: "The signing operation is already active."
+ case .signingSaturated: "The signer is temporarily at capacity."
+ case .cancelled: "The signing operation was cancelled."
+ case .timedOut: "The signing operation timed out."
+ case .invalidSignature: "The signing result failed verification."
+ case .recoveryRequired: "Identity recovery must complete before continuing."
+ case .unsupportedPortabilityEnvelope: "The encrypted identity envelope is unsupported."
+ case .portabilityAuthenticationFailed:
+ "The encrypted identity envelope could not be authenticated."
+ case .storageUnavailable: "Identity storage is unavailable."
+ case .cryptographyFailed: "The identity cryptography operation failed."
+ }
+ }
+}
diff --git a/Sources/RadrootsKitTesting/RadrootsIdentityMetadataTesting.swift b/Sources/RadrootsKitTesting/RadrootsIdentityMetadataTesting.swift
@@ -0,0 +1,72 @@
+import Foundation
+import RadrootsKit
+
+public final class RadrootsInMemoryIdentityMetadataStore: RadrootsIdentityMetadataStore,
+ @unchecked Sendable
+{
+ public enum Operation: Equatable, Sendable {
+ case read
+ case write
+ case delete
+ }
+
+ public enum Failure: Error, Sendable {
+ case forced
+ }
+
+ private let lock = NSLock()
+ private var values: [RadrootsIdentityMetadataSlot: Data] = [:]
+ private var nextFailure: (Operation, RadrootsIdentityMetadataSlot)?
+
+ public init() {}
+
+ public func data(for slot: RadrootsIdentityMetadataSlot) throws -> Data? {
+ lock.lock()
+ defer { lock.unlock() }
+ try failIfRequested(.read, slot: slot)
+ return values[slot]
+ }
+
+ public func put(_ data: Data, for slot: RadrootsIdentityMetadataSlot) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ try failIfRequested(.write, slot: slot)
+ values[slot] = data
+ }
+
+ public func delete(_ slot: RadrootsIdentityMetadataSlot) throws {
+ lock.lock()
+ defer { lock.unlock() }
+ try failIfRequested(.delete, slot: slot)
+ values.removeValue(forKey: slot)
+ }
+
+ public func failNext(_ operation: Operation, slot: RadrootsIdentityMetadataSlot) {
+ lock.lock()
+ nextFailure = (operation, slot)
+ lock.unlock()
+ }
+
+ public func rawData(for slot: RadrootsIdentityMetadataSlot) -> Data? {
+ lock.lock()
+ defer { lock.unlock() }
+ return values[slot]
+ }
+
+ public func replaceRawData(_ data: Data?, for slot: RadrootsIdentityMetadataSlot) {
+ lock.lock()
+ values[slot] = data
+ lock.unlock()
+ }
+
+ private func failIfRequested(_ operation: Operation, slot: RadrootsIdentityMetadataSlot) throws {
+ guard let failure = nextFailure,
+ failure.0 == operation,
+ failure.1 == slot
+ else {
+ return
+ }
+ nextFailure = nil
+ throw Failure.forced
+ }
+}
diff --git a/Tests/RadrootsKitTests/RadrootsIdentityCustodyTests.swift b/Tests/RadrootsKitTests/RadrootsIdentityCustodyTests.swift
@@ -0,0 +1,457 @@
+import Foundation
+import RadrootsKitTesting
+import Testing
+
+@testable import RadrootsKit
+
+private let identityTestNow: UInt64 = 1_800_000_000_000
+private let aliceSecretHex = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"
+private let alicePublicKeyHex = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"
+private let aliceNsec = "nsec1zrznqntvnt36rgt00ps0rny0tca8vgj6ye3m82vf5rthtyvm0h6syu7drz"
+private let bobSecretHex = "59392e9068f66431b12f70218fb61281cb6b433d7f27c5abee1f1a3fe1a96ff8"
+
+@Test func identitySecretMaterialParsesHexAndNsecWithoutDebugDisclosure() throws {
+ let hex = try RadrootsIdentitySecretMaterial(importText: aliceSecretHex.uppercased())
+ let nsec = try RadrootsIdentitySecretMaterial(importText: aliceNsec)
+
+ #expect(hex.copyBytes() == nsec.copyBytes())
+ #expect(hex.copyBytes().count == 32)
+ #expect(!String(reflecting: hex).contains(aliceSecretHex))
+ #expect(throws: RadrootsIdentityCustodyError.invalidSecret) {
+ _ = try RadrootsIdentitySecretMaterial(importText: "nsec1invalid")
+ }
+}
+
+@Test func identityLifecycleIsOneActiveOpaqueAndSignatureBound() async throws {
+ let fixture = try makeIdentityFixture()
+ #expect(await fixture.custody.snapshot().state == .absent)
+
+ let imported = try await fixture.custody.importIdentity(
+ RadrootsIdentitySecretMaterial(importText: aliceSecretHex),
+ label: " Alice "
+ )
+ #expect(imported.state == .unlocked)
+ #expect(imported.identity?.publicKeyHex == alicePublicKeyHex)
+ #expect(imported.identity?.label == "Alice")
+ let firstHandle = try #require(imported.signerHandle)
+
+ await #expect(throws: RadrootsIdentityCustodyError.identityAlreadyExists) {
+ try await fixture.custody.importIdentity(
+ RadrootsIdentitySecretMaterial(importText: bobSecretHex)
+ )
+ }
+
+ let digest = Data(repeating: 0x42, count: 32)
+ let request = try RadrootsOpaqueSignRequest(
+ operationID: UUID().uuidString.lowercased(),
+ signerHandle: firstHandle,
+ publicKeyHex: alicePublicKeyHex,
+ digest: digest,
+ purpose: .nostrEvent,
+ deadlineUnixMilliseconds: identityTestNow + 1_000
+ )
+ let signature = try await fixture.custody.sign(request)
+ #expect(signature.operationID == request.operationID)
+ #expect(signature.signature.count == 64)
+ #expect(
+ RadrootsIdentityCryptography().verify(
+ signature: signature.signature,
+ digest: digest,
+ publicKeyHex: alicePublicKeyHex
+ )
+ )
+ #expect(!String(reflecting: request).contains(digest.base64EncodedString()))
+ #expect(!String(reflecting: signature).contains(signature.signature.base64EncodedString()))
+
+ await fixture.custody.lockIdentity()
+ #expect(await fixture.custody.snapshot().state == .locked)
+ let unlocked = try await fixture.custody.selectIdentity(
+ identityHandle: try #require(imported.identity?.identityHandle)
+ )
+ #expect(unlocked.state == .unlocked)
+ #expect(unlocked.signerHandle != firstHandle)
+ await #expect(throws: RadrootsIdentityCustodyError.identityNotFound) {
+ try await fixture.custody.selectIdentity(
+ identityHandle: "rrid1_\(String(repeating: "0", count: 64))")
+ }
+ await #expect(throws: RadrootsIdentityCustodyError.staleSigner) {
+ try await fixture.custody.sign(request)
+ }
+}
+
+@Test func identitySigningRejectsTimeoutCancellationAndWrongBinding() async throws {
+ let fixture = try makeIdentityFixture()
+ let imported = try await fixture.custody.importIdentity(
+ RadrootsIdentitySecretMaterial(importText: aliceSecretHex)
+ )
+ let handle = try #require(imported.signerHandle)
+ let operationID = UUID().uuidString.lowercased()
+ let expired = try RadrootsOpaqueSignRequest(
+ operationID: operationID,
+ signerHandle: handle,
+ publicKeyHex: alicePublicKeyHex,
+ digest: Data(repeating: 1, count: 32),
+ purpose: .blossomUpload,
+ deadlineUnixMilliseconds: identityTestNow - 1
+ )
+ await #expect(throws: RadrootsIdentityCustodyError.timedOut) {
+ try await fixture.custody.sign(expired)
+ }
+
+ try await fixture.custody.cancelSigning(operationID: operationID)
+ let cancelled = try RadrootsOpaqueSignRequest(
+ operationID: operationID,
+ signerHandle: handle,
+ publicKeyHex: alicePublicKeyHex,
+ digest: Data(repeating: 1, count: 32),
+ purpose: .blossomUpload,
+ deadlineUnixMilliseconds: identityTestNow + 1
+ )
+ await #expect(throws: RadrootsIdentityCustodyError.cancelled) {
+ try await fixture.custody.sign(cancelled)
+ }
+
+ let wrongKey = String(repeating: "0", count: 64)
+ let wrongBinding = try RadrootsOpaqueSignRequest(
+ operationID: UUID().uuidString.lowercased(),
+ signerHandle: handle,
+ publicKeyHex: wrongKey,
+ digest: Data(repeating: 2, count: 32),
+ purpose: .nostrEvent,
+ deadlineUnixMilliseconds: identityTestNow + 1
+ )
+ await #expect(throws: RadrootsIdentityCustodyError.staleSigner) {
+ try await fixture.custody.sign(wrongBinding)
+ }
+}
+
+@Test func identityReplacementAndDeleteRecoverAfterMetadataFailures() async throws {
+ let fixture = try makeIdentityFixture()
+ fixture.metadata.failNext(.write, slot: .activeIdentity)
+ await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) {
+ try await fixture.custody.importIdentity(
+ RadrootsIdentitySecretMaterial(importText: aliceSecretHex)
+ )
+ }
+ #expect(await fixture.custody.snapshot().state == .recoveryRequired)
+ let recovered = try await fixture.custody.recover()
+ #expect(recovered.state == .locked)
+ #expect(recovered.identity?.publicKeyHex == alicePublicKeyHex)
+
+ fixture.metadata.failNext(.delete, slot: .activeIdentity)
+ await #expect(throws: RadrootsIdentityCustodyError.recoveryRequired) {
+ try await fixture.custody.deleteIdentity()
+ }
+ #expect(await fixture.custody.snapshot().state == .recoveryRequired)
+ #expect(try await fixture.custody.recover().state == .absent)
+ #expect(fixture.secureStore.keys().isEmpty)
+}
+
+@Test func corruptMetadataIsDistinctFromAbsenceAndCanBeRepaired() async throws {
+ let fixture = try makeIdentityFixture()
+ _ = try await fixture.custody.importIdentity(
+ RadrootsIdentitySecretMaterial(importText: aliceSecretHex),
+ label: "Before"
+ )
+ await fixture.custody.lockIdentity()
+ fixture.metadata.replaceRawData(Data("not-json".utf8), for: .activeIdentity)
+
+ let corrupt = await fixture.custody.snapshot()
+ #expect(corrupt.state == .corrupt)
+ #expect(corrupt.recoveryCode == "identity.corrupt_metadata")
+ #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == Data("not-json".utf8))
+
+ let repaired = try await fixture.custody.repairCorruptMetadata(label: "Recovered")
+ #expect(repaired.state == .unlocked)
+ #expect(repaired.identity?.publicKeyHex == alicePublicKeyHex)
+ #expect(repaired.identity?.label == "Recovered")
+ #expect(fixture.metadata.rawData(for: .quarantinedMetadata) == nil)
+}
+
+@Test func corruptTransactionJournalRequiresRecoveryWithoutClaimingAbsence() async throws {
+ let fixture = try makeIdentityFixture()
+ _ = try await fixture.custody.importIdentity(
+ RadrootsIdentitySecretMaterial(importText: aliceSecretHex)
+ )
+ await fixture.custody.lockIdentity()
+ fixture.metadata.replaceRawData(Data("not-a-journal".utf8), for: .transactionJournal)
+
+ let snapshot = await fixture.custody.snapshot()
+ #expect(snapshot.state == .corrupt)
+ #expect(snapshot.identity == nil)
+ #expect(snapshot.recoveryCode == "identity.corrupt_metadata")
+ await #expect(throws: RadrootsIdentityCustodyError.corruptMetadata) {
+ try await fixture.custody.recover()
+ }
+}
+
+@Test func identityCustodyRoundTripsAgainstAppleStorageAdapters() async throws {
+ let suffix = UUID().uuidString.lowercased()
+ let namespace = "native-storage-\(suffix)"
+ let servicePrefix = "org.radroots.tests.identity.\(suffix)"
+ let suiteName = "org.radroots.tests.identity.metadata.\(suffix)"
+ let keychain = RadrootsAppleKeychainSecureStore(servicePrefix: servicePrefix)
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defer {
+ try? keychain.deleteNamespace(namespace)
+ defaults.removePersistentDomain(forName: suiteName)
+ }
+ let custody = try RadrootsIdentityCustody(
+ configuration: RadrootsIdentityCustodyConfiguration(
+ namespace: namespace,
+ secretPolicy: .secureLocalSecret
+ ),
+ secureStore: keychain,
+ metadataStore: RadrootsAppleIdentityMetadataStore(
+ namespace: namespace,
+ userDefaults: defaults
+ ),
+ userPresence: RadrootsFakeUserPresence(),
+ now: { identityTestNow }
+ )
+
+ let imported = try await custody.importIdentity(
+ RadrootsIdentitySecretMaterial(importText: aliceSecretHex),
+ label: "Native"
+ )
+ #expect(imported.state == .unlocked)
+ #expect(imported.identity?.publicKeyHex == alicePublicKeyHex)
+
+ await custody.lockIdentity()
+ #expect(await custody.snapshot().state == .locked)
+ #expect(try await custody.unlockIdentity().state == .unlocked)
+ #expect(try await custody.deleteIdentity().state == .absent)
+ #expect(
+ try keychain.contains(
+ RadrootsSecureStoreKey(namespace: namespace, name: "active_secret_v1")
+ ) == false)
+}
+
+@Test func protectedDataAndUserPresenceFailuresDoNotClaimIdentitySuccess() async throws {
+ let secureStore = RadrootsInMemorySecureStore()
+ let metadata = RadrootsInMemoryIdentityMetadataStore()
+ let presence = RadrootsFakeUserPresence(verificationOutcome: .success(false))
+ let custody = try RadrootsIdentityCustody(
+ configuration: RadrootsIdentityCustodyConfiguration(namespace: "protected-test"),
+ secureStore: secureStore,
+ metadataStore: metadata,
+ userPresence: presence,
+ protectedData: RadrootsProtectedDataProvider { .available },
+ now: { identityTestNow }
+ )
+ await #expect(throws: RadrootsIdentityCustodyError.userPresenceRequired) {
+ try await custody.importIdentity(RadrootsIdentitySecretMaterial(importText: aliceSecretHex))
+ }
+ #expect(await custody.snapshot().state == .absent)
+
+ let unavailable = try RadrootsIdentityCustody(
+ configuration: RadrootsIdentityCustodyConfiguration(namespace: "locked-test"),
+ secureStore: RadrootsInMemorySecureStore(),
+ metadataStore: RadrootsInMemoryIdentityMetadataStore(),
+ userPresence: RadrootsFakeUserPresence(),
+ protectedData: RadrootsProtectedDataProvider { .locked },
+ now: { identityTestNow }
+ )
+ await #expect(throws: RadrootsIdentityCustodyError.protectedDataUnavailable) {
+ try await unavailable.createIdentity()
+ }
+ #expect(await unavailable.snapshot().state == .absent)
+}
+
+@Test func encryptedPortabilityRoundTripsAcrossIndependentHostsAndRejectsTampering() async throws {
+ let source = try makeIdentityFixture(namespace: "portability-source")
+ _ = try await source.custody.importIdentity(
+ RadrootsIdentitySecretMaterial(importText: aliceNsec),
+ label: "Portable"
+ )
+ let passphrase = try RadrootsIdentityPassphrase("correct horse battery staple")
+ let envelope = try await source.custody.exportPortableIdentity(passphrase: passphrase)
+ let serialized = envelope.serializedRepresentation
+ let rendered = String(decoding: serialized, as: UTF8.self)
+ #expect(envelope.version == 1)
+ #expect(!rendered.contains(aliceSecretHex))
+ #expect(!rendered.contains(aliceNsec))
+ #expect(!String(reflecting: passphrase).contains("correct horse"))
+
+ let destination = try makeIdentityFixture(namespace: "portability-destination")
+ let imported = try await destination.custody.importPortableIdentity(
+ envelope,
+ passphrase: passphrase
+ )
+ #expect(imported.identity?.publicKeyHex == alicePublicKeyHex)
+ #expect(imported.identity?.label == "Portable")
+
+ let wrongDestination = try makeIdentityFixture(namespace: "portability-wrong")
+ await #expect(throws: RadrootsIdentityCustodyError.portabilityAuthenticationFailed) {
+ try await wrongDestination.custody.importPortableIdentity(
+ envelope,
+ passphrase: RadrootsIdentityPassphrase("incorrect but sufficiently long")
+ )
+ }
+
+ let unsupported = serialized.replacingOccurrences(
+ of: Data("\"version\":1".utf8),
+ with: Data("\"version\":2".utf8)
+ )
+ #expect(throws: RadrootsIdentityCustodyError.unsupportedPortabilityEnvelope) {
+ _ = try RadrootsIdentityPortabilityEnvelope(serializedRepresentation: unsupported)
+ }
+}
+
+@Test func legacyIdentityMigrationIsIdempotentAndDeletesOnlyAfterCommit() async throws {
+ let fixture = try makeIdentityFixture(namespace: "legacy-test")
+ let legacyKey = RadrootsSecureStoreKey(namespace: "legacy", name: "selected_secret_hex")
+ try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey)
+
+ let migrated = try await fixture.custody.migrateLegacyIdentity(from: legacyKey, label: "Migrated")
+ #expect(migrated.identity?.publicKeyHex == alicePublicKeyHex)
+ #expect(try fixture.secureStore.get(legacyKey) == nil)
+
+ try fixture.secureStore.put(Data(aliceSecretHex.utf8), for: legacyKey)
+ let replayed = try await fixture.custody.migrateLegacyIdentity(from: legacyKey)
+ #expect(replayed.identity?.publicKeyHex == alicePublicKeyHex)
+ #expect(try fixture.secureStore.get(legacyKey) == nil)
+}
+
+@Test func opaqueSignerBridgeCancelsPendingWorkAndCompletesExactlyOnce() async throws {
+ let secureStore = RadrootsInMemorySecureStore()
+ let metadata = RadrootsInMemoryIdentityMetadataStore()
+ let presence = ControllableIdentityPresence()
+ let custody = try RadrootsIdentityCustody(
+ configuration: RadrootsIdentityCustodyConfiguration(namespace: "bridge-test"),
+ secureStore: secureStore,
+ metadataStore: metadata,
+ userPresence: presence,
+ now: { identityTestNow }
+ )
+ let imported = try await custody.importIdentity(
+ RadrootsIdentitySecretMaterial(importText: aliceSecretHex)
+ )
+ let signerHandle = try #require(imported.signerHandle)
+ await presence.suspendNextVerification()
+ let request = try RadrootsOpaqueSignRequest(
+ operationID: UUID().uuidString.lowercased(),
+ signerHandle: signerHandle,
+ publicKeyHex: alicePublicKeyHex,
+ digest: Data(repeating: 9, count: 32),
+ purpose: .nostrEvent,
+ deadlineUnixMilliseconds: identityTestNow + 1
+ )
+ let result = LockedIdentityResult()
+ let cancellation = RadrootsOpaqueSignerBridge(custody: custody).submit(request) {
+ result.record($0)
+ }
+ while await presence.pendingVerificationCount == 0 {
+ await Task.yield()
+ }
+ cancellation.cancel()
+ await presence.resumePending(verified: true)
+ while result.count == 0 {
+ await Task.yield()
+ }
+ #expect(result.count == 1)
+ guard case .failure(.cancelled) = result.value else {
+ Issue.record("expected one cancelled completion")
+ return
+ }
+}
+
+private struct IdentityFixture {
+ let custody: RadrootsIdentityCustody
+ let secureStore: RadrootsInMemorySecureStore
+ let metadata: RadrootsInMemoryIdentityMetadataStore
+}
+
+private func makeIdentityFixture(namespace: String = UUID().uuidString.lowercased()) throws
+ -> IdentityFixture
+{
+ let secureStore = RadrootsInMemorySecureStore()
+ let metadata = RadrootsInMemoryIdentityMetadataStore()
+ let custody = try RadrootsIdentityCustody(
+ configuration: RadrootsIdentityCustodyConfiguration(namespace: namespace),
+ secureStore: secureStore,
+ metadataStore: metadata,
+ userPresence: RadrootsFakeUserPresence(),
+ now: { identityTestNow }
+ )
+ return IdentityFixture(custody: custody, secureStore: secureStore, metadata: metadata)
+}
+
+private actor ControllableIdentityPresence: RadrootsUserPresence {
+ private var suspendNext = false
+ private var pending: [CheckedContinuation<RadrootsUserPresenceResult, Never>] = []
+
+ func currentStatus() async throws -> RadrootsUserPresenceStatus {
+ RadrootsUserPresenceStatus(
+ support: .biometricsOrDeviceCredential,
+ biometryKind: .faceID,
+ canEvaluateDeviceCredential: true,
+ canEvaluateBiometrics: true
+ )
+ }
+
+ func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult {
+ guard suspendNext else {
+ return RadrootsUserPresenceResult(policy: request.policy, verified: true)
+ }
+ suspendNext = false
+ return await withCheckedContinuation { continuation in
+ pending.append(continuation)
+ }
+ }
+
+ func suspendNextVerification() {
+ suspendNext = true
+ }
+
+ var pendingVerificationCount: Int {
+ pending.count
+ }
+
+ func resumePending(verified: Bool) {
+ let continuations = pending
+ pending.removeAll()
+ for continuation in continuations {
+ continuation.resume(
+ returning: RadrootsUserPresenceResult(
+ policy: .deviceOwnerAuthentication,
+ verified: verified
+ )
+ )
+ }
+ }
+}
+
+private final class LockedIdentityResult: @unchecked Sendable {
+ private let lock = NSLock()
+ private var results: [Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>] = []
+
+ var count: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return results.count
+ }
+
+ var value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>? {
+ lock.lock()
+ defer { lock.unlock() }
+ return results.first
+ }
+
+ func record(_ value: Result<RadrootsOpaqueSignature, RadrootsIdentityCustodyError>) {
+ lock.lock()
+ results.append(value)
+ lock.unlock()
+ }
+}
+
+extension Data {
+ fileprivate func replacingOccurrences(of needle: Data, with replacement: Data) -> Data {
+ guard let range = range(of: needle) else {
+ return self
+ }
+ var copy = self
+ copy.replaceSubrange(range, with: replacement)
+ return copy
+ }
+}