379 lines
18 KiB
Swift
379 lines
18 KiB
Swift
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 rotating OAuth logins, matching
|
|
/// `RunnerCredentialVault.mirrorableKinds`. Static keys are never stored on the phone.
|
|
static let mirrorableKinds: [CredentialKind] = [.claudeOAuth, .codexAuth, .grokAuth]
|
|
|
|
// 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
|
|
case .grokAuth:
|
|
guard GrokCredentialFormat.shouldReplace(candidate: json, current: current)
|
|
else { continue }
|
|
stamp = GrokCredentialFormat.expiresAt(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
|
|
}
|
|
}
|