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 }
|
var agentLoginChallenge: (WireAgentLoginChallenge) -> Void = { _ in }
|
||||||
/// The definitive outcome of this phone's sign-in attempt, keyed by requestID.
|
/// The definitive outcome of this phone's sign-in attempt, keyed by requestID.
|
||||||
var agentLoginResult: (WireAgentLoginResult) -> Void = { _ in }
|
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
|
private let callbacks: Callbacks
|
||||||
|
|
||||||
@@ -627,6 +631,13 @@ final class HostConnection {
|
|||||||
if welcome.capabilities.canCast {
|
if welcome.capabilities.canCast {
|
||||||
send(.castSubscribe(CastSubscribe(cursors: callbacks.castCursors())))
|
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
|
// 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.
|
// only the gap rather than snapping back to the host's 200-event tail.
|
||||||
if let id = openSessionID {
|
if let id = openSessionID {
|
||||||
@@ -842,17 +853,43 @@ final class HostConnection {
|
|||||||
directBox?.deliver(.decline(reason))
|
directBox?.deliver(.decline(reason))
|
||||||
case .credentialNeeded(let need):
|
case .credentialNeeded(let need):
|
||||||
// The host's sealing key rides this push (kinds may be empty — a cockpit Mac never
|
// 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
|
// asks, a runner lists what it's missing). The key is what the one-shot API-key
|
||||||
// *provider* — it holds no vault to answer `kinds` from — but the key is what the
|
// flow seals to (REMOTE_AGENT_LOGIN §8) — cache it either way. Non-empty kinds
|
||||||
// "use an API key" flow seals to (REMOTE_AGENT_LOGIN §8).
|
// 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
|
credentialSealingKey = need.sealingPublicKey.isEmpty ? nil : need.sealingPublicKey
|
||||||
callbacks.didUpdate()
|
callbacks.didUpdate()
|
||||||
case .credentialUpdate,
|
if !need.kinds.isEmpty, capabilities.canReceiveSealedCredentials {
|
||||||
// The owner's runner-pool credential (item 4) — inert until the phone grows a
|
Task { [weak self] in
|
||||||
// pool-management surface; Macs are the managers today.
|
guard let envelope = await PhoneCredentialVault.shared.sealedEnvelope(for: need)
|
||||||
.runnerPoolCredential:
|
else { return }
|
||||||
// Remaining Covalence runner credential verbs (docs/COVALENCE_RUNNER.md §6): inert
|
self?.send(.credentialProvision(envelope))
|
||||||
// here until the phone-side vault lands.
|
}
|
||||||
|
}
|
||||||
|
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
|
break
|
||||||
case .wireError(let error):
|
case .wireError(let error):
|
||||||
if error.code == .channelMismatch {
|
if error.code == .channelMismatch {
|
||||||
@@ -973,6 +1010,19 @@ final class HostConnection {
|
|||||||
Task { await client.send(msg) }
|
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
|
/// 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
|
/// 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
|
/// 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() {
|
func onAppear() {
|
||||||
setupLiveActivityBridge()
|
setupLiveActivityBridge()
|
||||||
if demoMode { seedDemo(); return }
|
if demoMode { seedDemo(); return }
|
||||||
|
// Seed the phone-vault mirror (what this phone can credential a runner with).
|
||||||
|
refreshPhoneVaultKinds()
|
||||||
startNetworkingIfNeeded()
|
startNetworkingIfNeeded()
|
||||||
if isPaired {
|
if isPaired {
|
||||||
// Show the saved chat history immediately, before any host connects.
|
// 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.
|
// Mesh dispatch: resume the waiting `dispatchChatToMesh` by requestID.
|
||||||
self?.chatStartedWaiters.removeValue(forKey: outcome.requestID)?.resume(returning: outcome)
|
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
|
cb.meshRosterChanged = { [weak self] in
|
||||||
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
|
// 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.
|
// (connecting the newcomer) and drop any that left — without switching the active host.
|
||||||
@@ -2125,6 +2132,46 @@ final class RemoteStore: ObservableObject {
|
|||||||
&& conn.credentialSealingKey != nil
|
&& 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
|
/// 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
|
/// 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,
|
/// 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).
|
// Never originated from here (connection-internal, or handled by dedicated loops).
|
||||||
// `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by
|
// `requestPairingCode`/`cancelPairingCode` are sent straight to the chosen host by
|
||||||
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
|
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
|
||||||
// The runner verbs (intelligence results, credential mesh — COVALENCE_RUNNER §5–6) will
|
// The runner verbs ride their own loops: manifests/provisions go out per-connection
|
||||||
// ride their own executor/vault loops when the phone side lands; nothing routes them here.
|
// 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
|
// `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.
|
// phone grows that UI — a brand-new project has no owner to route by.
|
||||||
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
|
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
|
||||||
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
|
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
|
||||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
|
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
|
||||||
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
||||||
.intelligenceResult, .credentialManifest, .credentialProvision, .createProject,
|
.intelligenceResult, .credentialManifest, .credentialProvision, .credentialRevoke,
|
||||||
|
.createProject,
|
||||||
// Remote agent sign-in goes straight to the user-chosen host from
|
// Remote agent sign-in goes straight to the user-chosen host from
|
||||||
// `beginAgentLogin`/`submitPastedLoginCode`/`cancelAgentLogin` — the attempt is
|
// `beginAgentLogin`/`submitPastedLoginCode`/`cancelAgentLogin` — the attempt is
|
||||||
// pinned to one host's PKCE state, so owner-routing can never apply.
|
// 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.
|
// the demo path short-circuits in `requestPairingCode()` with a stand-in code.
|
||||||
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
||||||
// Covalence runner verbs (COVALENCE_RUNNER §5–6) — demo has no runner host.
|
// 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.
|
// Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on.
|
||||||
.createProject,
|
.createProject,
|
||||||
// Remote agent sign-in (docs/REMOTE_AGENT_LOGIN.md) — demo has no host to broker
|
// 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
|
@EnvironmentObject var store: RemoteStore
|
||||||
/// The row whose "Use API key…" sheet is open — (host, provider, display name).
|
/// The row whose "Use API key…" sheet is open — (host, provider, display name).
|
||||||
@State private var apiKeyTarget: APIKeyTarget?
|
@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 {
|
struct APIKeyTarget: Identifiable {
|
||||||
let hostID: String
|
let hostID: String
|
||||||
@@ -22,6 +25,12 @@ struct AgentAccountsSection: View {
|
|||||||
var id: String { hostID + "·" + provider.rawValue }
|
var id: String { hostID + "·" + provider.rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct DeleteTarget: Identifiable {
|
||||||
|
let kind: CredentialKind
|
||||||
|
let label: String
|
||||||
|
var id: String { kind.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let hosts = store.agentAccountHosts
|
let hosts = store.agentAccountHosts
|
||||||
if !hosts.isEmpty {
|
if !hosts.isEmpty {
|
||||||
@@ -40,16 +49,54 @@ struct AgentAccountsSection: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Agent accounts")
|
Text("Agent accounts")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Sign-ins run on the host — your Mac or a cloud runner — and sync to every "
|
Text(footerText)
|
||||||
+ "device in your mesh. This phone only shows the consent page and relays "
|
|
||||||
+ "the sign-in code over the encrypted channel.")
|
|
||||||
}
|
}
|
||||||
.sheet(item: $apiKeyTarget) { target in
|
.sheet(item: $apiKeyTarget) { target in
|
||||||
APIKeyEntrySheet(target: target)
|
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
|
@ViewBuilder
|
||||||
private func providerRow(
|
private func providerRow(
|
||||||
_ status: WireProviderAuthStatus, hostID: String, hostName: String
|
_ status: WireProviderAuthStatus, hostID: String, hostName: String
|
||||||
@@ -87,6 +134,30 @@ struct AgentAccountsSection: View {
|
|||||||
.accessibilityLabel("Use an API key for \(name)")
|
.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] }
|
private var apiKeyProviders: [AgentLoginProvider] { [.claude, .codex] }
|
||||||
|
|||||||
Reference in New Issue
Block a user