Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Models/IdentityStore.swift
T
abkslmandClaude Fable 5 3e25ef9559 feat(relay): wire the live Nucleic Private Relay into the desktop + iOS apps (mesh P2 complete)
The relay Worker (nucleic-edge at relay.nucleic.blakeslee.xyz) is deployed, so land the
formerly deploy-gated client side of the data path:

- NucleicProtocol/Sync/RelayTransport.swift: RelayAPI (one base URL for REST + WS,
  membership -> connection token trade), RelayWebSocket (ordered sends, ping keepalive,
  ping-confirmed connect), RelayFrameChannel (client leg, WireFraming inside WS binary,
  presence fail-fast when the room has no host), RelayPresence.
- NucleicCore/Sync/RelayAccess.swift: X25519 PoP enrollment (RelayEnrollment), room
  credential in the login Keychain (separate from the push credential), membership minting
  with re-enroll-on-401.
- NucleicCore/Sync/RelayListener.swift: host SyncListener demuxing the room socket into
  per-tag virtual FrameChannels via RelayEnvelope; presence-driven reaping; backoff redial;
  injectable RelayRoomSocket seam for tests.
- Wire: additive HostMsg.relayMembership(WireRelayMembership) pushed after every hello
  (SyncHost.register -> AppStore mint) so devices paired before the relay adopt it and the
  ~90-day token refreshes on each connect; the pairing QR also carries a bootstrap
  membership so first contact can ride the relay. Old clients ignore the unknown tag.
- AppStore: .relay joins the listener composite behind the Connection-methods checkbox
  (failure degrades to a status row), advertises relayRoomID in PeerAddresses, mints the
  QR bootstrap in beginPairing.
- Desktop UI: the Nucleic Private Relay toggle is enabled (was "coming soon"); the
  LAN-only banner offers it alongside Tailnet.
- iOS: relay is the last dial candidate in HostConnection pair + reconnect (10s handshake
  watchdog); PairedHost persists relayRoomID/relayMembershipToken/relayURL; the
  relayMembership push updates the registry in place; Settings shows Relay in Transports.

Tests: RelayTransportTests, RelayListenerTests, SyncHostTests relay push + QR bootstrap.
Full suite green (783 core + 113 protocol + 2 new); iOS simulator build succeeds. Live
smoke test against the deployed Worker passed end-to-end (PoP enroll, both token tiers,
two-socket frame round-trip through the Room DO with correct envelope tags).

Known limits: host revoke-on-unpair not wired (endpoint is admin-only); PeerClient
(Mac<->Mac) doesn't dial the relay yet.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 21:05:01 -07:00

177 lines
7.5 KiB
Swift

import Foundation
import Security
import NucleicProtocol
#if canImport(UIKit)
import UIKit
#endif
/// What the phone pins about its Mac at pairing (SYNC §4.2): the host's static key (for IK
/// reconnect), a display name, and the transport + connection hint from the QR — LAN
/// host:port or the Mac's tailnet IP. The pairing secret is *not* stored — it's one-time.
/// Non-secret, so UserDefaults is fine; the device private key goes to Keychain. The new
/// optional fields decode as nil from a pre-transport record (= LAN).
struct PairedHost: Codable, Equatable {
var deviceID: String
var hostName: String
var hostStaticKey: Data
var fingerprint: String
var lanHost: String?
var lanPort: UInt16?
/// `SyncTransportHint` raw value; nil = LAN (records saved before transports existed).
var transport: String?
var tailnetHost: String?
var tailnetPort: UInt16?
/// Nucleic Private Relay bootstrap (mesh P2): the host's room, this device's membership
/// token, and an optional base-URL override. Set from the pairing QR and refreshed by
/// the host's `relayMembership` push on every connect; nil while the host has the relay
/// method off (records predating the relay decode them as nil).
var relayRoomID: String?
var relayMembershipToken: String?
var relayURL: String?
var transportHint: SyncTransportHint { transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan }
}
/// Loads/persists this device's long-term `DeviceIdentity` (Keychain) and the pinned host
/// (UserDefaults). The identity is generated once on first launch and reused thereafter.
enum IdentityStore {
private static let keychainAccount = "xyz.blakeslee.nucleic.remote.identity"
/// Legacy single-host slot (pre-mesh P3). Migrated into `pairedHostsKey` on first registry read.
private static let pairedHostKey = "nucleic.pairedHost"
/// The multi-host registry (mesh P3): an ordered `[PairedHost]`, most-recently-paired last.
/// The last entry is the "active" host the single connection uses today; the multiplexer will
/// connect to all of them.
private static let pairedHostsKey = "nucleic.pairedHosts"
private static let deviceIDKey = "nucleic.deviceID"
static func loadOrCreateIdentity() -> DeviceIdentity {
if let data = keychainRead(), let identity = try? DeviceIdentity(importingRaw: data) {
return identity
}
let identity = DeviceIdentity()
keychainWrite(identity.exportRaw())
return identity
}
/// Stable per-install device id (re-used across reconnects so the host can match the pin).
static func deviceID() -> String {
let defaults = UserDefaults.standard
if let existing = defaults.string(forKey: deviceIDKey) { return existing }
let id = deviceIDPrefix + UUID().uuidString.prefix(8).lowercased()
defaults.set(id, forKey: deviceIDKey)
return id
}
/// Idiom-tagged prefix so the host lists a paired device with the right kind/icon
/// (`ipad-…` vs `iphone-…`). Only stamps *freshly generated* ids — an existing install
/// keeps whatever id it already persisted, so upgrading a phone never changes its identity.
private static var deviceIDPrefix: String {
#if canImport(UIKit)
return UIDevice.current.userInterfaceIdiom == .pad ? "ipad-" : "iphone-"
#else
return "iphone-"
#endif
}
// MARK: - Paired-host registry (mesh P3)
/// Every Mac this phone is paired with, most-recently-paired last. Migrates the legacy
/// single-host slot into the registry on first read (then removes it), so an upgrade keeps its
/// Mac. Ordered so the last is the "active" host today; a future multiplexer connects to all.
static func pairedHosts() -> [PairedHost] {
let defaults = UserDefaults.standard
if let data = defaults.data(forKey: pairedHostsKey),
let hosts = try? JSONDecoder().decode([PairedHost].self, from: data) {
return hosts
}
// One-time migration from the pre-mesh single slot.
if let legacy = defaults.data(forKey: pairedHostKey),
let host = try? JSONDecoder().decode(PairedHost.self, from: legacy) {
savePairedHosts([host])
defaults.removeObject(forKey: pairedHostKey)
return [host]
}
return []
}
/// The one paired Mac with `hostID` (its `fingerprint`), if any.
static func pairedHost(id hostID: String) -> PairedHost? {
pairedHosts().first { $0.fingerprint == hostID }
}
/// Add or update a host by fingerprint, moving it to the end (making it the active host). Used
/// on a successful pairing handshake and on any address refresh.
static func upsertPairedHost(_ host: PairedHost) {
var hosts = pairedHosts().filter { $0.fingerprint != host.fingerprint }
hosts.append(host)
savePairedHosts(hosts)
}
/// Forget one paired Mac by fingerprint (per-host unpair).
static func removePairedHost(id hostID: String) {
savePairedHosts(pairedHosts().filter { $0.fingerprint != hostID })
}
/// Mutate one host's record **in place**, preserving registry order — unlike
/// `upsertPairedHost`, this must not make the host active (it backs background
/// refreshes like the relay-membership push, which can arrive from a non-active Mac).
static func updatePairedHost(id hostID: String, mutate: (inout PairedHost) -> Void) {
var hosts = pairedHosts()
guard let index = hosts.firstIndex(where: { $0.fingerprint == hostID }) else { return }
mutate(&hosts[index])
savePairedHosts(hosts)
}
private static func savePairedHosts(_ hosts: [PairedHost]) {
if let data = try? JSONEncoder().encode(hosts) {
UserDefaults.standard.set(data, forKey: pairedHostsKey)
}
}
// MARK: - Single-host bridge (until the RemoteStore multiplexer lands)
/// The active host the single connection uses — the most-recently-paired. `nil` if unpaired.
static func loadPairedHost() -> PairedHost? {
pairedHosts().last
}
/// Pair (or re-pair) a host and make it active.
static func savePairedHost(_ host: PairedHost) {
upsertPairedHost(host)
}
/// Unpair the active host (the one the single connection currently uses).
static func clearPairedHost() {
if let active = pairedHosts().last { removePairedHost(id: active.fingerprint) }
}
// MARK: - Keychain
private static func keychainRead() -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil }
return item as? Data
}
private static func keychainWrite(_ data: Data) {
let delete: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainAccount,
]
SecItemDelete(delete as CFDictionary)
let add: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainAccount,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
SecItemAdd(add as CFDictionary, nil)
}
}