From 87d9a098035359c186eba8076518599e5b708d25 Mon Sep 17 00:00:00 2001 From: Nucleic Date: Sat, 18 Jul 2026 01:14:37 -0700 Subject: [PATCH] Merge nucleic/fuzzy-velvet-quail-rnyt into dev --- .../NucleicRemote/Models/HostConnection.swift | 68 +++- .../Models/PhoneCredentialVault.swift | 373 ++++++++++++++++++ .../NucleicRemote/Models/RemoteStore.swift | 57 ++- .../Views/AgentAccountsView.swift | 77 +++- 4 files changed, 559 insertions(+), 16 deletions(-) create mode 100644 NucleicRemote/NucleicRemote/Models/PhoneCredentialVault.swift diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index a5455c5..d016355 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -146,6 +146,10 @@ final class HostConnection { var agentLoginChallenge: (WireAgentLoginChallenge) -> Void = { _ in } /// The definitive outcome of this phone's sign-in attempt, keyed by requestID. var agentLoginResult: (WireAgentLoginResult) -> Void = { _ in } + /// The phone vault changed under this connection — kinds landed from a + /// `credentialUpdate` or cleared by a mesh-wide deletion. RemoteStore refreshes the + /// held-kinds mirror the Settings surface reads. + var credentialsChanged: () -> Void = {} } private let callbacks: Callbacks @@ -627,6 +631,13 @@ final class HostConnection { if welcome.capabilities.canCast { send(.castSubscribe(CastSubscribe(cursors: callbacks.castCursors()))) } + // Credential mesh, phone as HOLDER: tell a host that ingests sealed credentials + // what this phone can provide — descriptors, deletion tombstones, and the sealing + // key rotations get mirrored back to (never secret bytes). Same post-welcome slot + // as the Mac's PeerClient gossip. + if welcome.capabilities.canReceiveSealedCredentials { + gossipCredentialManifest() + } // Warm-resubscribe from what we already have, so a reconnect on a long transcript replays // only the gap rather than snapping back to the host's 200-event tail. if let id = openSessionID { @@ -842,17 +853,43 @@ final class HostConnection { directBox?.deliver(.decline(reason)) case .credentialNeeded(let need): // The host's sealing key rides this push (kinds may be empty — a cockpit Mac never - // asks, a runner lists what it's missing). The phone is still not a credential - // *provider* — it holds no vault to answer `kinds` from — but the key is what the - // "use an API key" flow seals to (REMOTE_AGENT_LOGIN §8). + // asks, a runner lists what it's missing). The key is what the one-shot API-key + // flow seals to (REMOTE_AGENT_LOGIN §8) — cache it either way. Non-empty kinds + // are a real ask: answer from the phone vault, sealing ONLY requested kinds + // (empty kinds must solicit nothing — that contract is load-bearing for the + // cockpit Mac's key-advertisement push). credentialSealingKey = need.sealingPublicKey.isEmpty ? nil : need.sealingPublicKey callbacks.didUpdate() - case .credentialUpdate, - // The owner's runner-pool credential (item 4) — inert until the phone grows a - // pool-management surface; Macs are the managers today. - .runnerPoolCredential: - // Remaining Covalence runner credential verbs (docs/COVALENCE_RUNNER.md §6): inert - // here until the phone-side vault lands. + if !need.kinds.isEmpty, capabilities.canReceiveSealedCredentials { + Task { [weak self] in + guard let envelope = await PhoneCredentialVault.shared.sealedEnvelope(for: need) + else { return } + self?.send(.credentialProvision(envelope)) + } + } + case .credentialUpdate(let envelope): + // A host rotated a credential and mirrored it here, sealed to this phone's vault + // key — land it newest-wins (the shared comparators), then re-gossip the manifest + // so the host's descriptor view tracks the fresh stamp. + Task { [weak self] in + let landed = await PhoneCredentialVault.shared.land(envelope) + guard !landed.isEmpty, let self else { return } + self.gossipCredentialManifest() + self.callbacks.credentialsChanged() + } + case .credentialRevoked(let tombstones): + // A credential kind was deleted mesh-wide — clear the phone vault's copy and + // record the stones so this phone's own manifest stops offering it. No re-send: + // the host that pushed this owns the fan-out; a replay no-ops in the vault. + Task { [weak self] in + let applied = await PhoneCredentialVault.shared.applyTombstones(tombstones) + guard !applied.isEmpty, let self else { return } + self.gossipCredentialManifest() + self.callbacks.credentialsChanged() + } + case .runnerPoolCredential: + // The owner's runner-pool credential (item 4) — inert until the phone grows a + // pool-management surface; Macs are the managers today. break case .wireError(let error): if error.code == .channelMismatch { @@ -973,6 +1010,19 @@ final class HostConnection { Task { await client.send(msg) } } + /// Push this phone's credential manifest at the host (phone as credential HOLDER): + /// descriptors + tombstones + the vault's sealing key, never secret bytes. Only ever sent + /// to a host that advertised `canReceiveSealedCredentials` (callers gate; an older host + /// throws on the unknown tag). Vault reads run on the vault actor — off the main actor. + func gossipCredentialManifest() { + guard capabilities.canReceiveSealedCredentials else { return } + Task { [weak self] in + let manifest = await PhoneCredentialVault.shared.manifest( + deviceID: IdentityStore.deviceID()) + self?.send(.credentialManifest(manifest)) + } + } + /// Pull the open session's *full* transcript via mesh full-transcript sync and merge it into the /// transcript on screen. The cold `subscribe` only returns the host's 200-event tail, so without /// this the phone shows nothing from before it connected. `afterSeq: 0` asks for the whole history diff --git a/NucleicRemote/NucleicRemote/Models/PhoneCredentialVault.swift b/NucleicRemote/NucleicRemote/Models/PhoneCredentialVault.swift new file mode 100644 index 0000000..3848756 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Models/PhoneCredentialVault.swift @@ -0,0 +1,373 @@ +import CryptoKit +import Foundation +import NucleicProtocol +import Security + +/// The phone-side credential vault (docs/REMOTE_AGENT_LOGIN.md follow-up: phone as credential +/// HOLDER). Holds the mirrorable rotating logins — Claude OAuth and Codex auth — so an +/// iPhone-primary mesh can credential a fresh runner with every Mac asleep: the phone gossips +/// a `CredentialManifest`, answers `credentialNeeded` for kinds it holds, and lands +/// `credentialUpdate` rotations with the exact same newest-wins comparators the Mac uses +/// (`ClaudeCredentialFormat` / `CodexCredentialFormat` in NucleicProtocol — shared, not +/// reimplemented). API keys stay deliberately out: the one-shot `submitAPIKey` push seals them +/// straight to a host and the phone never stores them. +/// +/// At rest: one file, ChaChaPoly-sealed with a device-local 32-byte vault key. Where a Secure +/// Enclave exists, that vault key is wrapped by an SE-resident P-256 key +/// (`kSecAttrTokenIDSecureEnclave`) and only the wrapped blob touches the Keychain; without +/// one (simulator), the raw key lives in the Keychain (this-device-only, after-first-unlock). +/// +/// HONESTY RULE (CLOUD_RUNTIME §5): the credential-sealing keypair is Curve25519, which CANNOT +/// live in the Secure Enclave (it holds P-256 only) — it is an ordinary Keychain item. What +/// the SE protects here is the at-rest wrap of the vault key. Never claim more. +/// +/// Refresh leases: the phone may RECORD leases it learns but never ACQUIRES one — it +/// backgrounds unpredictably, and `CredentialRefreshLease.merged` deliberately prefers stable +/// holders (Macs/runners stay the refreshers). +/// +/// An actor (not `@MainActor`): every Keychain and file touch runs off the main actor — the +/// Settings beach-ball lesson from AppStore applies to the phone too. +actor PhoneCredentialVault { + static let shared = PhoneCredentialVault() + + /// The kinds the phone holds — the two rotating OAuth logins, matching + /// `RunnerCredentialVault.mirrorableKinds`. Static keys are never stored on the phone. + static let mirrorableKinds: [CredentialKind] = [.claudeOAuth, .codexAuth] + + // MARK: - Contents + + private struct StoredRecord: Codable { + var payload: Data + /// The credential's own freshness stamp (Claude `expiresAt`, Codex `last_refresh`) — + /// the same clock every other mesh member merges on. + var updatedAt: Date + } + + private struct VaultContents: Codable { + /// Keyed by `CredentialKind.rawValue`. + var records: [String: StoredRecord] = [:] + /// Recorded (never acquired) refresh leases — see the type doc. Empty today; kept in + /// the file shape so recording them later needs no migration. + var leases: [CredentialRefreshLease] = [] + /// Mesh-wide deletions this phone knows (`deletedAt`-monotonic, one per kind). + var tombstones: [CredentialTombstone] = [] + } + + private var cachedContents: VaultContents? + private var cachedVaultKey: SymmetricKey? + + // MARK: - Sealing keypair (Curve25519 — Keychain, NOT the Secure Enclave) + + private static let sealingKeyAccount = "xyz.blakeslee.nucleic.remote.credential-sealing" + + /// The public half other mesh members seal credentials to (rides in this phone's + /// manifest). Empty only if the Keychain refuses us entirely. + func sealingPublicKey() -> Data { + guard let key = sealingPrivateKey() else { return Data() } + return key.publicKey.rawRepresentation + } + + private func sealingPrivateKey() -> Curve25519.KeyAgreement.PrivateKey? { + if let data = Self.keychainRead(account: Self.sealingKeyAccount), + let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data) { + return key + } + let fresh = Curve25519.KeyAgreement.PrivateKey() + guard Self.keychainWrite(fresh.rawRepresentation, account: Self.sealingKeyAccount) + else { return nil } + return fresh + } + + // MARK: - Holder surface (manifest / provision / land / revoke) + + /// What this phone gossips after `welcome` on a host that ingests sealed credentials: + /// descriptors for the kinds it holds (never the bytes), the tombstones it knows, and its + /// sealing key so rotations can be mirrored here. Leases ride through unchanged — recorded + /// only, never acquired (see the type doc). + func manifest(deviceID: String) -> CredentialManifest { + let contents = loadContents() + var records: [CredentialRecordDescriptor] = [] + for (raw, record) in contents.records { + let kind = CredentialKind(rawValue: raw) + guard !suppressed(kind, updatedAt: record.updatedAt, in: contents) else { continue } + records.append(CredentialRecordDescriptor( + kind: kind, updatedAt: record.updatedAt, provenanceDeviceID: deviceID)) + } + let key = sealingPublicKey() + return CredentialManifest( + records: records.sorted { $0.kind.rawValue < $1.kind.rawValue }, + leases: contents.leases, + sealingPublicKey: key.isEmpty ? nil : key, + tombstones: contents.tombstones) + } + + /// Seal every *requested* kind this phone holds to the asker's key — the answer to + /// `HostMsg.credentialNeeded`. Empty `kinds` solicits nothing (that contract is + /// load-bearing: a cockpit Mac pushes `kinds: []` purely to advertise its sealing key for + /// the one-shot API-key path, and no holder may volunteer anything for it). + func sealedEnvelope(for need: WireCredentialNeed) -> SealedCredentialEnvelope? { + guard !need.kinds.isEmpty else { return nil } + let contents = loadContents() + var records: [SealedCredentialRecord] = [] + for kind in need.kinds { + guard let stored = contents.records[kind.rawValue], + !suppressed(kind, updatedAt: stored.updatedAt, in: contents) else { continue } + let stub = SealedCredentialRecord( + kind: kind, updatedAt: stored.updatedAt, + box: SealedCredentialBox(ephemeralPublicKey: Data(), ciphertext: Data())) + guard let box = try? SealedCredentialBox.seal( + stored.payload, to: need.sealingPublicKey, additionalData: stub.additionalData) + else { continue } + records.append(SealedCredentialRecord(kind: kind, updatedAt: stored.updatedAt, box: box)) + } + return records.isEmpty ? nil : SealedCredentialEnvelope(records: records) + } + + /// Land a `credentialUpdate` (a host mirrored a rotation, sealed to this phone's key) — + /// newest-wins by the credential's own clock via the SAME comparators the Mac hubs use. + /// Returns the kinds actually written. + func land(_ envelope: SealedCredentialEnvelope) -> [CredentialKind] { + guard let key = sealingPrivateKey() else { return [] } + var contents = loadContents() + var landed: [CredentialKind] = [] + for record in envelope.records where Self.mirrorableKinds.contains(record.kind) { + guard let plaintext = try? record.box.open( + with: key, additionalData: record.additionalData), + let json = String(data: plaintext, encoding: .utf8) + else { continue } + let current = contents.records[record.kind.rawValue] + .flatMap { String(data: $0.payload, encoding: .utf8) } + let stamp: Date + switch record.kind { + case .claudeOAuth: + guard ClaudeCredentialFormat.shouldReplace(candidate: json, current: current) + else { continue } + stamp = ClaudeCredentialFormat.expiresAt(json) + .map { Date(timeIntervalSince1970: $0 / 1000) } ?? record.updatedAt + case .codexAuth: + guard CodexCredentialFormat.shouldReplace(candidate: json, current: current) + else { continue } + stamp = CodexCredentialFormat.lastRefresh(json) + .map(Date.init(timeIntervalSince1970:)) ?? record.updatedAt + default: + continue + } + // A revision at or before a recorded deletion stays dead — only a strictly newer + // login resurrects the kind. Judged on the credential's OWN clock (`stamp`), never + // the wire stamp: a mirror-back is stamped "now", which a Claude tombstone (bumped + // past the deleted token's future expiry) would wrongly suppress. + guard !suppressed(record.kind, updatedAt: stamp, in: contents) else { continue } + contents.records[record.kind.rawValue] = StoredRecord(payload: plaintext, updatedAt: stamp) + landed.append(record.kind) + } + if !landed.isEmpty { saveContents(contents) } + return landed + } + + /// Land mesh-wide deletions (`HostMsg.credentialRevoked` or the post-hello table push): + /// merge each stone `deletedAt`-monotonic, drop the matching record, and absorb its + /// freshness stamp so no stale holder can resurrect it. Returns the stones that carried + /// new information (a replay returns empty — that's what terminates gossip loops). + @discardableResult + func applyTombstones(_ incoming: [CredentialTombstone]) -> [CredentialTombstone] { + var contents = loadContents() + var table = Dictionary(uniqueKeysWithValues: contents.tombstones.map { ($0.kind, $0) }) + var applied: [CredentialTombstone] = [] + for stone in incoming { + let winner = CredentialTombstone.merged(table[stone.kind], stone) + guard let winner, winner != table[stone.kind] else { continue } + var effective = winner + if let record = contents.records[stone.kind.rawValue] { + effective = winner.absorbing(freshness: record.updatedAt) + contents.records[stone.kind.rawValue] = nil + } + table[stone.kind] = effective + applied.append(effective) + } + guard !applied.isEmpty else { return [] } + contents.tombstones = table.values.sorted { $0.kind.rawValue < $1.kind.rawValue } + saveContents(contents) + return applied + } + + /// The user deleted a credential from this phone (Agent Accounts): drop the local copy (if + /// any), mint the tombstone — absorbing the copy's freshness stamp — and return it for the + /// caller to send (`ClientMsg.credentialRevoke`) at every host that accepts the verb. + func deleteCredential(_ kind: CredentialKind, deviceID: String) -> CredentialTombstone { + var contents = loadContents() + var stone = CredentialTombstone(kind: kind, deletedAt: Date(), originDeviceID: deviceID) + if let record = contents.records[kind.rawValue] { + stone = stone.absorbing(freshness: record.updatedAt) + contents.records[kind.rawValue] = nil + } + var table = Dictionary(uniqueKeysWithValues: contents.tombstones.map { ($0.kind, $0) }) + table[kind] = CredentialTombstone.merged(table[kind], stone) ?? stone + contents.tombstones = table.values.sorted { $0.kind.rawValue < $1.kind.rawValue } + saveContents(contents) + return table[kind] ?? stone + } + + /// The kinds this phone currently holds (for the Settings surface). + func heldKinds() -> [CredentialKind] { + loadContents().records.keys.map(CredentialKind.init(rawValue:)) + .sorted { $0.rawValue < $1.rawValue } + } + + private func suppressed( + _ kind: CredentialKind, updatedAt: Date, in contents: VaultContents + ) -> Bool { + contents.tombstones.first { $0.kind == kind }? + .suppresses(recordUpdatedAt: updatedAt) ?? false + } + + // MARK: - At-rest encryption + + private static var vaultFileURL: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Nucleic", isDirectory: true) + .appendingPathComponent("credential-vault.sealed") + } + + private func loadContents() -> VaultContents { + if let cachedContents { return cachedContents } + guard let key = vaultKey(), + let sealed = try? Data(contentsOf: Self.vaultFileURL), + let box = try? ChaChaPoly.SealedBox(combined: sealed), + let plaintext = try? ChaChaPoly.open(box, using: key), + let contents = try? JSONDecoder().decode(VaultContents.self, from: plaintext) + else { + let empty = VaultContents() + cachedContents = empty + return empty + } + cachedContents = contents + return contents + } + + private func saveContents(_ contents: VaultContents) { + cachedContents = contents + guard let key = vaultKey(), + let plaintext = try? JSONEncoder().encode(contents), + let sealed = try? ChaChaPoly.seal(plaintext, using: key) + else { return } + let url = Self.vaultFileURL + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try? sealed.combined.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } + + // MARK: - Vault key (SE-wrapped where available) + + private static let wrappedKeyAccount = "xyz.blakeslee.nucleic.remote.vault-key.wrapped" + private static let rawKeyAccount = "xyz.blakeslee.nucleic.remote.vault-key" + private static let seKeyTag = Data("xyz.blakeslee.nucleic.remote.vault-wrap".utf8) + + private func vaultKey() -> SymmetricKey? { + if let cachedVaultKey { return cachedVaultKey } + let key = loadOrCreateVaultKey() + cachedVaultKey = key + return key + } + + private func loadOrCreateVaultKey() -> SymmetricKey? { + // Secure Enclave path: the vault key only ever exists in the clear in process memory; + // the Keychain holds the SE-wrapped blob, and the wrap key never leaves the enclave. + if SecureEnclave.isAvailable { + if let wrapped = Self.keychainRead(account: Self.wrappedKeyAccount), + let seKey = Self.loadSEKey(), + let raw = Self.seDecrypt(wrapped, with: seKey) { + return SymmetricKey(data: raw) + } + let fresh = SymmetricKey(size: .bits256) + let rawFresh = fresh.withUnsafeBytes { Data($0) } + if let seKey = Self.loadOrCreateSEKey(), + let wrapped = Self.seEncrypt(rawFresh, with: seKey), + Self.keychainWrite(wrapped, account: Self.wrappedKeyAccount) { + return fresh + } + // SE claimed available but refused (rare) — fall through to the plain-Keychain key. + } + if let raw = Self.keychainRead(account: Self.rawKeyAccount) { + return SymmetricKey(data: raw) + } + let fresh = SymmetricKey(size: .bits256) + let raw = fresh.withUnsafeBytes { Data($0) } + guard Self.keychainWrite(raw, account: Self.rawKeyAccount) else { return nil } + return fresh + } + + // MARK: SE wrap primitives (SecKey — P-256 in the enclave, ECIES for the wrap) + + private static func loadSEKey() -> SecKey? { + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: seKeyTag, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecReturnRef as String: true, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil } + return (item as! SecKey) + } + + private static func loadOrCreateSEKey() -> SecKey? { + if let existing = loadSEKey() { return existing } + guard let access = SecAccessControlCreateWithFlags( + nil, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, .privateKeyUsage, nil) + else { return nil } + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, + kSecAttrApplicationTag as String: seKeyTag, + kSecAttrAccessControl as String: access, + ], + ] + return SecKeyCreateRandomKey(attributes as CFDictionary, nil) + } + + private static let seAlgorithm = SecKeyAlgorithm.eciesEncryptionCofactorVariableIVX963SHA256AESGCM + + private static func seEncrypt(_ plaintext: Data, with privateKey: SecKey) -> Data? { + guard let publicKey = SecKeyCopyPublicKey(privateKey), + SecKeyIsAlgorithmSupported(publicKey, .encrypt, seAlgorithm) + else { return nil } + return SecKeyCreateEncryptedData(publicKey, seAlgorithm, plaintext as CFData, nil) as Data? + } + + private static func seDecrypt(_ ciphertext: Data, with privateKey: SecKey) -> Data? { + guard SecKeyIsAlgorithmSupported(privateKey, .decrypt, seAlgorithm) else { return nil } + return SecKeyCreateDecryptedData(privateKey, seAlgorithm, ciphertext as CFData, nil) as Data? + } + + // MARK: Keychain (generic-password items, this-device-only) + + private static func keychainRead(account: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil } + return item as? Data + } + + @discardableResult + private static func keychainWrite(_ data: Data, account: String) -> Bool { + let delete: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + ] + SecItemDelete(delete as CFDictionary) + let add: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + kSecValueData as String: data, + ] + return SecItemAdd(add as CFDictionary, nil) == errSecSuccess + } +} diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 76064cd..6116a9f 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -837,6 +837,8 @@ final class RemoteStore: ObservableObject { func onAppear() { setupLiveActivityBridge() if demoMode { seedDemo(); return } + // Seed the phone-vault mirror (what this phone can credential a runner with). + refreshPhoneVaultKinds() startNetworkingIfNeeded() if isPaired { // Show the saved chat history immediately, before any host connects. @@ -1340,6 +1342,11 @@ final class RemoteStore: ObservableObject { // Mesh dispatch: resume the waiting `dispatchChatToMesh` by requestID. self?.chatStartedWaiters.removeValue(forKey: outcome.requestID)?.resume(returning: outcome) } + cb.credentialsChanged = { [weak self] in + // The phone vault changed under this connection (a rotation landed / a mesh-wide + // deletion cleared a kind) — refresh the held-kinds mirror Settings shows. + self?.refreshPhoneVaultKinds() + } cb.meshRosterChanged = { [weak self] in // Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac // (connecting the newcomer) and drop any that left — without switching the active host. @@ -2125,6 +2132,46 @@ final class RemoteStore: ObservableObject { && conn.credentialSealingKey != nil } + // MARK: - Phone credential vault (phone as credential holder) + + /// The credential kinds this phone's encrypted vault currently holds — the Agent Accounts + /// footer's "this iPhone can credential a fresh runner" note. Refreshed whenever a + /// connection lands an update or a deletion. + @Published private(set) var phoneVaultKinds: [CredentialKind] = [] + + /// Re-read the vault's held kinds (vault I/O runs on its own actor, off the main actor). + func refreshPhoneVaultKinds() { + Task { [weak self] in + let kinds = await PhoneCredentialVault.shared.heldKinds() + self?.phoneVaultKinds = kinds + } + } + + /// Whether `hostID` can land a mesh-wide credential deletion right now. + func canRevokeCredentials(onHost hostID: String) -> Bool { + guard let conn = connections[hostID] else { return false } + return conn.connectivity.isLive && conn.capabilities.canRevokeCredentials + } + + /// Delete a credential kind from every mesh member: clear this phone's own vault copy, + /// mint the tombstone (absorbing the copy's freshness so a stale holder can't resurrect + /// it), and send it at every live host that accepts the verb — each host clears its + /// stores, records the stone, and fans it out to its other clients and peers. The + /// provider rows flip via the hosts' refreshed `agentAuthStatus` push (the implicit ack). + func revokeCredential(kind: CredentialKind) { + guard !demoMode else { return } + Task { [weak self] in + let stone = await PhoneCredentialVault.shared.deleteCredential( + kind, deviceID: IdentityStore.deviceID()) + guard let self else { return } + for conn in self.connections.values + where conn.connectivity.isLive && conn.capabilities.canRevokeCredentials { + conn.send(.credentialRevoke([stone])) + } + self.refreshPhoneVaultKinds() + } + } + /// Seal an API key directly to `hostID` and send it (REMOTE_AGENT_LOGIN §8 — the /// ToS-defensive Console-key fallback). The key transits only as a sealed box inside the /// E2EE channel and is NOT kept on the phone; the host lands it (Keychain stores on a Mac, @@ -2481,15 +2528,17 @@ final class RemoteStore: ObservableObject { // Never originated from here (connection-internal, or handled by dedicated loops). // `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by // `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch. - // The runner verbs (intelligence results, credential mesh — COVALENCE_RUNNER §5–6) will - // ride their own executor/vault loops when the phone side lands; nothing routes them here. + // The runner verbs ride their own loops: manifests/provisions go out per-connection + // from `HostConnection` (gossip on ready, answers to `credentialNeeded`), and + // `credentialRevoke` goes to every capable host from `revokeCredential(kind:)`. // `createProject` (CLOUD_RUNTIME §4.3) will go straight to a user-chosen host when the // phone grows that UI — a brand-new project has no owner to route by. case .hello, .ping, .listPeers, .addressUpdate, .meshRoster, .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .requestPairingCode, .cancelPairingCode, .respondMacPair, - .intelligenceResult, .credentialManifest, .credentialProvision, .createProject, + .intelligenceResult, .credentialManifest, .credentialProvision, .credentialRevoke, + .createProject, // Remote agent sign-in goes straight to the user-chosen host from // `beginAgentLogin`/`submitPastedLoginCode`/`cancelAgentLogin` — the attempt is // pinned to one host's PKCE state, so owner-routing can never apply. @@ -2695,7 +2744,7 @@ final class RemoteStore: ObservableObject { // the demo path short-circuits in `requestPairingCode()` with a stand-in code. .requestPairingCode, .cancelPairingCode, .respondMacPair, // Covalence runner verbs (COVALENCE_RUNNER §5–6) — demo has no runner host. - .intelligenceResult, .credentialManifest, .credentialProvision, + .intelligenceResult, .credentialManifest, .credentialProvision, .credentialRevoke, // Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on. .createProject, // Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md) — demo has no host to broker diff --git a/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift index 07444e5..b671b17 100644 --- a/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift +++ b/NucleicRemote/NucleicRemote/Views/AgentAccountsView.swift @@ -13,6 +13,9 @@ struct AgentAccountsSection: View { @EnvironmentObject var store: RemoteStore /// The row whose "Use API key…" sheet is open — (host, provider, display name). @State private var apiKeyTarget: APIKeyTarget? + /// The mesh-wide deletion awaiting the user's confirm (it tombstones the credential on + /// EVERY device, so it always confirms first). Nil hides the dialog. + @State private var deleteTarget: DeleteTarget? struct APIKeyTarget: Identifiable { let hostID: String @@ -22,6 +25,12 @@ struct AgentAccountsSection: View { var id: String { hostID + "·" + provider.rawValue } } + struct DeleteTarget: Identifiable { + let kind: CredentialKind + let label: String + var id: String { kind.rawValue } + } + var body: some View { let hosts = store.agentAccountHosts if !hosts.isEmpty { @@ -40,16 +49,54 @@ struct AgentAccountsSection: View { } header: { Text("Agent accounts") } footer: { - Text("Sign-ins run on the host — your Mac or a cloud runner — and sync to every " - + "device in your mesh. This phone only shows the consent page and relays " - + "the sign-in code over the encrypted channel.") + Text(footerText) } .sheet(item: $apiKeyTarget) { target in APIKeyEntrySheet(target: target) } + .confirmationDialog( + "Delete the \(deleteTarget?.label ?? "credential") from every device in your mesh?", + isPresented: Binding( + get: { deleteTarget != nil }, + set: { if !$0 { deleteTarget = nil } }), + titleVisibility: .visible + ) { + Button("Delete Everywhere", role: .destructive) { + guard let target = deleteTarget else { return } + deleteTarget = nil + store.revokeCredential(kind: target.kind) + } + Button("Cancel", role: .cancel) { deleteTarget = nil } + } message: { + Text("Your Macs, cloud runners, and this iPhone all drop it. A tombstone keeps " + + "any offline device from bringing it back; signing in again re-enables " + + "the provider everywhere.") + } } } + /// The section footer: the standard sign-in explainer, plus — once this phone actually + /// holds mirrored logins — the holder note (an encrypted copy lives here, so a fresh + /// runner can be credentialed with every Mac asleep). + private var footerText: String { + var text = "Sign-ins run on the host — your Mac or a cloud runner — and sync to every " + + "device in your mesh. This phone only shows the consent page and relays " + + "the sign-in code over the encrypted channel." + let held = store.phoneVaultKinds.compactMap { kind -> String? in + switch kind { + case .claudeOAuth: "Claude" + case .codexAuth: "Codex" + default: nil + } + } + if !held.isEmpty { + text += " This iPhone also keeps an encrypted copy of the " + + held.joined(separator: " and ") + + " sign-in, so it can credential a fresh runner on its own." + } + return text + } + @ViewBuilder private func providerRow( _ status: WireProviderAuthStatus, hostID: String, hostName: String @@ -87,6 +134,30 @@ struct AgentAccountsSection: View { .accessibilityLabel("Use an API key for \(name)") } } + .contextMenu { + // Mesh-wide deletion (key deletion from any device): offered when a host that + // accepts the tombstone verb is reachable — the deletion then propagates from it + // to every other member (and this phone clears its own vault copy regardless). + if status.authenticated, store.canRevokeCredentials(onHost: hostID) { + if status.method == "apiKey" { + Button(role: .destructive) { + deleteTarget = DeleteTarget( + kind: status.provider == .codex ? .openAIAPIKey : .anthropicAPIKey, + label: "\(name) API key") + } label: { + Label("Delete API Key on All Devices…", systemImage: "trash") + } + } else { + Button(role: .destructive) { + deleteTarget = DeleteTarget( + kind: status.provider == .codex ? .codexAuth : .claudeOAuth, + label: "\(name) sign-in") + } label: { + Label("Sign Out on All Devices…", systemImage: "trash") + } + } + } + } } private var apiKeyProviders: [AgentLoginProvider] { [.claude, .codex] }