Merge nucleic/fuzzy-velvet-quail-rnyt into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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] }
|
||||
|
||||
Reference in New Issue
Block a user