Files
nucleic/Sources/NucleicProtocol/Noise/SecureChannel.swift
T
abkslmandClaude Fable 5 61e11572b8 Add Tailscale (Tailnet) as a sync transport between Mac and iPhone
Settings ▸ Remote gains a "Connect via" picker — LAN (default), Tailscale
(tailnet), or Relay (disabled, coming soon). On Tailnet, both devices run an
embedded tsnet node via TailscaleKit (tailscale/libtailscale) and sync frames
flow over the user's tailnet, so the phone can connect from anywhere the
tailnet reaches; Noise E2EE runs above the transport unchanged.

- NucleicTailnet (new target, macOS + iOS): TailnetNode wraps TailscaleKit's
  node lifecycle (auth-key login, generation-fenced start/stop since up() is
  un-cancellable) and drops to the framework's public C API for the data path
  — tailscale_dial/listen/accept hand back full-duplex socketpair fds, wrapped
  by FDFrameChannel (DispatchIO) into the shared FrameChannel seam. The Swift
  wrapper's one-way connection actors can't carry a bidirectional stream.
- Host: TailnetListener adopts SyncListener; startSyncServer is single-flight
  and honors toggle-off/picker changes at the commit point; pairing QRs carry
  transport + tailnet IP/port hints (PairingPayload additive optional fields,
  forward/backward compatible over CBOR).
- iPhone: pair/reconnect dial over whichever transport the pairing recorded;
  Settings gains a Tailscale auth-key field (Keychain, committed on editing
  end); connectivity chip shows "Connected · Tailnet".
- TailscaleKit has no SwiftPM distribution: scripts/build-tailscalekit.sh
  builds a pinned libtailscale commit into an untracked local xcframework;
  Package.swift links it only when present (everything builds without it, the
  picker then reports Tailscale support as not built in), and the script
  clears SwiftPM's content-keyed manifest cache so the toggle is picked up.
- iOS floor 17.0 → 18.1 (TailscaleKit requires the iOS 18 Swift runtime);
  package-app.sh embeds the framework in the .app like Sparkle.

703-test suite: no new failures (the 7 fake-claude/fake-grok staging issues
reproduce identically on an untouched checkout — pre-existing, tracked
separately). New coverage: FDFrameChannel over socketpairs, pairing-payload
version-skew both directions, transport-setting resolution.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 03:50:45 -07:00

222 lines
9.4 KiB
Swift

import Foundation
import CryptoKit
/// A device's long-term identity (SYNC_PROTOCOL §4.1): an X25519 static keypair (the Noise
/// `s`) plus an Ed25519 signing key. On-device these live in the Secure Enclave / Keychain;
/// this type is the in-memory handle and a raw-bytes codec for that storage.
public struct DeviceIdentity: Sendable {
public let staticKey: Curve25519.KeyAgreement.PrivateKey
public let signingKey: Curve25519.Signing.PrivateKey
public init(
staticKey: Curve25519.KeyAgreement.PrivateKey = .init(),
signingKey: Curve25519.Signing.PrivateKey = .init()
) {
self.staticKey = staticKey
self.signingKey = signingKey
}
public var staticPublicKey: Data { staticKey.publicKey.rawRepresentation }
public var signingPublicKey: Data { signingKey.publicKey.rawRepresentation }
/// Stable fingerprint of the static key for display / TXT-record advertising
/// (structural metadata only — safe to log/show).
public var fingerprint: String { Self.fingerprint(ofStaticKey: staticPublicKey) }
/// Fingerprint of any static public key — the phone uses this to match a Bonjour result
/// against the host key it pinned at pairing.
public static func fingerprint(ofStaticKey key: Data) -> String {
let digest = SHA256.hash(data: key)
return digest.prefix(8).map { String(format: "%02x", $0) }.joined()
}
// Raw persistence (store these bytes in the Keychain).
public func exportRaw() -> Data {
staticKey.rawRepresentation + signingKey.rawRepresentation
}
public init(importingRaw data: Data) throws {
guard data.count == 64 else { throw SecureChannelError.badIdentityBlob }
staticKey = try .init(rawRepresentation: data.prefix(32))
signingKey = try .init(rawRepresentation: data.suffix(32))
}
public func sign(_ data: Data) throws -> Data { try signingKey.signature(for: data) }
public static func verify(_ signature: Data, of data: Data, publicKey: Data) -> Bool {
guard let pk = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false }
return pk.isValidSignature(signature, for: data)
}
}
public enum SecureChannelError: Error, Equatable {
case badIdentityBlob
case handshakeIncomplete
case notEstablished
}
/// An established, encrypted application session over a completed Noise handshake. Wraps the
/// two transport `CipherState`s and (de)serializes application messages as CBOR. Drive it
/// from the transport's serial receive/send path — it is not internally synchronized.
public final class SecureSession {
private var send: CipherState
private var recv: CipherState
/// The peer's pinned static public key (who we're talking to).
public let remoteStaticKey: Data
/// Channel-binding hash — identical on both ends; basis for an out-of-band verify code.
public let handshakeHash: Data
init(send: CipherState, recv: CipherState, remoteStaticKey: Data, handshakeHash: Data) {
self.send = send
self.recv = recv
self.remoteStaticKey = remoteStaticKey
self.handshakeHash = handshakeHash
}
/// Encrypt an application message → the opaque frame payload (caller length-prefixes it).
public func seal<T: Encodable>(_ message: T) throws -> Data {
let plaintext = try CBOREncoder().encode(message)
return try send.encrypt(ad: Data(), plaintext: plaintext)
}
/// Decrypt a received frame payload → an application message.
public func open<T: Decodable>(_ type: T.Type, from frame: Data) throws -> T {
let plaintext = try recv.decrypt(ad: Data(), ciphertext: frame)
return try CBORDecoder().decode(T.self, from: plaintext)
}
}
// MARK: - Pairing QR payload (SYNC §4.2)
/// What the Mac encodes into the pairing QR: its static public key, a one-time pairing
/// secret (the Noise PSK), a human label, and a connection hint for whichever transport the
/// host is listening on — LAN (`lanHost`/`lanPort`) or tailnet (`tailnetHost`/`tailnetPort`).
/// The phone scans this to bootstrap the `xxpsk0` pairing handshake bound to exactly this QR.
///
/// `transport` is a raw string (see `SyncTransportHint`), not an enum, so an old phone
/// scanning a newer QR — or this build scanning a future transport — still decodes the
/// payload and can say "unsupported" instead of failing the scan. All hint fields are
/// optional; a payload from an older host simply decodes them as nil (LAN).
public struct PairingPayload: Sendable, Codable, Equatable {
public let protocolVersion: Int
public let hostName: String
public let hostStaticKey: Data
public let pairingSecret: Data // 32 bytes; the Noise PSK
public let lanHost: String?
public let lanPort: UInt16?
/// Which transport the host is listening on (`SyncTransportHint` raw value); nil = LAN.
public let transport: String?
/// The host's tailnet IP (v4 preferred) when `transport == .tailnet`.
public let tailnetHost: String?
public let tailnetPort: UInt16?
public init(
protocolVersion: Int = SyncProtocol.version,
hostName: String, hostStaticKey: Data, pairingSecret: Data,
lanHost: String? = nil, lanPort: UInt16? = nil,
transport: String? = nil, tailnetHost: String? = nil, tailnetPort: UInt16? = nil
) {
self.protocolVersion = protocolVersion
self.hostName = hostName
self.hostStaticKey = hostStaticKey
self.pairingSecret = pairingSecret
self.lanHost = lanHost
self.lanPort = lanPort
self.transport = transport
self.tailnetHost = tailnetHost
self.tailnetPort = tailnetPort
}
/// The transport hint: `.lan` when absent (pre-transport hosts), nil when the QR names
/// a transport this build doesn't know — callers surface "update the app" rather than
/// silently dialing the wrong way.
public var transportHint: SyncTransportHint? {
guard let transport else { return .lan }
return SyncTransportHint(rawValue: transport)
}
public static func freshSecret() -> Data {
Data((0..<32).map { _ in UInt8.random(in: .min ... .max) })
}
/// Compact text for a QR code: `nucleic://pair?d=<base64url(cbor)>`.
public func qrString() throws -> String {
let cbor = try CBOREncoder().encode(self)
return "nucleic://pair?d=" + base64URL(cbor)
}
public init(qrString: String) throws {
guard let range = qrString.range(of: "d=") else { throw SecureChannelError.badIdentityBlob }
let encoded = String(qrString[range.upperBound...])
guard let cbor = Self.dataFromBase64URL(encoded) else { throw SecureChannelError.badIdentityBlob }
self = try CBORDecoder().decode(PairingPayload.self, from: cbor)
}
}
private func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
extension PairingPayload {
fileprivate static func dataFromBase64URL(_ s: String) -> Data? {
var b64 = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
while b64.count % 4 != 0 { b64.append("=") }
return Data(base64Encoded: b64)
}
}
// MARK: - Handshake convenience (host = responder, phone = initiator)
public enum NoiseSession {
/// Phone side. Pairing uses `xxpsk0` with the QR secret; reconnect uses `IK` against the
/// pinned host static key. Returns a driver and a finisher producing the `SecureSession`.
public static func makeInitiator(
identity: DeviceIdentity, hostStaticKey: Data, psk: Data?
) -> HandshakeDriver {
let pattern: NoiseHandshake.Pattern = psk != nil ? .xxpsk0 : .ik
let hs = NoiseHandshake(
pattern: pattern, initiator: true, staticKey: identity.staticKey,
remoteStatic: hostStaticKey, psk: psk)
return HandshakeDriver(handshake: hs)
}
/// Mac side. Mirrors the phone: `xxpsk0` during pairing, `IK` on reconnect.
public static func makeResponder(
identity: DeviceIdentity, psk: Data?
) -> HandshakeDriver {
let pattern: NoiseHandshake.Pattern = psk != nil ? .xxpsk0 : .ik
let hs = NoiseHandshake(
pattern: pattern, initiator: false, staticKey: identity.staticKey, psk: psk)
return HandshakeDriver(handshake: hs)
}
}
/// Thin stateful wrapper that steps a handshake and, when complete, mints a `SecureSession`.
public final class HandshakeDriver {
private var handshake: NoiseHandshake
init(handshake: NoiseHandshake) { self.handshake = handshake }
public var isComplete: Bool { handshake.isComplete }
public var isMyTurnToWrite: Bool { handshake.isMyTurnToWrite }
public func write(payload: Data = Data()) throws -> Data {
try handshake.writeMessage(payload: payload)
}
public func read(_ message: Data) throws -> Data {
try handshake.readMessage(message)
}
/// Available once `isComplete` — the established encrypted session.
public func session() throws -> SecureSession {
guard handshake.isComplete, let rs = handshake.remoteStaticKey else {
throw SecureChannelError.handshakeIncomplete
}
let ciphers = try handshake.transportCiphers()
return SecureSession(
send: ciphers.send, recv: ciphers.recv,
remoteStaticKey: rs, handshakeHash: handshake.handshakeHash)
}
}