diff --git a/CLAUDE.md b/CLAUDE.md index 438333c3..4335b000 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -321,8 +321,8 @@ strip, with gold for the battery giving power. ## Non-negotiable invariants - **Never fake live.** Every reading carries its age. -- **Freshness is two fields.** `carrier` (relay, cache, none) and `srcState` - (live, lagging, stale, down, never) stay orthogonal. +- **Freshness is two fields.** `carrier` (tunnel, relay, cloud, cache, + none) and `srcState` (live, lagging, stale, down, never) stay orthogonal. - **The app expresses intent; the box decides.** - **Positive watts flow into the site, negative out.** The UI never shows a raw minus sign. @@ -345,7 +345,7 @@ strip, with gold for the battery giving power. On iPhone, one Swift package in `appleApp/FTWKit` holds three modules: - `FTWKit` owns everything that is not a pixel: enrollment parse, - rendezvous handles, Noise IK, frames, the relay and Noise carriers, the + rendezvous handles, Noise IK, frames, the tunnel, relay and Noise carriers, the session, vault wrap/unwrap and the state each screen reads. The app alone links it. It writes the App Group cache. - `FTWShared` holds plain `Sendable` values for the app's extensions: the diff --git a/README.md b/README.md index c782ee76..c73985cf 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,9 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). │ session, relay └──────────────────┬──────────────────────┘ ▼ - wss://relay.ftw.energy (encrypted) + iPhone: wss://cloud.ftw.energy/v1/app, the box session inside as tun + Android, and the iPhone's fallback for one release: wss://relay.ftw.energy + Both carry the same Noise session, end to end to the box │ ▼ FTW box diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index 35e20cca..e51c7156 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -31,6 +31,7 @@ struct FTWApp: App { passkeys: Passkeys(), build: AppInfo.build, ua: AppInfo.userAgent, + relayURL: Self.relayURL, // App Review and anyone without a box can try a simulated home. demo: SimulatedBox.loopback ) @@ -58,6 +59,17 @@ struct FTWApp: App { network = NetworkWatch { [weak model] in model?.networkOnline() } } + /// The relay, or in a debug build `-FTWRelayURL `: pointed + /// nowhere, it shows the app reaching its box through FTW Cloud alone. + static var relayURL: URL { + #if DEBUG + if let raw = UserDefaults.standard.string(forKey: "FTWRelayURL"), let url = URL(string: raw), url.scheme == "wss" || url.scheme == "ws" { + return url + } + #endif + return Origin.relayURL + } + var body: some Scene { WindowGroup { RootView(app: app) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 349ba9d2..3d6bac40 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -79,6 +79,10 @@ public final class SimulatedBox { "cmd.readback", "der.battery", "der.ev", "plan.dispatch", "price.spot", "api.passthrough", ] + /// Capabilities this box offers beyond `caps`, for a test that needs a + /// box of another build. + public var moreCaps: [String] = [] + public init(scheduler: Scheduler, house: SimulatedHouse = SimulatedHouse(), requireStepUp: Bool = false, uptimeAtStartMs: Double = 3_600_000) { self.scheduler = scheduler self.house = house @@ -169,7 +173,7 @@ public final class SimulatedBox { ("mode", .text(booting ? "booting" : "full")), ("box", .map([("id", .text("demo-box")), ("build", .text("0.131.0-demo")), ("tz", .text(house.timeZone.identifier))])), ("clock", .map([("source", .text("ntp")), ("syncedAtMs", .number(scheduler.nowMs - 60_000)), ("uptimeMs", .number(uptimeMs))])), - ("caps", .array(Self.caps.map { .text($0) })), + ("caps", .array((Self.caps + moreCaps).map { .text($0) })), ("capsHash", .text("demo")), ("modes", .array(Self.modes.map { .map([("key", .text($0.key)), ("label", .text($0.label)), ("tooltip", .text($0.tooltip)), ("tier", .text($0.tier))]) })), ("role", .text(Contract.roleOwner)), diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift index c806929c..90eb3127 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift @@ -47,6 +47,14 @@ public protocol Carrier: AnyObject { /// never replayed. func wake() func close(reason: String) + /// A new session with the box starts here: the next frame is its + /// handshake message 1. A carrier that names sessions on the wire draws + /// a new name; one that does not ignores it. + func beginSession() +} + +extension Carrier { + public func beginSession() {} } // MARK: - WebSockets diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift index 85fb1c67..1d32d7bb 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift @@ -10,8 +10,9 @@ import FTWShared /// file moves them and checks nothing about them. `CloudView` checks every /// signature with the key the box gave this phone over Noise. /// -/// The service reads and never controls. Nothing sent here can change the -/// house: a command still goes to the box over the relay. +/// The phone's own session with its box can ride the same socket as `tun` +/// (`TunnelCarrier`). The service forwards it and cannot read it: nothing +/// the service says can change the house. @MainActor public final class CloudCarrier { public enum Status: Equatable, Sendable { @@ -37,6 +38,8 @@ public final class CloudCarrier { /// Message 2 is one round trip after message 1; a service this slow is /// not one to wait on. static let handshakeTimeoutMs: Double = 10_000 + /// A normal close, said to the tunnel when this session ends for good. + static let closeNormal = 1000 public private(set) var status: Status = .connecting @@ -46,6 +49,16 @@ public final class CloudCarrier { private let scheduler: Scheduler private let makeSocket: WebSocketFactory private var looking: Bool + /// A new phone's pairing proof for message 1, while the roster does not + /// list it yet. + private let pair: Bytes? + /// The box session riding this one. Weak: the carrier stack above owns + /// it, and leaves when it closes. + weak var tunnel: TunnelCarrier? + /// The box session's message 1 that rode this dial's message 1, kept + /// until message 2 so a retry on the app's other service key carries it + /// again. + private var riding: CloudWire.Tun? /// What the service last heard: message 1's `hz`, then each `watch`. /// Nil until message 1 of this dial goes. private var told: Bool? @@ -62,16 +75,18 @@ public final class CloudCarrier { private var retry: Cancellable? private var deadline: Cancellable? private var shutdown = false + private var dialledAtMs: Double = -.infinity private var onSnapshot: @MainActor (CloudWire.Snapshot, Double) -> Void = { _, _ in } private var onEnvelope: @MainActor (CloudWire.Envelope, Double) -> Void = { _, _ in } private var onStatus: @MainActor (Status) -> Void = { _ in } - public init(endpoint: CloudEndpoint, home: String, deviceKey: Primitives.KeyPair, looking: Bool, scheduler: Scheduler, makeSocket: @escaping WebSocketFactory) { + public init(endpoint: CloudEndpoint, home: String, deviceKey: Primitives.KeyPair, looking: Bool, pair: Bytes? = nil, scheduler: Scheduler, makeSocket: @escaping WebSocketFactory) { self.endpoint = endpoint self.home = home self.deviceKey = deviceKey self.looking = looking + self.pair = pair self.scheduler = scheduler self.makeSocket = makeSocket } @@ -145,12 +160,36 @@ public final class CloudCarrier { dial() } + /// The box session woke with the app or the network: a socket that + /// looks open may be dead, so dial afresh, as the relay does. A dial + /// started within the last second is kept. + func redial() { + guard !shutdown else { return } + if status == .connecting, socket != nil, scheduler.nowMs - dialledAtMs < 1_000 { return } + retry?.cancel() + retry = nil + attempt = 0 + refusals = 0 + keyIndex = 0 + drop() + dial() + } + + /// One message of the box session, or false when this session is not + /// open and nothing went out. + @discardableResult + func sendTun(_ tun: CloudWire.Tun) -> Bool { + send(CloudWire.msgTun, tun.body) + } + public func close() { guard !shutdown else { return } shutdown = true retry?.cancel() deadline?.cancel() drop() + tunnel?.cloudStatus(.closed(code: Self.closeNormal, retrying: false)) + tunnel = nil onSnapshot = { _, _ in } onEnvelope = { _, _ in } onStatus = { _ in } @@ -161,6 +200,7 @@ public final class CloudCarrier { private func dial() { guard !shutdown else { return } setStatus(.connecting) + dialledAtMs = scheduler.nowMs generation += 1 let mine = generation handshake = nil @@ -193,7 +233,11 @@ public final class CloudCarrier { setStatus(.closed(code: CloudWire.closeDenied, retrying: false)) return } - let now = CloudWire.encodeUnsigned(t: CloudWire.msgNow, body: .map([("hz", .int(looking ? CloudWire.hzLooking : CloudWire.hzAway))])) + // The box session's message 1 rides along when it waits, so that + // session is up one round trip after TLS. + let tun = riding ?? tunnel?.takeFirst() + riding = tun + let now = CloudWire.now(hz: looking ? CloudWire.hzLooking : CloudWire.hzAway, pair: pair, tun: tun) guard let msg1 = try? hs.writeMessage(now) else { return } handshake = hs socket.send(msg1) @@ -216,6 +260,14 @@ public final class CloudCarrier { } // Only a delivered message proves the path works. attempt = 0 + if env.t == CloudWire.msgTun { + guard let tun = try? CloudWire.Tun(body: env.b) else { + fail(code: CloudWire.closeProtocol, retrying: true) + return + } + tunnel?.receive(tun) + return + } onEnvelope(env, arrival) return } @@ -228,6 +280,7 @@ public final class CloudCarrier { } deadline?.cancel() handshake = nil + riding = nil transport = NoiseTransport(keys) attempt = 0 refusals = 0 @@ -248,13 +301,17 @@ public final class CloudCarrier { private func closed(code: Int) { let beforeMessage2 = transport == nil + let carried = riding + riding = nil socket = nil generation += 1 deadline?.cancel() switch code { case CloudWire.closeDenied where beforeMessage2 && keyIndex + 1 < endpoint.serviceKeys.count: - // Perhaps the service already runs on the app's other key. + // Perhaps the service already runs on the app's other key. The + // box session still waits for an answer to its message 1. keyIndex += 1 + riding = carried dial() case CloudWire.closeReplaced: setStatus(.closed(code: code, retrying: false)) @@ -309,12 +366,14 @@ public final class CloudCarrier { socket?.close() socket = nil handshake = nil + riding = nil transport = nil } private func setStatus(_ next: Status) { guard next != status else { return } status = next + tunnel?.cloudStatus(next) onStatus(next) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift index 5b547941..e1981234 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift @@ -71,6 +71,9 @@ public enum CloudWire { public static let msgPushRegister = "push.register" /// The only kind of push registration so far. static let pushKindAlert = "alert" + /// Both ways, `{sid, d}`: one message of the phone's own session with + /// its box, which the service carries and cannot read (`Tun`). + public static let msgTun = "tun" public static let hzLooking = 1 public static let hzAway = 0 @@ -301,6 +304,73 @@ public enum CloudWire { ) } + // MARK: - Tunnel + + /// One message of the phone's session with its box, `ftw.session.v1`, + /// as it rides the app session: `d` byte for byte as it would cross the + /// relay, and an empty `d` ends the session `sid` names. The service + /// adds the device from its own handshake on the way to the box, so this + /// side never names one. + public struct Tun: Equatable, Sendable { + /// The phone's number for one session with the box, drawn at random + /// for each, so the late end of an older one never ends a newer one. + public var sid: UInt32 + public var d: Bytes + + public init(sid: UInt32, d: Bytes) { + self.sid = sid + self.d = d + } + + public var ended: Bool { d.isEmpty } + + var body: CBOR { .map([("sid", .unsigned(UInt64(sid))), ("d", .bytes(d))]) } + + public init(body: Bytes) throws { + let f = try Fields(body: body) + let sid = try f.uint("sid") + guard sid <= UInt64(UInt32.max) else { throw bodyError("sid", "below 2^32") } + self.init(sid: UInt32(sid), d: try f.bytes("d")) + } + } + + /// The largest `d`: one transport message around the app protocol's + /// largest bulk frame. + public static let tunDataMax = 16_384 + 24 + + /// Starts the hash a pending pairing is listed by. + public static let pairingTag = Bytes("ftw.cloud.pair.v1\0".utf8) + + /// What a new phone proves in message 1 so the service admits it before + /// the roster lists it: SHA-256(`pairingTag` ‖ home ‖ code). The code + /// itself rides inside the box session's message 1, encrypted to the + /// box, so the service never sees it. + public static func pairingHash(home: String, code: Bytes) -> Bytes { + Primitives.sha256(pairingTag + Bytes(home.utf8) + code) + } + + /// The box's name for a paired device: the first 8 characters of its + /// Noise static key in base64url. The roster names devices this way. + public static func deviceID(key: Bytes) -> String { + String(Base64url.encode(key).prefix(8)) + } + + /// A home id as the service cuts one: 26 characters of lowercase + /// base32. Anything else in a QR is not a home. + public static func validHome(_ s: String) -> Bool { + s.utf8.count == 26 && s.utf8.allSatisfy { ($0 >= 0x61 && $0 <= 0x7a) || ($0 >= 0x32 && $0 <= 0x37) } + } + + /// Message 1's payload: whether the phone looks, the pairing proof of a + /// phone the roster does not list yet, and the box session's own + /// message 1, so that session is up one round trip after TLS. + public static func now(hz: Int, pair: Bytes? = nil, tun: Tun? = nil) -> Bytes { + var body: [CBOR.Entry] = [CBOR.Entry(.text("hz"), .int(hz))] + if let pair { body.append(CBOR.Entry(.text("pair"), .bytes(pair))) } + if let tun { body.append(CBOR.Entry(.text("tun"), tun.body)) } + return encodeUnsigned(t: msgNow, body: .map(body)) + } + // MARK: - Bodies /// In every body the box sends. diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/FallbackCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/FallbackCarrier.swift new file mode 100644 index 00000000..12c4a159 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/FallbackCarrier.swift @@ -0,0 +1,166 @@ +import Foundation +import FTWShared +import os + +private let carrierLog = Logger(subsystem: "energy.ftw.app", category: "carrier") + +/// The tunnel first, the relay when FTW Cloud cannot carry the box session. +/// +/// For one release, while boxes, FTW Cloud and phones move to the tunnel. The +/// tunnel has to prove itself: until the box has answered a frame of the +/// session (its handshake answer is not enough: the box takes the device's +/// slot only on the phone's first transport frame), a refusal, an +/// unreachable service or a session ended before that answer moves this +/// carrier to the relay, and so does `tunnelWaitMs` without one. A tunnel that carried and then dropped gets the same window +/// to carry again. Once on the relay it stays there; the next carrier, built +/// when the app comes back to the front, tries the tunnel again. +/// +/// One carrier at a time: the tunnel and the relay share the device's one +/// slot at the box, so both up at once would evict each other. The tunnel +/// closes, and ends its session at the box, before the relay dials. +/// +/// `kind` says which one carries, so the freshness band names the path the +/// readings really took. +@MainActor +public final class FallbackCarrier: Carrier { + /// Long enough for TLS, the app session and the box's answer on a slow + /// mobile link; short enough that a missing tunnel costs one glance. + static let tunnelWaitMs: Double = 5_000 + + public private(set) var status: CarrierStatus + public var kind: CarrierKind { active.kind } + /// Whether the relay took over. + public private(set) var onRelay = false + + private var active: Carrier + private let makeRelay: (@MainActor () -> Carrier)? + private let scheduler: Scheduler + /// The box has answered a transport frame through the tunnel since it + /// last opened. + private var carrying = false + /// Frames from the box in the tunnel's current session: message 2, then + /// transport. + private var framesThisSession = 0 + private var wait: Cancellable? + private var closed = false + + private var onFrame: @MainActor (Bytes) -> Void = { _ in } + private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + + /// `relay` builds the relay carrier when it is needed, and not before: + /// a socket held open in reserve would take one of the relay's streams + /// for the home. Nil leaves the tunnel to manage alone. + public init(tunnel: TunnelCarrier, relay: (@MainActor () -> Carrier)?, scheduler: Scheduler) { + active = tunnel + makeRelay = relay + self.scheduler = scheduler + status = tunnel.status + tunnel.setHandlers( + onFrame: { [weak self] bytes in self?.tunnelFrame(bytes) }, + onStatus: { [weak self] s in self?.tunnelStatus(s) } + ) + if case .closed(let reason, _) = tunnel.status { + toRelay(reason) + } else { + startWait() + } + } + + public func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { + self.onFrame = onFrame + self.onStatus = onStatus + } + + @discardableResult + public func send(_ frame: Bytes) -> Bool { active.send(frame) } + + public func beginSession() { + framesThisSession = 0 + active.beginSession() + } + + /// The tunnel dials afresh, so it has to carry again within the wait, + /// or a dial that hangs would keep the relay away until it times out. + public func wake() { + if !onRelay, !closed { + carrying = false + framesThisSession = 0 + startWait() + } + active.wake() + } + + public func close(reason: String = "closed by client") { + guard !closed else { return } + closed = true + wait?.cancel() + active.close(reason: reason) + status = .closed(reason: reason, retryable: false) + onFrame = { _ in } + onStatus = { _ in } + } + + // MARK: Inside + + private func tunnelFrame(_ bytes: Bytes) { + framesThisSession += 1 + if !carrying, framesThisSession >= 2 { + carrying = true + wait?.cancel() + wait = nil + } + onFrame(bytes) + } + + private func tunnelStatus(_ s: CarrierStatus) { + guard !closed, !onRelay else { return } + if case .closed(let reason, let retryable) = s { + if !retryable || !carrying { + toRelay(reason) + return + } + carrying = false + startWait() + } + setStatus(s) + } + + private func startWait() { + wait?.cancel() + wait = scheduler.after(Self.tunnelWaitMs) { [weak self] in + guard let self, !self.carrying else { return } + self.toRelay("no answer through FTW Cloud") + } + } + + private func toRelay(_ why: String) { + guard !closed, !onRelay, let makeRelay else { + if !closed, makeRelay == nil { setStatus(active.status) } + return + } + // Said in the log, so a phone on the fallback can be found. + carrierLog.notice("the relay took over: \(why, privacy: .public)") + onRelay = true + wait?.cancel() + wait = nil + let tunnel = active + tunnel.setHandlers(onFrame: { _ in }, onStatus: { _ in }) + tunnel.close(reason: "the relay took over") + let relay = makeRelay() + active = relay + relay.setHandlers( + onFrame: { [weak self] bytes in self?.onFrame(bytes) }, + onStatus: { [weak self] s in self?.setStatus(s) } + ) + // Whatever the tunnel began is gone; the session starts again on the + // relay once it opens. + setStatus(.closed(reason: "moving to the relay", retryable: true)) + setStatus(relay.status) + } + + private func setStatus(_ next: CarrierStatus) { + guard !closed, next != status else { return } + status = next + onStatus(next) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift index b2c068f1..7878eab9 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/NoiseCarrier.swift @@ -158,7 +158,9 @@ public final class NoiseCarrier: Carrier { } do { - inner.send(try hs.writeMessage(payload)) + let message1 = try hs.writeMessage(payload) + inner.beginSession() + inner.send(message1) } catch { fail("handshake failed", retryable: true) } diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift new file mode 100644 index 00000000..f882cd10 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift @@ -0,0 +1,218 @@ +import Foundation +import FTWShared + +/// The phone's own session with its box, carried by FTW Cloud. +/// +/// Each frame rides the app session to FTW Cloud as `tun {sid, d}`, and FTW +/// Cloud hands it to the box under the device its own handshake proved. `d` +/// is the box session byte for byte, Noise to the box's key, so FTW Cloud +/// can drop or delay a frame and can neither read nor forge one. Lane 0 +/// keeps its padding and cadence inside it. +/// +/// One socket instead of two: the box session's message 1 waits for the app +/// session's message 1 and rides inside it, so the box session is up one +/// round trip after TLS. +/// +/// srcfl/ftw `docs/protocol-cloud.md`, "Tunnel", is the normative text. +@MainActor +public final class TunnelCarrier: Carrier { + static let backoffBaseMs: Double = 500 + static let backoffCapMs: Double = 60_000 + /// A session that carried this long before it ended starts the backoff + /// over. A shorter one does not, so two sessions of one device cannot + /// take the box's slot from each other in a loop. + static let settledMs: Double = 30_000 + + public let kind: CarrierKind = .tunnel + public private(set) var status: CarrierStatus = .connecting + + private let cloud: CloudCarrier + private let scheduler: Scheduler + /// This session's number on the wire. A new one for every handshake. + private(set) var sid: UInt32 = 0 + /// The session's message 1, while the app session cannot carry it yet. + private var first: Bytes? + /// The box or the service ended the session; a new one starts after + /// this backoff. + private var ended = false + private var attempt = 0 + /// Frames from the box in this session: message 2, then transport. + private var framesThisSession = 0 + /// When the box's first transport frame of this session arrived: the + /// box claims the device's slot on the phone's first transport frame, + /// so only its answer to that shows the session is this phone's. + private var carryingSinceMs: Double? + private var retry: Cancellable? + private var shutdown = false + + private var onFrame: @MainActor (Bytes) -> Void = { _ in } + private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + + /// `cloud` is the home's FTW Cloud session. It stays the site model's: + /// closing this carrier ends the box session and leaves the app session + /// up for the readable copy. + public init(cloud: CloudCarrier, scheduler: Scheduler) { + self.cloud = cloud + self.scheduler = scheduler + cloud.tunnel = self + // A session waiting out a backoff dials now: someone wants the box. + if case .closed(_, true) = cloud.status { cloud.wake() } + status = follow(cloud.status) + } + + public func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { + self.onFrame = onFrame + self.onStatus = onStatus + } + + public func beginSession() { + sid = UInt32.random(in: 1...UInt32.max) + first = nil + framesThisSession = 0 + carryingSinceMs = nil + } + + @discardableResult + public func send(_ frame: Bytes) -> Bool { + guard !shutdown, status.isOpen, frame.count <= CloudWire.tunDataMax else { return false } + if cloud.status.isOpen { + return cloud.sendTun(CloudWire.Tun(sid: sid, d: frame)) + } + // Before the app session's message 2 only message 1 can wait, and + // only one: it rides message 1, or goes the moment message 2 is in. + // Nothing else of a session exists before its handshake answers. + guard first == nil else { return false } + first = frame + return true + } + + public func wake() { + guard !shutdown else { return } + retry?.cancel() + retry = nil + attempt = 0 + ended = false + cloud.redial() + setStatus(follow(cloud.status)) + } + + /// Ends the session at the box and leaves the app session to the site + /// model. + public func close(reason: String = "closed by client") { + guard !shutdown else { return } + shutdown = true + retry?.cancel() + if first == nil, sid != 0, cloud.status.isOpen { cloud.sendTun(CloudWire.Tun(sid: sid, d: [])) } + if cloud.tunnel === self { cloud.tunnel = nil } + first = nil + setStatus(.closed(reason: reason, retryable: false)) + onFrame = { _ in } + onStatus = { _ in } + } + + // MARK: From the app session + + /// Message 1 of the app session goes now: the box session's message 1 + /// rides inside it, if one waits. + func takeFirst() -> CloudWire.Tun? { + guard let d = first else { return nil } + first = nil + return CloudWire.Tun(sid: sid, d: d) + } + + func receive(_ tun: CloudWire.Tun) { + // The late end or answer of an older session. + guard !shutdown, tun.sid == sid, sid != 0 else { return } + guard !tun.ended else { + end() + return + } + framesThisSession += 1 + if framesThisSession == 2 { carryingSinceMs = scheduler.nowMs } + onFrame(tun.d) + } + + func cloudStatus(_ s: CloudCarrier.Status) { + guard !shutdown else { return } + switch s { + case .open: + // Message 1 went before the box session's was ready. + if let d = first { + first = nil + cloud.sendTun(CloudWire.Tun(sid: sid, d: d)) + } + case .closed: + // Every box session ends with the app session that carried it. + first = nil + case .connecting: + break + } + if case .closed(_, false) = s { + // Nothing to come back to. + retry?.cancel() + retry = nil + ended = false + } + if ended { return } + setStatus(follow(s)) + } + + // MARK: Inside + + /// An empty `d`: the box dropped the session, another session of this + /// device took its slot, the box does not hold it, or FTW Cloud had no + /// box to give it to. The phone cannot tell which. A new session starts + /// after a backoff that grows with each end and starts over only after + /// a session carried for `settledMs`, so neither an absent box nor a + /// second session of this device is asked in a loop. + private func end() { + first = nil + ended = true + if let since = carryingSinceMs, scheduler.nowMs - since >= Self.settledMs { attempt = 0 } + carryingSinceMs = nil + setStatus(.closed(reason: Self.endedReason, retryable: true)) + let ceiling = min(Self.backoffCapMs, Self.backoffBaseMs * pow(2, Double(attempt))) + attempt = min(attempt + 1, 16) + retry?.cancel() + retry = scheduler.after(scheduler.random() * ceiling) { [weak self] in + guard let self, !self.shutdown else { return } + self.retry = nil + self.ended = false + self.setStatus(self.follow(self.cloud.status)) + } + } + + static let endedReason = "the box ended the session" + + /// Open while the app session dials or is up: message 1 can wait for + /// it. Closed while it waits out a backoff or has ended. + private func follow(_ s: CloudCarrier.Status) -> CarrierStatus { + switch s { + case .connecting, .open: + return .open(sinceMs: scheduler.nowMs) + case .closed(let code, let retrying): + return .closed(reason: Self.reason(code), retryable: retrying) + } + } + + nonisolated static func reason(_ code: Int) -> String { + switch code { + case CloudWire.closeDenied: return "FTW Cloud refused the session" + case CloudWire.closeReplaced: return "another session took over" + case CloudCarrier.closeNormal: return "FTW Cloud session closed" + default: return "FTW Cloud connection lost" + } + } + + private func setStatus(_ next: CarrierStatus) { + switch (status, next) { + case (.connecting, .connecting), (.open, .open): + return + case let (.closed(a, x), .closed(b, y)) where a == b && x == y: + return + default: + status = next + onStatus(next) + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift index 6bae79ec..20ef0f38 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift @@ -101,4 +101,9 @@ public struct CloudIdentity: Codable, Equatable, Sendable { func sameHome(_ other: CloudIdentity?) -> Bool { other?.pk == pk } + + /// Whether the owner has the readable copy on. The tables are cut with + /// the grant's key, so they are empty while it is off + /// (docs/protocol-cloud.md, Session information). + var copyOn: Bool { !sources.isEmpty || !loadpoints.isEmpty } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift index 5d03d9a2..73427f96 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Enrollment.swift @@ -3,6 +3,12 @@ import Foundation /// The pairing payload. /// /// https://app.ftw.energy/p#v2.... +/// https://app.ftw.energy/p#v3..... +/// +/// v3 is v2 with the home id at FTW Cloud, base64url of its 26 characters, +/// so a new phone can reach the box through FTW Cloud before any session +/// with it (srcfl/ftw `docs/protocol-cloud.md`, "Pairing through the +/// service"). Both are read. /// /// Everything after `#` is a fragment and never sent in a request, so the /// trust anchor reaches the phone optically and reaches no server on the way. @@ -15,8 +21,12 @@ public struct Enrollment: Equatable, Sendable { public var lanHint: String /// The long-lived secret the rotating relay handle is derived from. public var rendezvousSecret: Bytes + /// The home id at FTW Cloud. Nil from a v2 code. + public var home: String? - public static let version = "v2" + /// The newest version this app reads; it reads `v2` too. + public static let version = "v3" + public static let versions: Set = ["v2", "v3"] public static let host = Origin.appHost public static let path = "/p" public static let boxKeyBytes = 32 @@ -24,11 +34,12 @@ public struct Enrollment: Equatable, Sendable { public static let rendezvousSecretBytes = 32 public static let maxLanHintChars = 64 - public init(boxStaticPublic: Bytes, pairingCode: Bytes, lanHint: String, rendezvousSecret: Bytes) { + public init(boxStaticPublic: Bytes, pairingCode: Bytes, lanHint: String, rendezvousSecret: Bytes, home: String? = nil) { self.boxStaticPublic = boxStaticPublic self.pairingCode = pairingCode self.lanHint = lanHint self.rendezvousSecret = rendezvousSecret + self.home = home } } @@ -92,15 +103,16 @@ extension Enrollment { // Version before shape, and which side is behind decides the // sentence. Telling someone to update the wrong thing is worse than // saying nothing. - if v != version { + if !versions.contains(v) { if v.count > 1, v.hasPrefix("v"), let n = Int(v.dropFirst()), v.dropFirst().allSatisfy(\.isNumber) { let ours = Int(version.dropFirst())! throw EnrollmentError(code: "E_QR_VERSION", message: "payload version \(v)", help: n < ours ? EnrollmentError.boxTooOld : EnrollmentError.appTooOld) } throw EnrollmentError(code: "E_QR_NOT_FTW", message: "fragment does not start with a version", help: EnrollmentError.wrongCode) } - guard parts.count == 5 else { - throw EnrollmentError(code: "E_QR_SHAPE", message: "\(parts.count) segments, expected 5", help: EnrollmentError.scanAgain) + let expected = v == "v2" ? 5 : 6 + guard parts.count == expected else { + throw EnrollmentError(code: "E_QR_SHAPE", message: "\(parts.count) segments, expected \(expected)", help: EnrollmentError.scanAgain) } let box = try segment(parts[1]) @@ -125,19 +137,30 @@ extension Enrollment { guard secret.count == rendezvousSecretBytes else { throw EnrollmentError(code: "E_QR_SECRET", message: "rendezvous secret is \(secret.count) bytes", help: EnrollmentError.scanAgain) } - return Enrollment(boxStaticPublic: box, pairingCode: code, lanHint: hint, rendezvousSecret: secret) + var home: String? + if parts.count == 6 { + // Where the phone dials FTW Cloud, so only a home id the + // service could have cut is taken. + guard let named = String(bytes: try segment(parts[5]), encoding: .utf8), CloudWire.validHome(named) else { + throw EnrollmentError(code: "E_QR_HOME", message: "home is not a home id", help: EnrollmentError.scanAgain) + } + home = named + } + return Enrollment(boxStaticPublic: box, pairingCode: code, lanHint: hint, rendezvousSecret: secret, home: home) } - /// The inverse, for tests and the loopback box. + /// The inverse, for tests and the loopback box: v3 when it names a + /// home, else v2. public func url() -> String { - let fragment = [ - Enrollment.version, + var segments = [ + home == nil ? "v2" : "v3", Base64url.encode(boxStaticPublic), Base64url.encode(pairingCode), Base64url.encode(Array(lanHint.utf8)), Base64url.encode(rendezvousSecret), - ].joined(separator: ".") - return "https://\(Enrollment.host)\(Enrollment.path)#\(fragment)" + ] + if let home { segments.append(Base64url.encode(Array(home.utf8))) } + return "https://\(Enrollment.host)\(Enrollment.path)#\(segments.joined(separator: "."))" } private static func segment(_ s: String) throws -> Bytes { diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift index eed537d6..e28901c6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/Pairing.swift @@ -44,6 +44,7 @@ public final class Pairing { rendezvousSecret: Data(enrollment.rendezvousSecret), pairingCode: Data(enrollment.pairingCode), lanHint: enrollment.lanHint.isEmpty ? nil : enrollment.lanHint, + cloudHome: enrollment.home ?? sites.get(siteId)?.cloudHome, addedAtMs: at, lastSeenAtMs: at ) diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index 2a7b14d6..06966d22 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -47,7 +47,7 @@ public enum Contract { "cmd.lease", "cmd.precondition", "cmd.readback", "der.battery", "der.ev", "der.v2x", "plan.dispatch", "net.webrtc", "price.spot", "api.passthrough", - "cloud.link", "cmd.exchange", + "cloud.link", "cloud.tun", "cmd.exchange", ] public static let capHistory5m = "history.5m" @@ -58,6 +58,9 @@ public enum Contract { /// The box can run the FTW Cloud uplink. Absent, the app offers no FTW /// Cloud and reads through the relay alone. public static let capCloudLink = "cloud.link" + /// The box takes its app session through FTW Cloud's tunnel. Absent, + /// the phone reaches it through the relay, home id or not. + public static let capCloudTun = "cloud.tun" /// The box can take a watch. Absent, the phone offers no Add watch. public static let capCmdExchange = "cmd.exchange" @@ -146,5 +149,5 @@ public enum Contract { } public static let sourceStates = ["live", "lagging", "stale", "down", "never"] - public static let carrierStates = ["webrtc", "relay", "cloud", "cache", "none"] + public static let carrierStates = ["webrtc", "relay", "tunnel", "cloud", "cache", "none"] } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index 8a6d727f..ff26698f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -86,6 +86,7 @@ public final class AppModel { @ObservationIgnored let build: String @ObservationIgnored let ua: String @ObservationIgnored let relayURL: URL + @ObservationIgnored let cloudEndpoint: CloudEndpoint? @ObservationIgnored let makeSocket: WebSocketFactory @ObservationIgnored private var connectGeneration = 0 /// `suspend` closed the session; the next look builds a new one. @@ -101,6 +102,7 @@ public final class AppModel { build: String, ua: String, relayURL: URL = Origin.relayURL, + cloudEndpoint: CloudEndpoint? = CloudEndpoint.current, makeSocket: @escaping WebSocketFactory = URLSessionWebSocket.factory(), demo: DemoCarrier? = nil ) { @@ -114,6 +116,7 @@ public final class AppModel { self.build = build self.ua = ua self.relayURL = relayURL + self.cloudEndpoint = cloudEndpoint self.makeSocket = makeSocket vault = Vault(store: store) sites = SiteList(store: store) @@ -139,6 +142,9 @@ public final class AppModel { let site = SiteModel(siteId: siteId, build: build, ua: ua, scheduler: scheduler, files: files, shared: shared) site.stepUp = { [weak self] in await self?.stepUp() ?? .unavailable } site.sharedWritten = sharedWritten + site.learnedHome = { [weak self] home in + self?.sites.update(siteId) { $0.cloudHome = home } + } home = HomeModels(site: site, cache: shared.map { CacheReader(sources: [$0]) }) sites.setCurrent(siteId) site.start() @@ -165,9 +171,12 @@ public final class AppModel { guard vault.isEnrolled else { throw ConnectError(kind: .notEnrolled, help: AppWords.text("This device has no key for that home.")) } - // Read from the QR, never derived: a handle derived from the box - // key would be one household identifier good for years. - guard let secret = stored.rendezvousSecret?.byteArray else { + // The rendezvous secret is read from the QR, never derived: a + // handle derived from the box key would be one household + // identifier good for years. A home id reaches the box through + // FTW Cloud instead. + let secret = stored.rendezvousSecret?.byteArray + guard secret != nil || stored.cloudHome != nil else { throw ConnectError(kind: .stalePairing, help: AppWords.text("This home was paired before this app could reach it privately.")) } var wrapping = try vault.silentWrappingKey() @@ -180,17 +189,33 @@ public final class AppModel { guard mine == connectGeneration, home?.site === site else { return } let box = stored.boxStaticKey.byteArray - let relay = RelayCarrier(url: relayURL, secret: secret, scheduler: scheduler, makeSocket: makeSocket) + let code = stored.pairingCode?.byteArray + // FTW Cloud first: the box session rides it when the box takes + // the tunnel. + site.connectCloud(deviceKey: device, makeSocket: makeSocket, endpoint: cloudEndpoint, home: stored.cloudHome, pairingCode: code) + var relay: (@MainActor () -> Carrier)? + if let secret { + relay = { [relayURL, scheduler, makeSocket] in + RelayCarrier(url: relayURL, secret: secret, scheduler: scheduler, makeSocket: makeSocket) + } + } + let inner: Carrier + if let cloud = site.tunnelCloud() { + inner = FallbackCarrier(tunnel: TunnelCarrier(cloud: cloud, scheduler: scheduler), relay: relay, scheduler: scheduler) + } else if let relay { + inner = relay() + } else { + throw ConnectError(kind: .stalePairing, help: AppWords.text("This home was paired before this app could reach it privately.")) + } let noise = NoiseCarrier( - inner: relay, + inner: inner, staticKey: device, remoteStatic: box, prologue: NoiseCarrier.prologue(boxStaticKey: box), - handshakePayload: stored.pairingCode?.byteArray ?? [], + handshakePayload: code ?? [], scheduler: scheduler ) if site.connect(noise) { connectHelp = nil } - site.connectCloud(deviceKey: device, makeSocket: makeSocket) } catch is PasskeyCancelled { // A closed sheet is not a lost key: say what happened and let // the person try again. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift index 9dbd1e9b..2c7482d5 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift @@ -107,7 +107,7 @@ public struct NodeFreshness: Equatable, Sendable { } public var live: Bool { - (carrier == .relay || carrier == .webrtc || carrier == .cloud) && srcState == .live + carrier.connected && srcState == .live } /// From the session as it stands: the field's source in the box's diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift index 8fcead77..1bbecf6c 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeLive.swift @@ -19,6 +19,7 @@ public enum NodeLive { let via: String switch carrier { case .relay: via = NodeWords.text("VIA RELAY") + case .tunnel: via = NodeWords.text("ENCRYPTED VIA FTW CLOUD") case .webrtc: via = NodeWords.text("VIA DIRECT LINK") case .cloud: via = NodeWords.text("VIA FTW CLOUD") case .cache: via = NodeWords.text("SAVED ON THIS PHONE") diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index faeb626b..947545bf 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -52,6 +52,7 @@ public enum NodeWords { "VIA RELAY": "VIA RELÄ", "VIA DIRECT LINK": "VIA DIREKTLÄNK", "VIA FTW CLOUD": "VIA FTW CLOUD", + "ENCRYPTED VIA FTW CLOUD": "KRYPTERAT VIA FTW CLOUD", "SAVED ON THIS PHONE": "SPARAD PÅ TELEFONEN", "NOT CONNECTED": "INTE ANSLUTEN", // The grid's phases against the main fuse. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 352b9891..a491f29e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -148,9 +148,19 @@ public final class SiteModel { var cloudView: CloudView? } - // FTW Cloud. All nil while the home has no grant or this build no - // endpoint. + // FTW Cloud. All nil while this phone knows no home id for the box or + // this build has no endpoint. @ObservationIgnored private var cloud: CloudCarrier? + /// The home the running session dials. + @ObservationIgnored private var cloudHome: String? + /// The home id as the phone keeps it with the home, from the QR or an + /// earlier hello. The box's word at hello wins. + @ObservationIgnored private var storedHome: String? + /// The pairing proof for message 1, while the phone holds a code. + @ObservationIgnored private var cloudPair: Bytes? + /// The box named its home id at hello and it was not the one kept. The + /// shell keeps it with the home, so the next start can dial it. + @ObservationIgnored public var learnedHome: (@MainActor (String) -> Void)? @ObservationIgnored private(set) var cloudView: CloudView? @ObservationIgnored private(set) var cloudIdentity: CloudIdentity? @ObservationIgnored private var cloudDevice: Primitives.KeyPair? @@ -367,7 +377,7 @@ public final class SiteModel { } // Every box message over Noise that names its uptime is a chance to // learn rule U, once hello has named the boot it belongs to. - if s.heardFromBox, let boot = s.cloud?.boot, s.carrier == .relay || s.carrier == .webrtc, s.uptimeMs != previous.uptimeMs || !previous.heardFromBox { + if s.heardFromBox, let boot = s.cloud?.boot, [.relay, .tunnel, .webrtc].contains(s.carrier), s.uptimeMs != previous.uptimeMs || !previous.heardFromBox { learnRuleU(boot: boot, uptimeMs: s.uptimeMs, phoneMs: now) } if s.phase == .streaming { scheduleSnapshot() } @@ -1031,17 +1041,40 @@ struct CloudCache: Codable, Equatable, Sendable { } extension SiteModel { - /// Read the home from FTW Cloud beside the box session, with the same - /// device key. It starts once the box has said, now or at an earlier - /// launch, that FTW Cloud is on and how to check what it forwards. - public func connectCloud(deviceKey: Primitives.KeyPair, makeSocket: @escaping WebSocketFactory, endpoint: CloudEndpoint? = CloudEndpoint.current) { + /// Open the home's FTW Cloud session with the same device key, once a + /// home id is known: from the box at this or an earlier hello, or as the + /// phone keeps it (`home`). It carries the readable copy while the + /// owner has it on, and the box session when the box takes the tunnel. + /// + /// `pairingCode` makes the pairing proof a phone needs while the roster + /// does not list it: a new phone, or one paired again after it was + /// removed. FTW Cloud ignores the proof from a phone the roster lists. + public func connectCloud(deviceKey: Primitives.KeyPair, makeSocket: @escaping WebSocketFactory, endpoint: CloudEndpoint? = CloudEndpoint.current, home: String? = nil, pairingCode: Bytes? = nil) { guard !destroyed, !isDemo else { return } cloudDevice = deviceKey cloudSocket = makeSocket cloudEndpoint = endpoint + if let home, CloudWire.validHome(home) { storedHome = home } + cloudPair = nil + if let code = pairingCode, let home = storedHome { + cloudPair = CloudWire.pairingHash(home: home, code: code) + } startCloud() } + /// The box has not spoken to this phone, now or at an earlier launch. + var neverHeard: Bool { !core.state.heardFromBox && kept?.hello == nil } + + /// FTW Cloud's session for the box session to ride, when the box takes + /// the tunnel: it said `cloud.tun` at its last hello, or has never + /// spoken and the QR named its home. Nil sends the box session to the + /// relay. + public func tunnelCloud() -> CloudCarrier? { + guard let cloud, !destroyed else { return nil } + let takes = neverHeard ? storedHome != nil : core.state.caps.contains(Contract.capCloudTun) + return takes ? cloud : nil + } + /// An owner pauses FTW Cloud for one of the home's phones, or lifts the /// pause. It only narrows what the box allows. @discardableResult @@ -1049,13 +1082,15 @@ extension SiteModel { cloud?.suspend(deviceID: deviceID, on) ?? false } - /// Whether the box said FTW Cloud is on for this home. - public var cloudOn: Bool { cloudIdentity != nil } + /// Whether the box said the readable copy is on for this home. A linked + /// box names its home with the copy off too, for the tunnel; its tables + /// are empty then. + public var cloudOn: Bool { cloudIdentity?.copyOn ?? false } private func startCloud() { guard cloud == nil, !destroyed, let endpoint = cloudEndpoint, let device = cloudDevice, - let socket = cloudSocket, let identity = cloudIdentity else { return } - let c = CloudCarrier(endpoint: endpoint, home: identity.home, deviceKey: device, looking: documentVisible, scheduler: scheduler, makeSocket: socket) + let socket = cloudSocket, let home = storedHome ?? cloudIdentity?.home else { return } + let c = CloudCarrier(endpoint: endpoint, home: home, deviceKey: device, looking: documentVisible, pair: cloudPair, scheduler: scheduler, makeSocket: socket) c.setHandlers( onSnapshot: { [weak self] snap, at in self?.onCloudSnapshot(snap, at) }, onEnvelope: { [weak self] env, at in self?.onCloudEnvelope(env, at) }, @@ -1068,8 +1103,9 @@ extension SiteModel { } ) cloud = c + cloudHome = home cloudStatus = c.status - installPushSink() + if cloudOn { installPushSink() } c.start() } @@ -1077,13 +1113,16 @@ extension SiteModel { private func closeCloud() { cloud?.close() cloud = nil + cloudHome = nil cloudStatus = nil publish() } private func adoptCloud(_ identity: CloudIdentity, offset: CloudCache.Offset?) { if !identity.sameHome(cloudIdentity) { - closeCloud() + // A session dialled from the kept home id is this home's, and may + // carry the very session that brought this hello. + if cloudHome != identity.home { closeCloud() } cloudView = CloudView(home: identity.home, boxKey: identity.pk) ruleU = nil } @@ -1099,8 +1138,9 @@ extension SiteModel { private func cloudHello(_ identity: CloudIdentity?) { guard let identity else { // Off, or never on. What the cloud delivered goes with it, and - // so do its pushes. - closeCloud() + // so do its pushes. A session that carries the box session + // stays: it is how this hello arrived. + if cloud?.tunnel == nil { closeCloud() } if let home = cloudIdentity?.home ?? sharedCloudHome?.home { cloudKeys?.remove(home: home) } sharedCloudHome = nil uninstallPushSink() @@ -1113,10 +1153,17 @@ extension SiteModel { return } adoptCloud(identity, offset: nil) + if identity.home != storedHome { + storedHome = identity.home + learnedHome?(identity.home) + } shareCloudHome() cloudDirty = true persistNow() startCloud() + // Alerts come with the readable copy, and only then is the person + // asked to allow them. + if cloudOn, cloud != nil { installPushSink() } else if !cloudOn { uninstallPushSink() } } // MARK: Pushes diff --git a/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift index b1b3258d..a3614452 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/WatchShare.swift @@ -55,7 +55,7 @@ public enum WatchShare { switch origin { case .cache: return .cache case .cloud: return .cloud - case .relay: return site.carrier == .webrtc ? .webrtc : .relay + case .relay: return site.carrier == .webrtc || site.carrier == .tunnel ? site.carrier : .relay } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift index 3a615ec2..86454cb0 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Store/Sites.swift @@ -12,18 +12,23 @@ public struct StoredSite: Codable, Equatable, Sendable, Identifiable { /// Single use, spent in the first handshake. Kept until then. public var pairingCode: Data? public var lanHint: String? + /// The home id at FTW Cloud, where the box session is reached without + /// the relay. From a v3 QR, or from the box at its first hello. Nil for + /// a box that has not linked. + public var cloudHome: String? public var addedAtMs: Double public var lastSeenAtMs: Double public var id: String { siteId } - public init(siteId: String, label: String, boxStaticKey: Data, rendezvousSecret: Data?, pairingCode: Data?, lanHint: String?, addedAtMs: Double, lastSeenAtMs: Double) { + public init(siteId: String, label: String, boxStaticKey: Data, rendezvousSecret: Data?, pairingCode: Data?, lanHint: String?, cloudHome: String? = nil, addedAtMs: Double, lastSeenAtMs: Double) { self.siteId = siteId self.label = label self.boxStaticKey = boxStaticKey self.rendezvousSecret = rendezvousSecret self.pairingCode = pairingCode self.lanHint = lanHint + self.cloudHome = cloudHome self.addedAtMs = addedAtMs self.lastSeenAtMs = lastSeenAtMs } diff --git a/appleApp/FTWKit/Sources/FTWShared/Freshness.swift b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift index 2d97aebd..0f87f254 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Freshness.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Freshness.swift @@ -38,7 +38,7 @@ public enum Freshness { lastReading: String? = nil ) -> Band { let reaching = !noCarrier && [.idle, .handshaking, .subscribing, .failed, .booting].contains(phase) - let connected = carrier == .relay || carrier == .webrtc || carrier == .cloud + let connected = carrier.connected // The relay turned this phone away because the home already has as // many phones watching as it serves. Not a fault, and not ours to fix. let turnedAway = busy && !connected && reaching @@ -58,6 +58,14 @@ public enum Freshness { case .down: message = Words.text("FTW Cloud connected · a device stopped responding") default: message = ageMs == nil ? Words.text("FTW Cloud · age unverified") : Words.text("FTW Cloud connected · readings") } + } else if connected, carrier == .tunnel { + // The box's own session, end to end; FTW Cloud only carries it. + switch srcState { + case .live: message = Words.text("Live, encrypted via FTW Cloud") + case .never: message = Words.text("Encrypted via FTW Cloud · no reading yet") + case .down: message = Words.text("Encrypted via FTW Cloud · a device stopped responding") + default: message = Words.text("Encrypted via FTW Cloud · readings") + } } else if connected { let webrtc = carrier == .webrtc switch srcState { @@ -82,6 +90,8 @@ public enum Freshness { message = Words.text("Waiting for live readings") } else if phase == .handshaking, transport == .relay { message = Words.text("Securing encrypted relay") + } else if phase == .handshaking, transport == .tunnel { + message = Words.text("Securing link via FTW Cloud") } else if phase == .handshaking, transport == .webrtc { message = Words.text("Securing home link") } else { diff --git a/appleApp/FTWKit/Sources/FTWShared/Readings.swift b/appleApp/FTWKit/Sources/FTWShared/Readings.swift index e97af84a..ee2e7f38 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Readings.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Readings.swift @@ -11,8 +11,18 @@ import Foundation /// with it: "connected, but the inverter went quiet 40 seconds ago" needs /// both. public enum CarrierKind: String, Sendable, Codable { - /// `cloud` is data read through FTW Cloud, signed by the box. - case webrtc, relay, cloud, cache, none + /// `tunnel` is the box's own session carried end to end by FTW Cloud, + /// which cannot read it; `cloud` is the box-signed copy FTW Cloud + /// holds and can read. + case webrtc, relay, tunnel, cloud, cache, none + + /// Readings arrive now, from the box's session or FTW Cloud's copy. + public var connected: Bool { + switch self { + case .webrtc, .relay, .tunnel, .cloud: return true + case .cache, .none: return false + } + } } public enum SourceState: String, Sendable, Codable, Comparable { diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index aff88044..0d0cea1e 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -14,12 +14,17 @@ "FTW Cloud connected · a device stopped responding" = "FTW Cloud anslutet · en enhet svarar inte"; "FTW Cloud connected · readings" = "FTW Cloud anslutet · mätvärden"; "FTW Cloud · age unverified" = "FTW Cloud · åldern obekräftad"; +"Live, encrypted via FTW Cloud" = "Live, krypterat via FTW Cloud"; +"Encrypted via FTW Cloud · no reading yet" = "Krypterat via FTW Cloud · inget mätvärde än"; +"Encrypted via FTW Cloud · a device stopped responding" = "Krypterat via FTW Cloud · en enhet svarar inte"; +"Encrypted via FTW Cloud · readings" = "Krypterat via FTW Cloud · mätvärden"; "Access ended" = "Åtkomsten är avslutad"; "FTW is busy" = "FTW är upptaget"; "Your box is starting" = "Din box startar"; "Reconnecting to your box" = "Ansluter till din box igen"; "Waiting for live readings" = "Väntar på mätvärden"; "Securing encrypted relay" = "Krypterar anslutningen"; +"Securing link via FTW Cloud" = "Krypterar anslutningen via FTW Cloud"; "Securing home link" = "Krypterar hemlänken"; "Connecting to your box" = "Ansluter till din box"; "Can't reach your box" = "Når inte din box"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift index cf769c6d..58cbd00a 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Watch/WatchGlance.swift @@ -23,7 +23,7 @@ public enum WatchGlance { /// context or the cache, which never glows. public static func lit(_ record: CacheRecord?, arrivedAtMs: Double?, nowMs: Double) -> Bool { guard let record, let arrivedAtMs else { return false } - guard [.relay, .webrtc, .cloud].contains(record.carrier), record.srcState == .live else { return false } + guard record.carrier.connected, record.srcState == .live else { return false } return nowMs - arrivedAtMs >= -1_000 && nowMs - arrivedAtMs <= liveForMs } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift index 5efabea3..174a794b 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift @@ -16,8 +16,21 @@ final class FakeCloudService { /// Refuse every handshake made with this key, as a service that has /// moved to another key would. var refuseKey: Bytes? + /// The box behind the service, for the tunnel. Nil: no box takes `tun`, + /// and the service ends each box session a phone starts. + var tunBox: FakeTunnelBox? + /// The roster lists the phone without `tun`, as an older box's does: + /// the service passes nothing on and says nothing. + var silentTun = false + /// Pairing hashes the box lists while its QR shows. Each admits one + /// session. + var pending: Set = [] private(set) var dialled: [URL] = [] private(set) var nows: [Int] = [] + /// Message 1's `now` bodies, whole. + private(set) var nowBodies: [CBOR] = [] + /// Every `tun` a phone sent, from message 1 or the stream. + private(set) var tunsFromPhone: [CloudWire.Tun] = [] private(set) var received: [CloudWire.Envelope] = [] private var sessions: [FakeCloudSocket] = [] @@ -63,6 +76,7 @@ final class FakeCloudService { return } received.append(env) + if env.t == CloudWire.msgTun, let tun = try? CloudWire.Tun(body: env.b) { phoneTun(tun, on: socket) } return } let home = URLComponents(url: socket.url, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "home" }?.value ?? "" @@ -73,14 +87,35 @@ final class FakeCloudService { socket.closeFromService(CloudWire.closeDenied) return } - nows.append((try? decodeCBOR(now.b))?["hz"]?.int ?? -1) - guard let msg2 = try? hs.writeMessage(snapshotPayload()), let keys = try? hs.split(), - roster.contains(keys.remoteStatic) else { + let body = (try? decodeCBOR(now.b)) ?? .null + nowBodies.append(body) + nows.append(body["hz"]?.int ?? -1) + guard let msg2 = try? hs.writeMessage(snapshotPayload()), let keys = try? hs.split() else { + socket.closeFromService(CloudWire.closeDenied) + return + } + var admitted = roster.contains(keys.remoteStatic) + if !admitted, let pair = body["pair"]?.byteString, pending.remove(pair) != nil { admitted = true } + guard admitted else { socket.closeFromService(CloudWire.closeDenied) return } socket.transport = NoiseTransport(keys) + socket.device = keys.remoteStatic socket.deliver(msg2) + if let t = body["tun"], let tun = try? CloudWire.Tun(body: encodeCBOR(t)) { phoneTun(tun, on: socket) } + } + + /// One `tun` from a phone: to the box, or ended at once when there is + /// no box to take it. + private func phoneTun(_ tun: CloudWire.Tun, on socket: FakeCloudSocket) { + tunsFromPhone.append(tun) + if silentTun { return } + guard let box = tunBox else { + if !tun.ended { socket.tun(CloudWire.Tun(sid: tun.sid, d: [])) } + return + } + box.receive(tun, from: socket.device ?? []) { [weak socket] reply in socket?.tun(reply) } } func snapshotPayload(_ envelopes: [CloudWire.Envelope]? = nil) -> Bytes { @@ -107,6 +142,8 @@ final class FakeCloudSocket: WebSocketConnection { let events: WebSocketEvents var closed = false var transport: NoiseTransport? + /// The key message 1 proved. + var device: Bytes? init(service: FakeCloudService, url: URL, events: WebSocketEvents) { self.service = service @@ -134,6 +171,12 @@ final class FakeCloudSocket: WebSocketConnection { deliver(msg) } + /// One message of the phone's box session, from the box. + func tun(_ t: CloudWire.Tun) { + guard let env = try? CloudWire.Envelope.decode(CloudWire.encodeUnsigned(t: CloudWire.msgTun, body: t.body)) else { return } + push(env) + } + func closeFromService(_ code: Int) { _ = service.scheduler.after(service.latencyMs) { [weak self] in guard let self, !self.closed else { return } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudWireTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudWireTests.swift index 7fb48937..f55c5630 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudWireTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudWireTests.swift @@ -86,6 +86,18 @@ struct CloudAppVectors: Decodable { let handshakeHash: String let snapshot: [String] let transport: [Step] + let pairing: Pairing + + /// A new phone's proof: the code it scanned, the hash the roster lists + /// and `now.pair` carries, message 1 with and without the box session's + /// message 1 riding along, and the device id the service names it by. + struct Pairing: Decodable { + let code: String + let hash: String + let msg1Now: String + let msg1NowTun: String + let deviceId: String + } static func load() throws -> CloudAppVectors { guard let url = Bundle.module.url(forResource: "cloud-app-vectors", withExtension: "json", subdirectory: "Resources") else { @@ -106,7 +118,7 @@ func cloudSign(_ key: P256.Signing.PrivateKey, home: String, t: String, b: Bytes let v = try CloudVectors.load() let boxPK = Bytes(hex: v.boxKey.pk) let servicePK = Bytes(hex: v.serviceKey.pk) - #expect(v.messages.count == 13) + #expect(v.messages.count == 22) for m in v.messages { let frame = Bytes(hex: m.frame) @@ -259,10 +271,19 @@ func cloudSign(_ key: P256.Signing.PrivateKey, home: String, t: String, b: Bytes let transport = NoiseTransport(try handshake.split()) #expect(transport.handshakeHash.hex == v.handshakeHash) - #expect(v.transport.count == 4) + #expect(v.transport.count == 7) for step in v.transport { let frame = Bytes(hex: step.frame) - if step.from == "service" { + if step.from == "service", step.name.hasPrefix("tun") { + // The box session riding along: unsigned, opaque here. + #expect(try transport.decrypt(Bytes(hex: step.message)) == frame, "\(step.name)") + let env = try CloudWire.Envelope.decode(CloudWire.decodeFrame(frame)) + #expect(env.t == CloudWire.msgTun && env.sig.isEmpty, "\(step.name)") + let tun = try CloudWire.Tun(body: env.b) + #expect(tun.sid == 7, "\(step.name)") + #expect(tun.d == (step.name == "tun end" ? [] : Bytes(repeating: 0x99, count: 112)), "\(step.name)") + #expect(tun.ended == (step.name == "tun end"), "\(step.name)") + } else if step.from == "service" { #expect(try transport.decrypt(Bytes(hex: step.message)) == frame, "\(step.name)") let env = try CloudWire.Envelope.decode(CloudWire.decodeFrame(frame)) #expect(env == (try signed.envelope(step.name)), "\(step.name)") @@ -274,7 +295,39 @@ func cloudSign(_ key: P256.Signing.PrivateKey, home: String, t: String, b: Bytes // The phone's own frames are what encodeUnsigned writes. let away = try CloudWire.encodeFrame(CloudWire.encodeUnsigned(t: CloudWire.msgWatch, body: .map(["hz": .int(CloudWire.hzAway)]))) let suspend = try CloudWire.encodeFrame(CloudWire.encodeUnsigned(t: CloudWire.msgSuspend, body: .map(["deviceId": .text("Zf09xQ2a")]))) - #expect(v.transport.filter { $0.from == "phone" }.map(\.frame) == [away.hex, suspend.hex]) + let tun = try CloudWire.encodeFrame(CloudWire.encodeUnsigned(t: CloudWire.msgTun, body: CloudWire.Tun(sid: 7, d: Bytes(repeating: 0x9a, count: 48)).body)) + #expect(v.transport.filter { $0.from == "phone" }.map(\.frame) == [away.hex, suspend.hex, tun.hex]) + } + + /// A new phone proves the code it scanned without showing it, and its + /// box session's message 1 rides message 1: Go's bytes, exactly. + @Test func aNewPhonesMessage1MatchesGo() throws { + let v = try CloudAppVectors.load() + let code = Bytes(hex: v.pairing.code) + let hash = CloudWire.pairingHash(home: v.home, code: code) + #expect(hash.hex == v.pairing.hash) + #expect(CloudWire.now(hz: CloudWire.hzLooking, pair: hash).hex == v.pairing.msg1Now) + let tun = CloudWire.Tun(sid: 1, d: Bytes(repeating: 0x9b, count: 112)) + #expect(CloudWire.now(hz: CloudWire.hzLooking, pair: hash, tun: tun).hex == v.pairing.msg1NowTun) + #expect(CloudWire.now(hz: CloudWire.hzLooking).hex == v.msg1Payload) + #expect(CloudWire.deviceID(key: Bytes(hex: v.phonePublic)) == v.pairing.deviceId) + } + + /// The uplink's tun carries a device id the phone never sends; a phone + /// reading one ignores it, as Go ignores unknown keys. + @Test func tunBodiesReadAsGoWritesThem() throws { + let signed = try CloudVectors.load() + for name in ["tun down", "tun up", "tun end"] { + let tun = try CloudWire.Tun(body: signed.envelope(name).b) + #expect(tun.ended == (name == "tun end"), "\(name)") + #expect(tun.sid != 0, "\(name)") + } + #expect(throws: CloudWire.WireError.self) { try CloudWire.Tun(body: encodeCBOR(.map([("sid", .text("7")), ("d", .bytes([1]))]))) } + #expect(throws: CloudWire.WireError.self) { try CloudWire.Tun(body: encodeCBOR(.map([("sid", .unsigned(1 << 32)), ("d", .bytes([1]))]))) } + #expect(CloudWire.validHome(signed.home)) + for bad in ["", String(signed.home.dropLast()), signed.home.uppercased(), signed.home.dropLast() + "1", signed.home + "a"] { + #expect(!CloudWire.validHome(String(bad)), "\(bad)") + } } @Test func framesRefuseWhatThisProtocolNeverSends() throws { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift index 74c5c22a..80725485 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift @@ -59,7 +59,7 @@ import FTWDemo /// FTWShared spells the same states as enums, for widgets that never /// link FTWKit. @Test func sharedStatesAreTheRegistrys() { - #expect([CarrierKind.webrtc, .relay, .cloud, .cache, .none].map(\.rawValue) == Contract.carrierStates) + #expect([CarrierKind.webrtc, .relay, .tunnel, .cloud, .cache, .none].map(\.rawValue) == Contract.carrierStates) #expect([SourceState.live, .lagging, .stale, .down, .never].map(\.rawValue) == Contract.sourceStates) #expect(Contract.sourceStates.allSatisfy { SourceState(rawValue: $0) != nil }) #expect(Contract.carrierStates.allSatisfy { CarrierKind(rawValue: $0) != nil }) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift index bb39c646..6abec6c5 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/IdentityTests.swift @@ -126,13 +126,48 @@ struct IdentityVectors: Decodable { #expect(e.help.contains("box needs a software update")) } catch { Issue.record("\(error)") } do { - _ = try Enrollment.parse(scanned: url.replacingOccurrences(of: "#v2.", with: "#v3.")) - Issue.record("v3 parsed") + _ = try Enrollment.parse(scanned: url.replacingOccurrences(of: "#v2.", with: "#v4.")) + Issue.record("v4 parsed") } catch let e as EnrollmentError { #expect(e.help.contains("newer version of the app")) } catch { Issue.record("\(error)") } } + /// v3 is v2 and the home id at FTW Cloud: the same code the box's + /// `appenroll` makes, the web app's and Android's parsers read. + static let v3 = "https://app.ftw.energy/p#v3.AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE.AgICAgICAgICAgICAgICAg.MTAuMC4wLjI6ODA4MA.AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM.Zmp6eXI2N2J1enB5bmNxbWpsaXdnZXp2ZGU" + + @Test func readsAV3CodeAndTheHomeItNames() throws { + let e = try Enrollment.parse(scanned: Self.v3) + var expected = good + expected.home = "fjzyr67buzpyncqmjliwgezvde" + #expect(e == expected) + #expect(e.url() == Self.v3) + // v2 stays as it was, with no home. + #expect(try Enrollment.parse(scanned: good.url()).home == nil) + #expect(good.url().contains("#v2.")) + } + + @Test func aV3CodeMustNameAHome() throws { + let segments = Self.v3.split(separator: ".").map(String.init) + let cases: [(String, String)] = [ + (segments.dropLast().joined(separator: "."), "E_QR_SHAPE"), + (Self.v3 + ".AA", "E_QR_SHAPE"), + (good.url() + "." + segments.last!, "E_QR_SHAPE"), + ((segments.dropLast() + [Base64url.encode(Array("NOT-A-HOME-AT-ALL-12345678".utf8))]).joined(separator: "."), "E_QR_HOME"), + ((segments.dropLast() + [Base64url.encode(Array("fjzyr67buzpyncqmjliwgezvd".utf8))]).joined(separator: "."), "E_QR_HOME"), + ((segments.dropLast() + ["!!"]).joined(separator: "."), "E_QR_ENCODING"), + ] + for (url, code) in cases { + do { + _ = try Enrollment.parse(scanned: url) + Issue.record("parsed \(url)") + } catch let e as EnrollmentError { + #expect(e.code == code, "\(url)") + } catch { Issue.record("\(error)") } + } + } + @Test func refusesNonCanonicalBase64AndWrongLengths() { var short = good short.rendezvousSecret = Bytes(repeating: 3, count: 16) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveTunnelTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveTunnelTests.swift new file mode 100644 index 00000000..347c30c6 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveTunnelTests.swift @@ -0,0 +1,103 @@ +import Foundation +import Security +import Testing +@testable import FTWKit + +/// The whole app against a real `ftwcloudd` and a real box, with the relay +/// unreachable. Off unless the environment names them: +/// +/// FTW_LIVE_TUNNEL_DIR=/path pair.url (a v3 link the box wrote), +/// noise.pub and ca.pem +/// FTW_LIVE_CLOUD_URL=wss://127.0.0.1:18743/v1/app +/// +/// It pairs from the v3 link through FTW Cloud, streams, comes back from +/// the background, and times commands from the tap to the box's ack. +@MainActor +@Suite(.enabled(if: ProcessInfo.processInfo.environment["FTW_LIVE_TUNNEL_DIR"] != nil), .serialized) +struct LiveTunnelTests { + let env = ProcessInfo.processInfo.environment + var dir: URL { URL(fileURLWithPath: env["FTW_LIVE_TUNNEL_DIR"]!) } + + func read(_ name: String) throws -> String { + try String(contentsOf: dir.appendingPathComponent(name), encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + } + + @Test func aPhonePairsStreamsAndCommandsThroughFTWCloudWithTheRelayUnreachable() async throws { + let pem = try read("ca.pem") + let der = try #require(Data(base64Encoded: pem.split(separator: "\n").filter { !$0.hasPrefix("-----") }.joined())) + let anchor = try #require(SecCertificateCreateWithData(nil, der as CFData)) + let endpoint = CloudEndpoint(url: URL(string: env["FTW_LIVE_CLOUD_URL"] ?? "wss://127.0.0.1:18743/v1/app")!, + serviceKeys: [try Base64url.decode(read("noise.pub"))]) + // Nothing listens on port 9: the relay is unreachable. + let relayURL = URL(string: "wss://127.0.0.1:9")! + var relayDials = 0 + let sockets: WebSocketFactory = { url, events in + if url.host == relayURL.host, url.port == relayURL.port { relayDials += 1 } + return url.port == relayURL.port + ? URLSessionWebSocket.factory()(url, events) + : PinnedWebSocket(url: url, events: events, anchor: anchor) + } + let files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-live-tunnel-\(UUID().uuidString)"), store: MemoryStore()) + let app = AppModel(store: MemoryStore(), files: files, passkeys: FakePasskeys(), scheduler: LiveScheduler(), + build: "live-tunnel-test", ua: "test", relayURL: relayURL, cloudEndpoint: endpoint, makeSocket: sockets) + + let link = try read("pair.url") + #expect(link.contains("#v3.")) + await PairModel(app: app).pair(link) + let site = try #require(app.site) + let paired = try await until { site.session.phase == .streaming } + print("tunnel: scan to streaming \(ms(paired)) (pairing through FTW Cloud), carrier \(site.carrier), relay dials \(relayDials)") + #expect(site.carrier == .tunnel) + #expect(relayDials == 0) + #expect(site.session.caps.contains(Contract.capCloudTun)) + #expect(app.sites.get(try #require(site.siteId))?.cloudHome != nil) + + // Tap to the box's ack, and to its result, one command at a time. + var acks: [Double] = [], results: [Double] = [] + for i in 0..<50 { + try await Task.sleep(nanoseconds: 100_000_000) + let t0 = Date() + var ackAt: Date? + let result = try await site.core.command(Contract.opSetMode, args: [("mode", .text("self_consumption"))], + onAck: { ackAt = Date() }) + results.append(Date().timeIntervalSince(t0) * 1000) + if let ackAt { acks.append(ackAt.timeIntervalSince(t0) * 1000) } + #expect(result.state != .unconfirmed, "\(i)") + } + print("tunnel: tap to cmd.ack median \(q(acks, 0.5)) p95 \(q(acks, 0.95)) max \(q(acks, 1)) ms (n=\(acks.count))") + print("tunnel: tap to cmd.result median \(q(results, 0.5)) p95 \(q(results, 0.95)) max \(q(results, 1)) ms (n=\(results.count))") + #expect(acks.count == 50) + + // Back from the background, five times: the box said cloud.tun, so + // the tunnel again, with no proof and no relay. + var opens: [Double] = [] + for _ in 0..<5 { + app.suspend() + try await Task.sleep(nanoseconds: 300_000_000) + app.setVisible(true) + opens.append(try await until { site.session.phase == .streaming && site.carrier == .tunnel }) + } + print("tunnel: foreground to streaming \(opens.map { String(format: "%.0f", $0 * 1000) }.joined(separator: ", ")) ms; relay dials \(relayDials)") + #expect(relayDials == 0) + #expect(site.carrier == .tunnel) + try? "done".write(to: dir.appendingPathComponent("stop"), atomically: true, encoding: .utf8) + } + + /// Wait for `ok`, polling every 2 ms, and return how long it took. + func until(_ seconds: Double = 20, _ ok: () -> Bool) async throws -> Double { + let start = Date() + while !ok() { + if Date().timeIntervalSince(start) > seconds { throw CancellationError() } + try await Task.sleep(nanoseconds: 2_000_000) + } + return Date().timeIntervalSince(start) + } + + func ms(_ s: Double) -> String { String(format: "%.0f ms", s * 1000) } + + func q(_ values: [Double], _ p: Double) -> String { + guard !values.isEmpty else { return "-" } + let s = values.sorted() + return String(format: "%.1f", s[Int(Double(s.count - 1) * p)]) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-app-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-app-vectors.json index b49c1b36..feb481ec 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-app-vectors.json +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-app-vectors.json @@ -43,8 +43,33 @@ "name": "suspend", "frame": "010100000026a3616253a1686465766963654964685a6630397851326161746773757370656e646373696740", "message": "00000000000000018ef98c7df1fa903675ab1bf0e8aff5cd342be0cdc21b8ead3d04a153cf0fe6ce0d28f5fdf88e409e68eadc1827bfc61039294263085fc27f2b9b980b" + }, + { + "from": "phone", + "name": "tun", + "frame": "01010000004aa36162583aa2616458309a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a637369640761746374756e6373696740", + "message": "00000000000000022731255fc9340e3fc8fe0933b3cea6ee0a7bade6b3019c5e9b09696018bb2c57b1cc7cf32d3d556cc16ab7187ed6cba0c662469026d4c99a61f40e131cbd35f3b3d25ed22d445a9664f429271c8539fa0401884a10d87cdca3c10fd0e3273a2c" + }, + { + "from": "service", + "name": "tun", + "frame": "01010000008aa36162587aa26164587099999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999637369640761746374756e6373696740", + "message": "00000000000000026cf288f8e3930b62ce5f00bda38eb90f3dc6fe833042f9cd777b8369dbb658abdf10ea42bdac4e40bae9482ae4e95a43e64a0d34d9c6aa7afc59c2455e7c3f8d1b4b081b2a8353f22e6cf6be6340e10a9dd0b9af405ad53d46feb2d6985d8426b76a8c887ff6f9b25c16be90dd9ba24455db43b6f869a1d7a51866bd4e104e9faacce088903a41f9a3c4d95c6d4dd2db54cdbf607a569cf1956cce138dc461c9" + }, + { + "from": "service", + "name": "tun end", + "frame": "010100000018a3616249a2616440637369640761746374756e6373696740", + "message": "0000000000000003237e42f082e81124d36ef77e2467933a52c8c41f3ecf41b2247ee239a9dd9dfd72d74d129a742d70765cbaf6e61c" } ], + "pairing": { + "code": "88888888888888888888888888888888", + "hash": "b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa3563", + "msg1Now": "a36162582ca262687a0164706169725820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636174636e6f776373696740", + "msg1NowTun": "a3616258aaa362687a016374756ea2616458709b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b637369640164706169725820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636174636e6f776373696740", + "deviceId": "99CBpeiI" + }, "notes": { "keys": "Secrets are SHA-256 of the labels in app_vectors_test.go. X25519 clamps them.", "snapshot": "Message 2 holds the cloud-vectors.json envelopes named here, in this order.", diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-vectors.json index d578b866..7a9dd780 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-vectors.json +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-vectors.json @@ -66,11 +66,11 @@ "name": "roster", "signer": "box", "t": "roster", - "body": "a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104", - "signedInputSha256": "f0926ee704ac9558f742f604315ef8ea260a9edef63a210172d9c299d5b171cf", - "sig": "cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124", - "envelope": "a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124", - "frame": "010100000207a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124" + "body": "a9637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104", + "signedInputSha256": "54a45d8ab064d1d9fda8dfd3d43708dca9294daefe67078b15ab17ae1c3c086a", + "sig": "26de4f6b491496569361fce489167aa3a445c91235689a79f5499f5a65d4ed5e35ecd601251013d345f8171904bd5b508e0a05d543dc25f2335b2d7a743ad916", + "envelope": "a361625901c5a9637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104617466726f7374657263736967584026de4f6b491496569361fce489167aa3a445c91235689a79f5499f5a65d4ed5e35ecd601251013d345f8171904bd5b508e0a05d543dc25f2335b2d7a743ad916", + "frame": "01010000021aa361625901c5a9637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104617466726f7374657263736967584026de4f6b491496569361fce489167aa3a445c91235689a79f5499f5a65d4ed5e35ecd601251013d345f8171904bd5b508e0a05d543dc25f2335b2d7a743ad916" }, { "name": "live full", @@ -161,6 +161,96 @@ "sig": "910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", "envelope": "a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", "frame": "010100000086a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32" + }, + { + "name": "event connected", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a00011558", + "signedInputSha256": "6e13d5c342e20d31aef8530bd5d8d89b08540809278bd1aa93d2d156fe2b6d29", + "sig": "490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "envelope": "a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "frame": "0101000000f9a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23" + }, + { + "name": "event interrupted", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a00011940", + "signedInputSha256": "b29e1deef680d5b19f631a1ee29965bf70774e690afe1dbab5e7e25c4738ddbb", + "sig": "ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "envelope": "a3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "frame": "0101000000fea3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25" + }, + { + "name": "event session complete", + "signer": "box", + "t": "event", + "body": "ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d28", + "signedInputSha256": "37a352e6f700b18e8dfec643cc783a67bf284209ec5ef16956266dfe84ed0b0a", + "sig": "e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "envelope": "a3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "frame": "01010000010aa3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb" + }, + { + "name": "roster tunnel only", + "signer": "box", + "t": "roster", + "body": "a9637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105", + "signedInputSha256": "30ae9e4de78ae0f21421f8a7b172759f3b244da825c0072f2781cb49a73a907c", + "sig": "f9b4b3e144492cf784a07fce0272fe0252d3d71164645b092ebc086a4eab2f0f4e8c11e20aec3567e454a5641a06a97166eaf20ccdd8a022f1eaaa2a18a52e8b", + "envelope": "a36162590168a9637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105617466726f73746572637369675840f9b4b3e144492cf784a07fce0272fe0252d3d71164645b092ebc086a4eab2f0f4e8c11e20aec3567e454a5641a06a97166eaf20ccdd8a022f1eaaa2a18a52e8b", + "frame": "0101000001bda36162590168a9637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105617466726f73746572637369675840f9b4b3e144492cf784a07fce0272fe0252d3d71164645b092ebc086a4eab2f0f4e8c11e20aec3567e454a5641a06a97166eaf20ccdd8a022f1eaaa2a18a52e8b" + }, + { + "name": "roster revoking", + "signer": "box", + "t": "roster", + "body": "a9637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106", + "signedInputSha256": "d8775b10218f6c69d9b7c9064d1cea8713058eaa9d91aeeba2e2f29419b7f9cc", + "sig": "4332ec4bf6f166175f50eecea08ecbc981d762c10b37f9e676d7e0a763b5d73e58533683fd6b566cf0a33feff903af40d4bf70bedc5245b2696fa85a3191186f", + "envelope": "a3616258f5a9637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106617466726f737465726373696758404332ec4bf6f166175f50eecea08ecbc981d762c10b37f9e676d7e0a763b5d73e58533683fd6b566cf0a33feff903af40d4bf70bedc5245b2696fa85a3191186f", + "frame": "010100000149a3616258f5a9637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106617466726f737465726373696758404332ec4bf6f166175f50eecea08ecbc981d762c10b37f9e676d7e0a763b5d73e58533683fd6b566cf0a33feff903af40d4bf70bedc5245b2696fa85a3191186f" + }, + { + "name": "purged copy", + "signer": "service", + "t": "purged", + "body": "a36461744d731b000001a130dc33e0647768617464636f7079676772616e7449645044444444444444444444444444444444", + "signedInputSha256": "72a96fe8a54dcebc90f78451152b7223088bcfcc7d48877006e1b3b6fc1aeed9", + "sig": "25e59e26aa33f7aaa1f4e463b721ea04c125f4cf1cec9f547dc49d07143e786dc35c05cd4320753d703759116c312e77f386579e61f1649763435fcbac5079c1", + "envelope": "a361625832a36461744d731b000001a130dc33e0647768617464636f7079676772616e744964504444444444444444444444444444444461746670757267656463736967584025e59e26aa33f7aaa1f4e463b721ea04c125f4cf1cec9f547dc49d07143e786dc35c05cd4320753d703759116c312e77f386579e61f1649763435fcbac5079c1", + "frame": "010100000086a361625832a36461744d731b000001a130dc33e0647768617464636f7079676772616e744964504444444444444444444444444444444461746670757267656463736967584025e59e26aa33f7aaa1f4e463b721ea04c125f4cf1cec9f547dc49d07143e786dc35c05cd4320753d703759116c312e77f386579e61f1649763435fcbac5079c1" + }, + { + "name": "tun down", + "signer": "", + "t": "tun", + "body": "a36164587099999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999636465766871354c77305a62316373696407", + "signedInputSha256": "09de8e31df87d982960f45e5bf4315b72dd8c315ba56a506d367ef7400596c7a", + "sig": "", + "envelope": "a361625887a36164587099999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999636465766871354c77305a6231637369640761746374756e6373696740", + "frame": "010100000097a361625887a36164587099999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999636465766871354c77305a6231637369640761746374756e6373696740" + }, + { + "name": "tun up", + "signer": "", + "t": "tun", + "body": "a3616458309a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a636465766871354c77305a62316373696407", + "signedInputSha256": "302a45e7a661c5e8ad29bf2e1c56ad4c544736b90630a2553dc78495da275c9d", + "sig": "", + "envelope": "a361625847a3616458309a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a636465766871354c77305a6231637369640761746374756e6373696740", + "frame": "010100000057a361625847a3616458309a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a636465766871354c77305a6231637369640761746374756e6373696740" + }, + { + "name": "tun end", + "signer": "", + "t": "tun", + "body": "a3616440636465766871354c77305a62316373696407", + "signedInputSha256": "e3e082a098ccdc6cb7c8cd2b863db4fd5060b40358ac3941d7ae983d83e35f27", + "sig": "", + "envelope": "a3616256a3616440636465766871354c77305a6231637369640761746374756e6373696740", + "frame": "010100000025a3616256a3616440636465766871354c77305a6231637369640761746374756e6373696740" } ] } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/TunnelCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/TunnelCarrierTests.swift new file mode 100644 index 00000000..62d51a36 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/TunnelCarrierTests.swift @@ -0,0 +1,739 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo +import FTWShared + +/// A box behind the fake FTW Cloud: the responder half of each phone's own +/// session, one per `sid`, admitting a paired key or a live pairing code and +/// answering a key it does not know with silence, as Core does. It echoes +/// each frame, or hands it to a simulated box when it has one. +@MainActor +final class FakeTunnelBox { + let staticKey = Primitives.generateKeyPair() + var paired: Set = [] + /// The QR code on the box's screen. Spent once. + var code: Bytes? + /// End each session at the phone's first transport frame, as the box + /// does when another session of the device holds the slot. + var endsOnFirstFrame = false + let simulated: SimulatedBox? + private(set) var opened: [UInt32] = [] + private(set) var ended: [UInt32] = [] + private(set) var frames: [Bytes] = [] + private var sessions: [UInt32: (key: Bytes, transport: NoiseTransport, reply: (CloudWire.Tun) -> Void)] = [:] + private var newest: UInt32? + + init(simulated: SimulatedBox? = nil) { + self.simulated = simulated + simulated?.send = { [weak self] frame in + guard let self, let sid = self.newest, let s = self.sessions[sid], let wire = try? s.transport.encrypt(frame) else { return } + s.reply(CloudWire.Tun(sid: sid, d: wire)) + } + } + + var prologue: Bytes { NoiseCarrier.prologue(boxStaticKey: staticKey.publicKey) } + + func receive(_ tun: CloudWire.Tun, from device: Bytes, reply: @escaping (CloudWire.Tun) -> Void) { + if tun.ended { + sessions[tun.sid] = nil + ended.append(tun.sid) + return + } + if let s = sessions[tun.sid] { + guard let frame = try? s.transport.decrypt(tun.d) else { return } + if endsOnFirstFrame { + end(tun.sid) + return + } + frames.append(frame) + if let simulated { + simulated.receive(frame) + } else if let echo = try? s.transport.encrypt(frame) { + reply(CloudWire.Tun(sid: tun.sid, d: echo)) + } + return + } + let hs = HandshakeState.responder(staticKey: staticKey, prologue: prologue) + guard let payload = try? hs.readMessage(tun.d), let message2 = try? hs.writeMessage(), let keys = try? hs.split() else { + // Not message 1 of a session this box holds: start again. + reply(CloudWire.Tun(sid: tun.sid, d: [])) + return + } + // The service names the device; the handshake proves it. + guard keys.remoteStatic == device else { return } + if !paired.contains(keys.remoteStatic) { + guard let code, payload == code else { return } + self.code = nil + paired.insert(keys.remoteStatic) + } + // One session per device: a new one ends the older. + for (sid, s) in sessions where s.key == keys.remoteStatic { sessions[sid] = nil } + sessions[tun.sid] = (keys.remoteStatic, NoiseTransport(keys), reply) + newest = tun.sid + opened.append(tun.sid) + reply(CloudWire.Tun(sid: tun.sid, d: message2)) + } + + /// The box drops a session and says so. + func end(_ sid: UInt32) { + guard let s = sessions.removeValue(forKey: sid) else { return } + s.reply(CloudWire.Tun(sid: sid, d: [])) + } + + /// A frame on a session, as the box sends one. + func say(_ frame: Bytes, on sid: UInt32) { + guard let s = sessions[sid], let wire = try? s.transport.encrypt(frame) else { return } + s.reply(CloudWire.Tun(sid: sid, d: wire)) + } +} + +/// The relay the fallback builds, standing still: it opens when told to. +@MainActor +final class StandInRelay: Carrier { + let kind: CarrierKind = .relay + private(set) var status: CarrierStatus = .connecting + private(set) var sent: [Bytes] = [] + private(set) var closedWith: String? + private var onStatus: @MainActor (CarrierStatus) -> Void = { _ in } + + func send(_ frame: Bytes) -> Bool { + guard status.isOpen else { return false } + sent.append(frame) + return true + } + + func setHandlers(onFrame: @escaping @MainActor (Bytes) -> Void, onStatus: @escaping @MainActor (CarrierStatus) -> Void) { + self.onStatus = onStatus + } + + func wake() {} + + func close(reason: String) { + closedWith = reason + status = .closed(reason: reason, retryable: false) + } + + func open() { + status = .open(sinceMs: 0) + onStatus(status) + } +} + +/// A phone's FTW Cloud session with its box session riding it. +@MainActor +final class TunnelRig { + let scheduler = ManualScheduler() + let service: FakeCloudService + let box = FakeTunnelBox() + let device = Primitives.generateKeyPair() + static let home = "abcdefghijklmnopqrstuvwx23" + var home: String { Self.home } + let cloud: CloudCarrier + var relays: [StandInRelay] = [] + var frames: [Bytes] = [] + var statuses: [CarrierStatus] = [] + /// Send one frame the moment the session opens, as `Session` sends its + /// hello. + var sayHello = false + + init(paired: Bool = true, pair: Bytes? = nil, endpoint: ((FakeCloudService) -> CloudEndpoint)? = nil) { + service = FakeCloudService(scheduler: scheduler) + service.tunBox = box + if paired { + service.roster = [device.publicKey] + box.paired = [device.publicKey] + } + cloud = CloudCarrier(endpoint: endpoint?(service) ?? service.endpoint, home: Self.home, deviceKey: device, looking: true, pair: pair, scheduler: scheduler, makeSocket: service.factory) + cloud.setHandlers(onSnapshot: { _, _ in }, onEnvelope: { _, _ in }, onStatus: { _ in }) + } + + /// The box session as the app builds it: Noise to the box, over the + /// tunnel, with or without the relay behind it. + func session(fallback: Bool = false, code: Bytes = []) -> NoiseCarrier { + let tunnel = TunnelCarrier(cloud: cloud, scheduler: scheduler) + let inner: Carrier = fallback + ? FallbackCarrier(tunnel: tunnel, relay: { [unowned self] in + let r = StandInRelay() + self.relays.append(r) + return r + }, scheduler: scheduler) + : tunnel + let noise = NoiseCarrier(inner: inner, staticKey: device, remoteStatic: box.staticKey.publicKey, + prologue: box.prologue, handshakePayload: code, scheduler: scheduler) + noise.setHandlers( + onFrame: { [unowned self] f in self.frames.append(f) }, + onStatus: { [unowned self, weak noise] s in + self.statuses.append(s) + if self.sayHello, s.isOpen { noise?.send([0x68]) } + } + ) + return noise + } + + /// Box sessions that opened, by their message 1 through the service. + var message1Sids: [UInt32] { box.opened } + + func run(_ ms: Double, step: Double = 5) async { + var left = ms + while left > 0 { + for _ in 0..<5 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<5 { await Task.yield() } + } +} + +@MainActor +@Suite struct TunnelCarrierTests { + /// The box session's message 1 rides the app session's, so the box's + /// answer follows message 2 by one leg: up one round trip after TLS. + @Test func theBoxSessionRidesMessage1AndIsUpOneRoundTripAfterTLS() async throws { + let rig = TunnelRig() + rig.cloud.start() + let noise = rig.session() + // Socket open at 10 ms, message 1 at the service at 20, message 2 + // and the box's answer back at 30. + await rig.run(30) + #expect(noise.status.isOpen) + #expect(noise.kind == .tunnel) + #expect(rig.service.nowBodies.count == 1) + #expect(rig.service.nowBodies.first?["tun"] != nil) + #expect(rig.service.nowBodies.first?["pair"] == nil) + #expect(rig.message1Sids.count == 1) + + // Frames go both ways, sealed for the box alone. + let frame = Bytes(repeating: 0x42, count: Frame.controlBuckets[0]) + #expect(noise.send(frame)) + await rig.run(30) + #expect(rig.box.frames == [frame]) + #expect(rig.frames == [frame]) + #expect(!rig.service.tunsFromPhone.contains { holds($0.d, Bytes(frame.prefix(32))) }) + } + + /// The service already runs on the app's other key and refuses message + /// 1 under the first. The retry on the next key carries the box + /// session's message 1 again, or the box would never see it. + @Test func aRetryOnTheOtherServiceKeyCarriesMessage1Again() async throws { + let rig = TunnelRig { service in + CloudEndpoint(url: URL(string: "wss://cloud.test/v1/app")!, serviceKeys: [Primitives.generateKeyPair().publicKey, service.staticKey.publicKey]) + } + rig.cloud.start() + let noise = rig.session() + await rig.run(80) + #expect(rig.service.dialled.count == 2) + #expect(rig.service.nowBodies.count == 1) + #expect(rig.service.nowBodies.first?["tun"] != nil) + #expect(noise.status.isOpen) + } + + /// A session already dialling has sent its message 1: the box session's + /// waits for message 2 and goes then. + @Test func message1WaitsForMessage2WhenTheAppSessionIsAlreadyDialling() async throws { + let rig = TunnelRig() + rig.cloud.start() + await rig.run(15) + let noise = rig.session() + await rig.run(15) + #expect(!noise.status.isOpen) + await rig.run(25) + #expect(noise.status.isOpen) + #expect(rig.service.nowBodies.first?["tun"] == nil) + #expect(rig.service.tunsFromPhone.count == 1) + } + + /// Every handshake draws a new sid, so the late end of an older session + /// can never end the new one, and the box never mistakes a second + /// message 1 for a frame of the first. + @Test func eachHandshakeDrawsANewSid() async throws { + let rig = TunnelRig() + rig.box.paired = [] + rig.cloud.start() + let noise = rig.session() + await rig.run(100) + // The box answers a key it does not know with silence. + #expect(!noise.status.isOpen) + #expect(rig.service.tunsFromPhone.count == 1) + rig.box.paired = [rig.device.publicKey] + // The handshake's own deadline, then a retry with backoff. + await rig.run(NoiseCarrier.handshakeDeadlineMs + NoiseCarrier.handshakeBackoffBaseMs, step: 50) + #expect(noise.status.isOpen) + let sids = rig.service.tunsFromPhone.map(\.sid) + #expect(sids.count == 2) + #expect(Set(sids).count == 2) + #expect(rig.message1Sids == [sids[1]]) + } + + /// The box dropped the session: a new one starts after a short backoff, + /// under a new sid, on the same app session. + @Test func anEndFromTheBoxStartsANewSession() async throws { + let rig = TunnelRig() + rig.cloud.start() + let noise = rig.session() + await rig.run(40) + let first = try #require(rig.message1Sids.first) + rig.box.end(first) + await rig.run(12) + #expect(!noise.status.isOpen) + await rig.run(TunnelCarrier.backoffBaseMs + 50) + #expect(noise.status.isOpen) + #expect(rig.message1Sids.count == 2) + #expect(rig.message1Sids[1] != first) + #expect(rig.service.dialled.count == 1) + } + + /// The box ends each session at the phone's first transport frame, as + /// when another session of this device holds the slot. The phone cannot + /// tell that from a box that dropped it, so it starts again, but ever + /// more slowly: no loop. + @Test func endsAfterEachHandshakeSlowDown() async throws { + let rig = TunnelRig() + rig.sayHello = true + rig.box.endsOnFirstFrame = true + rig.cloud.start() + let noise = rig.session() + await rig.run(60_000, step: 50) + withExtendedLifetime(noise) {} + // 0.25, 0.5, 1, 2, 4, 8, 16 and 30 s apart at the scheduler's + // midpoint: eight sessions in a minute, not two hundred. + #expect(rig.message1Sids.count <= 9) + #expect(rig.message1Sids.count >= 6) + #expect(rig.service.dialled.count == 1) + } + + /// A session that carried for a while and then ended, as when the box + /// restarts, starts again at once whatever ended before it. + @Test func aSettledSessionThatEndsStartsAgainAtOnce() async throws { + let rig = TunnelRig() + rig.sayHello = true + rig.box.endsOnFirstFrame = true + rig.cloud.start() + let noise = rig.session() + await rig.run(8_000, step: 50) + let before = rig.message1Sids.count + rig.box.endsOnFirstFrame = false + await rig.run(20_000, step: 50) + #expect(noise.status.isOpen) + await rig.run(TunnelCarrier.settledMs, step: 100) + let carried = try #require(rig.message1Sids.last) + rig.box.end(carried) + await rig.run(TunnelCarrier.backoffBaseMs, step: 10) + #expect(rig.message1Sids.count == before + 2) + #expect(noise.status.isOpen) + } + + /// A frame or an end that arrives for a session this phone has left is + /// dropped: the sid names the session it belongs to. + @Test func aLateFrameOfAnOlderSessionIsDropped() async throws { + let rig = TunnelRig() + rig.service.silentTun = true + rig.cloud.start() + let tunnel = TunnelCarrier(cloud: rig.cloud, scheduler: rig.scheduler) + var frames: [Bytes] = [] + tunnel.setHandlers(onFrame: { frames.append($0) }, onStatus: { _ in }) + tunnel.beginSession() + let old = tunnel.sid + #expect(tunnel.send([0xaa])) + await rig.run(40) + tunnel.beginSession() + let socket = try #require(rig.service.current) + socket.tun(CloudWire.Tun(sid: old, d: [1])) + socket.tun(CloudWire.Tun(sid: old, d: [])) + socket.tun(CloudWire.Tun(sid: tunnel.sid, d: [2])) + await rig.run(30) + #expect(frames == [[2]]) + #expect(tunnel.status.isOpen) + } + + /// A wake after sleep dials the app session afresh, as the relay + /// does: a socket that looks open may be dead. + @Test func aWakeDialsAfresh() async throws { + let rig = TunnelRig() + rig.cloud.start() + let noise = rig.session() + await rig.run(40) + noise.wake() + await rig.run(40) + #expect(rig.service.dialled.count == 2) + #expect(noise.status.isOpen) + // Its message 1 rode the new app session's. + #expect(rig.service.nowBodies.count == 2) + #expect(rig.service.nowBodies.last?["tun"] != nil) + } + + /// Closing the box session ends it at the box and leaves the app + /// session up for the readable copy. + @Test func closingEndsTheBoxSessionAndLeavesTheAppSession() async throws { + let rig = TunnelRig() + rig.cloud.start() + let noise = rig.session() + await rig.run(40) + let sid = try #require(rig.message1Sids.first) + noise.close(reason: "test") + await rig.run(30) + #expect(rig.box.ended == [sid]) + #expect(rig.cloud.status.isOpen) + } + + /// The app session closing for good closes the tunnel for good. + @Test func aClosedAppSessionClosesTheTunnel() async throws { + let rig = TunnelRig() + rig.cloud.start() + let tunnel = TunnelCarrier(cloud: rig.cloud, scheduler: rig.scheduler) + await rig.run(40) + #expect(tunnel.status.isOpen) + rig.cloud.close() + #expect(tunnel.status == .closed(reason: "FTW Cloud session closed", retryable: false)) + #expect(!tunnel.send([1])) + } + + /// A new phone is in no roster. It proves the code it scanned by its + /// hash, the service admits it once, and the box pairs it inside the + /// session as over the relay: the code itself never leaves the Noise + /// session to the box. + @Test func aNewPhonePairsThroughTheService() async throws { + let code = randomBytes(16) + let hash = CloudWire.pairingHash(home: TunnelRig.home, code: code) + let rig = TunnelRig(paired: false, pair: hash) + rig.service.pending = [hash] + rig.box.code = code + rig.cloud.start() + let noise = rig.session(code: code) + await rig.run(40) + #expect(noise.status.isOpen) + #expect(rig.service.nowBodies.first?["pair"]?.byteString == hash) + #expect(rig.service.pending.isEmpty) + #expect(rig.box.paired == [rig.device.publicKey]) + #expect(!rig.service.tunsFromPhone.contains { holds($0.d, code) }) + } +} + +/// Whether `needle` appears in `hay`, byte for byte. +private func holds(_ hay: Bytes, _ needle: Bytes) -> Bool { + guard !needle.isEmpty, hay.count >= needle.count else { return false } + return (0...(hay.count - needle.count)).contains { Array(hay[$0..<($0 + needle.count)]) == needle } +} + +@MainActor +@Suite struct FallbackCarrierTests { + /// While the tunnel carries, the relay is never dialled: a socket held + /// in reserve would take one of the relay's streams for the home. + @Test func theRelayStaysUndialledWhileTheTunnelCarries() async throws { + let rig = TunnelRig() + rig.cloud.start() + let noise = rig.session(fallback: true) + await rig.run(40) + #expect(noise.status.isOpen) + #expect(noise.kind == .tunnel) + for _ in 0..<5 { + #expect(noise.send([7])) + await rig.run(2_000, step: 50) + } + #expect(rig.relays.isEmpty) + #expect(rig.frames.count == 5) + } + + /// The box answered the handshake and ended the session at the phone's + /// first transport frame: the tunnel never carried, so the relay at + /// once. The box takes the slot only on that frame, so its handshake + /// answer alone proves nothing. + @Test func aHandshakeAnswerAloneIsNotCarrying() async throws { + let rig = TunnelRig() + rig.sayHello = true + rig.box.endsOnFirstFrame = true + rig.cloud.start() + let noise = rig.session(fallback: true) + await rig.run(60) + #expect(rig.message1Sids.count == 1) + #expect(rig.relays.count == 1) + #expect(noise.kind == .relay) + } + + /// No session for this phone at FTW Cloud: the relay at once. + @Test func aRefusedAppSessionMovesToTheRelay() async throws { + let rig = TunnelRig() + rig.service.roster = [] + rig.cloud.start() + let noise = rig.session(fallback: true) + await rig.run(40) + #expect(rig.relays.count == 1) + #expect(noise.kind == .relay) + rig.relays[0].open() + await rig.run(5) + // The handshake goes again, over the relay. + #expect(rig.relays[0].sent.count == 1) + } + + /// FTW Cloud unreachable: the relay at once. + @Test func anUnreachableServiceMovesToTheRelay() async throws { + let rig = TunnelRig() + rig.cloud.start() + let noise = rig.session(fallback: true) + rig.service.close(code: 1006) + await rig.run(5) + #expect(rig.relays.count == 1) + #expect(noise.kind == .relay) + } + + /// The service has no box to give the session to, as with a box that is + /// offline or does not take `tun`: the relay at once. + @Test func aSessionEndedBeforeAnyAnswerMovesToTheRelay() async throws { + let rig = TunnelRig() + rig.service.tunBox = nil + rig.cloud.start() + let noise = rig.session(fallback: true) + await rig.run(40) + #expect(rig.relays.count == 1) + #expect(noise.kind == .relay) + } + + /// An older box lists the phone without `tun`, and the service passes + /// nothing on and says nothing. The tunnel's wait ends it. + @Test func silenceMovesToTheRelayAfterTheWait() async throws { + let rig = TunnelRig() + rig.service.silentTun = true + rig.cloud.start() + let noise = rig.session(fallback: true) + await rig.run(FallbackCarrier.tunnelWaitMs - 100, step: 50) + #expect(rig.relays.isEmpty) + await rig.run(200, step: 50) + #expect(rig.relays.count == 1) + #expect(noise.kind == .relay) + } + + /// A tunnel that carried and drops for a moment, as when FTW Cloud + /// restarts, comes back on its own. + @Test func aCarryingTunnelThatDropsBrieflyStays() async throws { + let rig = TunnelRig() + rig.cloud.start() + let noise = rig.session(fallback: true) + await rig.run(40) + #expect(noise.send([1])) + await rig.run(30) + rig.service.close(code: 1006) + await rig.run(1_000, step: 10) + #expect(noise.status.isOpen) + #expect(noise.kind == .tunnel) + #expect(rig.relays.isEmpty) + #expect(rig.service.dialled.count == 2) + } + + /// A tunnel that carried and does not come back within the wait moves + /// to the relay. + @Test func aCarryingTunnelThatStaysDownMovesToTheRelay() async throws { + let rig = TunnelRig() + rig.cloud.start() + let noise = rig.session(fallback: true) + await rig.run(40) + #expect(noise.send([1])) + await rig.run(30) + rig.service.silentTun = true + rig.service.close(code: 1006) + await rig.run(FallbackCarrier.tunnelWaitMs + 200, step: 50) + #expect(rig.relays.count == 1) + #expect(noise.kind == .relay) + } + + /// A wake dials the tunnel afresh, so it has to carry again within the + /// wait: a new dial that hangs moves to the relay, not after a socket + /// timeout. + @Test func aWakeGivesTheTunnelTheWaitAgain() async throws { + let rig = TunnelRig() + rig.cloud.start() + let noise = rig.session(fallback: true) + await rig.run(40) + #expect(noise.send([1])) + await rig.run(30) + rig.service.silentTun = true + noise.wake() + await rig.run(FallbackCarrier.tunnelWaitMs - 200, step: 50) + #expect(rig.relays.isEmpty) + await rig.run(400, step: 50) + #expect(rig.relays.count == 1) + #expect(noise.kind == .relay) + } + + /// Without a relay to fall back on, the tunnel manages alone. + @Test func withoutARelayTheTunnelKeepsTrying() async throws { + let rig = TunnelRig() + rig.service.tunBox = nil + rig.cloud.start() + let tunnel = TunnelCarrier(cloud: rig.cloud, scheduler: rig.scheduler) + let fallback = FallbackCarrier(tunnel: tunnel, relay: nil, scheduler: rig.scheduler) + let noise = NoiseCarrier(inner: fallback, staticKey: rig.device, remoteStatic: rig.box.staticKey.publicKey, + prologue: rig.box.prologue, scheduler: rig.scheduler) + await rig.run(FallbackCarrier.tunnelWaitMs + 2_000, step: 50) + #expect(!fallback.onRelay) + #expect(fallback.kind == .tunnel) + #expect(rig.service.tunsFromPhone.count >= 2) + rig.service.tunBox = rig.box + await rig.run(NoiseCarrier.handshakeDeadlineMs + 4_000, step: 50) + #expect(noise.status.isOpen) + } +} + +/// The whole app against a simulated box behind FTW Cloud: pairing from a +/// v3 code, the stream and the carrier the band names, with the relay +/// unreachable or with FTW Cloud unreachable. +@MainActor +@Suite struct OneDoorShellTests { + @MainActor + final class World { + let scheduler = ManualScheduler() + let store = MemoryStore() + let files: SealedFiles + let passkeys = FakePasskeys() + let simulated: SimulatedBox + let service: FakeCloudService + /// The box behind FTW Cloud, when the tunnel world has one. + let tunnelBox: FakeTunnelBox? + /// The box behind the relay, when the relay world has one. + let relay: FakeRelay? + let boxKey: Bytes + private(set) var relayDials: [URL] = [] + let code = randomBytes(16) + static let home = "fjzyr67buzpyncqmjliwgezvde" + + /// `throughCloud`: the box sits behind FTW Cloud and the relay is + /// unreachable. Otherwise the box sits behind the relay and FTW + /// Cloud refuses every session. + init(throughCloud: Bool) { + files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-test-\(UUID().uuidString)"), store: store) + simulated = SimulatedBox(scheduler: scheduler) + service = FakeCloudService(scheduler: scheduler) + if throughCloud { + let box = FakeTunnelBox(simulated: simulated) + box.code = code + tunnelBox = box + relay = nil + boxKey = box.staticKey.publicKey + service.tunBox = box + service.pending = [CloudWire.pairingHash(home: Self.home, code: code)] + } else { + let endpoint = NoiseBoxEndpoint(box: simulated) + relay = FakeRelay(scheduler: scheduler, endpoint: endpoint) + tunnelBox = nil + boxKey = endpoint.staticKey.publicKey + } + } + + var cloudEndpoint: CloudEndpoint { + CloudEndpoint(url: URL(string: "wss://cloud.test/v1/app")!, serviceKeys: [service.staticKey.publicKey]) + } + + var sockets: WebSocketFactory { + { [unowned self] url, events in + if url.host == "cloud.test" { return self.service.factory(url, events) } + self.relayDials.append(url) + if let relay = self.relay { return relay.factory(url, events) } + // Nothing answers at the relay's address. + let socket = DeadSocket() + _ = self.scheduler.after(10) { events.onClose(1006, "") } + return socket + } + } + + func app() -> AppModel { + AppModel(store: store, files: files, passkeys: passkeys, scheduler: scheduler, build: "test", ua: "test", + relayURL: URL(string: "wss://relay.test")!, cloudEndpoint: cloudEndpoint, makeSocket: sockets) + } + + func qr(home: String?) -> String { + Enrollment(boxStaticPublic: boxKey, pairingCode: code, lanHint: "", rendezvousSecret: randomBytes(32), home: home).url() + } + + func run(_ ms: Double, step: Double = 20) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + } + + @MainActor + final class DeadSocket: WebSocketConnection { + func send(_ data: Bytes) {} + func close() {} + } + + /// A v3 code names the home, so the first session already goes through + /// FTW Cloud: the service admits the new phone by its proof, the box + /// pairs it inside the session, and the band says the tunnel carries. + @Test func aV3CodePairsAndStreamsThroughFTWCloudWithTheRelayUnreachable() async throws { + let world = World(throughCloud: true) + world.simulated.moreCaps = [Contract.capCloudTun] + let app = world.app() + let pair = PairModel(app: app) + let task = Task { await pair.pair(world.qr(home: World.home)) } + await world.run(500) + await task.value + let site = try #require(app.site) + #expect(site.session.phase == .streaming) + #expect(site.carrier == .tunnel) + #expect(site.freshness(noCarrier: false).message == "Live, encrypted via FTW Cloud") + #expect(world.relayDials.isEmpty) + #expect(world.service.pending.isEmpty) + #expect(world.tunnelBox?.paired.count == 1) + #expect(app.sites.get(try #require(site.siteId))?.cloudHome == World.home) + + // Back from the background: the box said it takes the tunnel, so + // the tunnel again, without the proof. The box's next roster lists + // the phone. + world.service.roster = try #require(world.tunnelBox?.paired) + app.suspend() + await world.run(100) + app.setVisible(true) + await world.run(500) + #expect(site.session.phase == .streaming) + #expect(site.carrier == .tunnel) + #expect(world.relayDials.isEmpty) + // The proof rides along while the phone holds a code; the service + // admits a phone the roster lists by its key. + #expect(world.service.nowBodies.count == 2) + #expect(world.service.pending.isEmpty) + } + + /// A box whose hello does not say `cloud.tun` is reached through the + /// relay, though the phone knows the home: no wait on a tunnel it + /// cannot have. + @Test func aBoxWithoutTunIsReachedThroughTheRelay() async throws { + let world = World(throughCloud: false) + let app = world.app() + let pair = PairModel(app: app) + let task = Task { await pair.pair(world.qr(home: nil)) } + await world.run(500) + await task.value + let site = try #require(app.site) + #expect(site.session.phase == .streaming) + #expect(site.carrier == .relay) + // No home id from a v2 code and none from this box: no FTW Cloud. + #expect(world.service.dialled.isEmpty) + + app.sites.update(try #require(site.siteId)) { $0.cloudHome = World.home } + app.suspend() + app.setVisible(true) + await world.run(500) + #expect(site.session.phase == .streaming) + #expect(site.carrier == .relay) + #expect(world.service.tunsFromPhone.isEmpty) + } + + /// FTW Cloud refuses the session: the relay carries, at once. + @Test func aRefusingCloudFallsBackToTheRelay() async throws { + let world = World(throughCloud: false) + let app = world.app() + let pair = PairModel(app: app) + let task = Task { await pair.pair(world.qr(home: World.home)) } + await world.run(500) + await task.value + let site = try #require(app.site) + #expect(world.service.dialled.count >= 1) + #expect(site.session.phase == .streaming) + #expect(site.carrier == .relay) + #expect(world.relayDials.count == 1) + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift index 3e2574f1..47877569 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PushAlertTests.swift @@ -100,7 +100,7 @@ import Testing let file = try Self.load("cloud-vectors", as: CloudFile.self) let home = try #require(file.boxKey.home) let keys = ["box": Data(hex: file.boxKey.pk), "service": Data(hex: file.serviceKey.pk)] - #expect(file.messages.count == 16) + #expect(file.messages.count == 22) for m in file.messages { let env = try #require(PushAlert.Envelope(Array(Data(hex: m.envelope))), "\(m.name)") #expect(env.t == m.t, "\(m.name)") diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json index 9ad22446..7a9dd780 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json +++ b/appleApp/FTWKit/Tests/FTWSharedTests/Resources/cloud-vectors.json @@ -66,11 +66,11 @@ "name": "roster", "signer": "box", "t": "roster", - "body": "a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104", - "signedInputSha256": "f0926ee704ac9558f742f604315ef8ea260a9edef63a210172d9c299d5b171cf", - "sig": "cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124", - "envelope": "a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124", - "frame": "010100000207a361625901b2a8637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a56269646871354c77305a6231636b65795820555555555555555555555555555555555555555555555555555555555555555564726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a5626964685a66303978513261636b65795820666666666666666666666666666666666666666666666666666666666666666664726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e7265616468757074696d654d7319ea6a69726f7374657253657104617466726f73746572637369675840cbb6ead0b031278d2252885c2a6f7770c98335676b1cf29f57115c9e76609c81e53eb6260d79df419aebc93ed29c80935190368e1730de2d703867abe31bf124" + "body": "a9637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104", + "signedInputSha256": "54a45d8ab064d1d9fda8dfd3d43708dca9294daefe67078b15ab17ae1c3c086a", + "sig": "26de4f6b491496569361fce489167aa3a445c91235689a79f5499f5a65d4ed5e35ecd601251013d345f8171904bd5b508e0a05d543dc25f2335b2d7a743ad916", + "envelope": "a361625901c5a9637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104617466726f7374657263736967584026de4f6b491496569361fce489167aa3a445c91235689a79f5499f5a65d4ed5e35ecd601251013d345f8171904bd5b508e0a05d543dc25f2335b2d7a743ad916", + "frame": "01010000021aa361625901c5a9637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104617466726f7374657263736967584026de4f6b491496569361fce489167aa3a445c91235689a79f5499f5a65d4ed5e35ecd601251013d345f8171904bd5b508e0a05d543dc25f2335b2d7a743ad916" }, { "name": "live full", @@ -191,6 +191,66 @@ "sig": "e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", "envelope": "a3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", "frame": "01010000010aa3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb" + }, + { + "name": "roster tunnel only", + "signer": "box", + "t": "roster", + "body": "a9637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105", + "signedInputSha256": "30ae9e4de78ae0f21421f8a7b172759f3b244da825c0072f2781cb49a73a907c", + "sig": "f9b4b3e144492cf784a07fce0272fe0252d3d71164645b092ebc086a4eab2f0f4e8c11e20aec3567e454a5641a06a97166eaf20ccdd8a022f1eaaa2a18a52e8b", + "envelope": "a36162590168a9637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105617466726f73746572637369675840f9b4b3e144492cf784a07fce0272fe0252d3d71164645b092ebc086a4eab2f0f4e8c11e20aec3567e454a5641a06a97166eaf20ccdd8a022f1eaaa2a18a52e8b", + "frame": "0101000001bda36162590168a9637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105617466726f73746572637369675840f9b4b3e144492cf784a07fce0272fe0252d3d71164645b092ebc086a4eab2f0f4e8c11e20aec3567e454a5641a06a97166eaf20ccdd8a022f1eaaa2a18a52e8b" + }, + { + "name": "roster revoking", + "signer": "box", + "t": "roster", + "body": "a9637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106", + "signedInputSha256": "d8775b10218f6c69d9b7c9064d1cea8713058eaa9d91aeeba2e2f29419b7f9cc", + "sig": "4332ec4bf6f166175f50eecea08ecbc981d762c10b37f9e676d7e0a763b5d73e58533683fd6b566cf0a33feff903af40d4bf70bedc5245b2696fa85a3191186f", + "envelope": "a3616258f5a9637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106617466726f737465726373696758404332ec4bf6f166175f50eecea08ecbc981d762c10b37f9e676d7e0a763b5d73e58533683fd6b566cf0a33feff903af40d4bf70bedc5245b2696fa85a3191186f", + "frame": "010100000149a3616258f5a9637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106617466726f737465726373696758404332ec4bf6f166175f50eecea08ecbc981d762c10b37f9e676d7e0a763b5d73e58533683fd6b566cf0a33feff903af40d4bf70bedc5245b2696fa85a3191186f" + }, + { + "name": "purged copy", + "signer": "service", + "t": "purged", + "body": "a36461744d731b000001a130dc33e0647768617464636f7079676772616e7449645044444444444444444444444444444444", + "signedInputSha256": "72a96fe8a54dcebc90f78451152b7223088bcfcc7d48877006e1b3b6fc1aeed9", + "sig": "25e59e26aa33f7aaa1f4e463b721ea04c125f4cf1cec9f547dc49d07143e786dc35c05cd4320753d703759116c312e77f386579e61f1649763435fcbac5079c1", + "envelope": "a361625832a36461744d731b000001a130dc33e0647768617464636f7079676772616e744964504444444444444444444444444444444461746670757267656463736967584025e59e26aa33f7aaa1f4e463b721ea04c125f4cf1cec9f547dc49d07143e786dc35c05cd4320753d703759116c312e77f386579e61f1649763435fcbac5079c1", + "frame": "010100000086a361625832a36461744d731b000001a130dc33e0647768617464636f7079676772616e744964504444444444444444444444444444444461746670757267656463736967584025e59e26aa33f7aaa1f4e463b721ea04c125f4cf1cec9f547dc49d07143e786dc35c05cd4320753d703759116c312e77f386579e61f1649763435fcbac5079c1" + }, + { + "name": "tun down", + "signer": "", + "t": "tun", + "body": "a36164587099999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999636465766871354c77305a62316373696407", + "signedInputSha256": "09de8e31df87d982960f45e5bf4315b72dd8c315ba56a506d367ef7400596c7a", + "sig": "", + "envelope": "a361625887a36164587099999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999636465766871354c77305a6231637369640761746374756e6373696740", + "frame": "010100000097a361625887a36164587099999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999636465766871354c77305a6231637369640761746374756e6373696740" + }, + { + "name": "tun up", + "signer": "", + "t": "tun", + "body": "a3616458309a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a636465766871354c77305a62316373696407", + "signedInputSha256": "302a45e7a661c5e8ad29bf2e1c56ad4c544736b90630a2553dc78495da275c9d", + "sig": "", + "envelope": "a361625847a3616458309a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a636465766871354c77305a6231637369640761746374756e6373696740", + "frame": "010100000057a361625847a3616458309a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a636465766871354c77305a6231637369640761746374756e6373696740" + }, + { + "name": "tun end", + "signer": "", + "t": "tun", + "body": "a3616440636465766871354c77305a62316373696407", + "signedInputSha256": "e3e082a098ccdc6cb7c8cd2b863db4fd5060b40358ac3941d7ae983d83e35f27", + "sig": "", + "envelope": "a3616256a3616440636465766871354c77305a6231637369640761746374756e6373696740", + "frame": "010100000025a3616256a3616440636465766871354c77305a6231637369640761746374756e6373696740" } ] } diff --git a/protocol/registry.yaml b/protocol/registry.yaml index 0cfdaecc..63b4de55 100644 --- a/protocol/registry.yaml +++ b/protocol/registry.yaml @@ -103,6 +103,10 @@ capabilities: # only and offers no FTW Cloud. It says nothing about whether the owner # turned FTW Cloud on; the box's grant decides that. - cloud.link + # The box takes its app session through FTW Cloud, end to end, as `tun` + # (docs/protocol-cloud.md, Tunnel, in srcfl/ftw). Absent means a phone + # reaches the box through the relay, even when it knows the home id. + - cloud.tun # The box can take a watch: it opens the relay's exchange socket and mints # watch codes. Absent means the phone offers no Add watch. The wire is # docs/watch-exchange.md in srcfl/ftw-webapp. @@ -274,7 +278,9 @@ client_errors: # the two into one enum cannot express "connected, but the inverter is quiet". # --------------------------------------------------------------------------- source_states: [live, lagging, stale, down, never] -carrier_states: [webrtc, relay, cloud, cache, none] +# tunnel is the box's own session carried by FTW Cloud, which cannot read it. +# cloud is the box-signed copy FTW Cloud holds and can read. +carrier_states: [webrtc, relay, tunnel, cloud, cache, none] # --------------------------------------------------------------------------- # History resolutions backed by what the box actually stores. diff --git a/shared/src/commonMain/kotlin/energy/ftw/identity/Enrollment.kt b/shared/src/commonMain/kotlin/energy/ftw/identity/Enrollment.kt index f0300931..b16bafe6 100644 --- a/shared/src/commonMain/kotlin/energy/ftw/identity/Enrollment.kt +++ b/shared/src/commonMain/kotlin/energy/ftw/identity/Enrollment.kt @@ -8,11 +8,18 @@ import energy.ftw.isDevHost * The enrollment payload. * * https://app.ftw.energy/p#v2.... + * https://app.ftw.energy/p#v3..... * * Everything after the `#` is a URL fragment and is never sent in an HTTP * request. The trust anchor reaches the app optically. + * + * v3 is v2 with the home id at FTW Cloud, base64url of its 26 characters, so + * a new phone can reach the box through FTW Cloud before any session with it + * (srcfl/ftw docs/protocol-cloud.md, "Pairing through the service"). Both + * are read. */ const val ENROLLMENT_VERSION = "v2" +const val ENROLLMENT_VERSION_HOME = "v3" const val BOX_KEY_BYTES = 32 const val PAIRING_CODE_BYTES = 16 const val RENDEZVOUS_SECRET_BYTES = 32 @@ -23,6 +30,8 @@ data class Enrollment( val pairingCode: ByteArray, val lanHint: String, val rendezvousSecret: ByteArray, + /** The home id at FTW Cloud. Null from a v2 code. */ + val home: String? = null, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -30,7 +39,8 @@ data class Enrollment( return boxStaticPublic.contentEquals(other.boxStaticPublic) && pairingCode.contentEquals(other.pairingCode) && lanHint == other.lanHint && - rendezvousSecret.contentEquals(other.rendezvousSecret) + rendezvousSecret.contentEquals(other.rendezvousSecret) && + home == other.home } override fun hashCode(): Int { @@ -38,10 +48,14 @@ data class Enrollment( h = 31 * h + pairingCode.contentHashCode() h = 31 * h + lanHint.hashCode() h = 31 * h + rendezvousSecret.contentHashCode() + h = 31 * h + (home?.hashCode() ?: 0) return h } } +/** A home id as FTW Cloud cuts one: 26 characters of lowercase base32. */ +fun isHomeId(s: String): Boolean = s.length == 26 && s.all { it in 'a'..'z' || it in '2'..'7' } + enum class EnrollmentErrorCode { E_QR_NOT_FTW, E_QR_VERSION, @@ -51,6 +65,7 @@ enum class EnrollmentErrorCode { E_QR_CODE, E_QR_HINT, E_QR_SECRET, + E_QR_HOME, } class EnrollmentError( @@ -69,7 +84,7 @@ private const val APP_TOO_OLD = private const val BOX_TOO_OLD = "This box needs a software update before it can pair. Update the box, then scan again." -private val OUR_VERSION = ENROLLMENT_VERSION.removePrefix("v").toInt() +private val OUR_VERSION = ENROLLMENT_VERSION_HOME.removePrefix("v").toInt() fun parseEnrollmentUrl(raw: String): Enrollment { val url = try { @@ -104,7 +119,7 @@ fun parseEnrollmentFragment(fragment: String): Enrollment { val parts = body.split('.') val version = parts[0] - if (version != ENROLLMENT_VERSION) { + if (version != ENROLLMENT_VERSION && version != ENROLLMENT_VERSION_HOME) { if (version.matches(Regex("^v\\d+$"))) { val n = version.substring(1).toInt() val help = if (n < OUR_VERSION) BOX_TOO_OLD else APP_TOO_OLD @@ -113,8 +128,9 @@ fun parseEnrollmentFragment(fragment: String): Enrollment { throw EnrollmentError("fragment does not start with a version", EnrollmentErrorCode.E_QR_NOT_FTW, WRONG_CODE) } - if (parts.size != 5) { - throw EnrollmentError("${parts.size} segments, expected 5", EnrollmentErrorCode.E_QR_SHAPE, SCAN_AGAIN) + val expected = if (version == ENROLLMENT_VERSION) 5 else 6 + if (parts.size != expected) { + throw EnrollmentError("${parts.size} segments, expected $expected", EnrollmentErrorCode.E_QR_SHAPE, SCAN_AGAIN) } val boxStaticPublic = decodeSegment(parts[1], "box key") @@ -147,18 +163,31 @@ fun parseEnrollmentFragment(fragment: String): Enrollment { ) } - return Enrollment(boxStaticPublic, pairingCode, lanHint, rendezvousSecret) + // Where the phone dials FTW Cloud, so only a home id the service could + // have cut is taken. + val home = if (parts.size == 6) { + val named = decodeSegment(parts[5], "home").decodeToString() + if (!isHomeId(named)) { + throw EnrollmentError("home is not a home id", EnrollmentErrorCode.E_QR_HOME, SCAN_AGAIN) + } + named + } else { + null + } + + return Enrollment(boxStaticPublic, pairingCode, lanHint, rendezvousSecret, home) } +/** v3 when the enrollment names a home, else v2. */ fun buildEnrollmentUrl(enrollment: Enrollment, origin: String = "https://$APP_HOST"): String { - val fragment = listOf( - ENROLLMENT_VERSION, + val segments = listOf( + if (enrollment.home == null) ENROLLMENT_VERSION else ENROLLMENT_VERSION_HOME, encodeBase64url(enrollment.boxStaticPublic), encodeBase64url(enrollment.pairingCode), encodeBase64url(enrollment.lanHint.encodeToByteArray()), encodeBase64url(enrollment.rendezvousSecret), - ).joinToString(".") - return "$origin$ENROLLMENT_PATH#$fragment" + ) + listOfNotNull(enrollment.home?.let { encodeBase64url(it.encodeToByteArray()) }) + return "$origin$ENROLLMENT_PATH#${segments.joinToString(".")}" } private fun decodeSegment(segment: String, what: String): ByteArray { diff --git a/shared/src/commonTest/kotlin/energy/ftw/identity/EnrollmentTest.kt b/shared/src/commonTest/kotlin/energy/ftw/identity/EnrollmentTest.kt index cd440579..318d3743 100644 --- a/shared/src/commonTest/kotlin/energy/ftw/identity/EnrollmentTest.kt +++ b/shared/src/commonTest/kotlin/energy/ftw/identity/EnrollmentTest.kt @@ -87,14 +87,45 @@ class EnrollmentTest { @Test fun versionDecidesWhoUpdates() { - val v3 = assertFailsWith { parseEnrollmentFragment(fragment("v3")) } - assertEquals(EnrollmentErrorCode.E_QR_VERSION, v3.code) - assertTrue(v3.help.contains("update the app", ignoreCase = true)) + val v4 = assertFailsWith { parseEnrollmentFragment(fragment("v4")) } + assertEquals(EnrollmentErrorCode.E_QR_VERSION, v4.code) + assertTrue(v4.help.contains("update the app", ignoreCase = true)) val v1 = assertFailsWith { parseEnrollmentFragment(fragment("v1")) } assertTrue(v1.help.contains("update the box", ignoreCase = true)) assertEquals(EnrollmentErrorCode.E_QR_NOT_FTW, codeOf { parseEnrollmentFragment(fragment("x1")) }) } + /** The same v3 code the iPhone's and the web app's tests read. */ + private val v3 = + "https://app.ftw.energy/p#v3.AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE.AgICAgICAgICAgICAgICAg." + + "MTAuMC4wLjI6ODA4MA.AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM.Zmp6eXI2N2J1enB5bmNxbWpsaXdnZXp2ZGU" + + @Test + fun readsAV3CodeAndTheHomeItNames() { + val parsed = parseEnrollmentUrl(v3) + assertEquals( + Enrollment(ByteArray(32) { 1 }, ByteArray(16) { 2 }, "10.0.0.2:8080", ByteArray(32) { 3 }, "fjzyr67buzpyncqmjliwgezvde"), + parsed, + ) + assertEquals(v3, buildEnrollmentUrl(parsed)) + assertEquals(null, parseEnrollmentUrl(buildEnrollmentUrl(payload)).home) + assertTrue("#v2." in buildEnrollmentUrl(payload)) + } + + @Test + fun aV3CodeMustNameAHome() { + val segments = v3.substringAfter('#').split('.') + val noHome = segments.dropLast(1).joinToString(".") + assertEquals(EnrollmentErrorCode.E_QR_SHAPE, codeOf { parseEnrollmentFragment(noHome) }) + assertEquals(EnrollmentErrorCode.E_QR_SHAPE, codeOf { parseEnrollmentFragment("$noHome.${segments.last()}.AA") }) + assertEquals(EnrollmentErrorCode.E_QR_SHAPE, codeOf { parseEnrollmentFragment(fragment() + "." + segments.last()) }) + val notAHome = encodeBase64url("NOT-A-HOME-AT-ALL-12345678".encodeToByteArray()) + assertEquals(EnrollmentErrorCode.E_QR_HOME, codeOf { parseEnrollmentFragment("$noHome.$notAHome") }) + val short = encodeBase64url("fjzyr67buzpyncqmjliwgezvd".encodeToByteArray()) + assertEquals(EnrollmentErrorCode.E_QR_HOME, codeOf { parseEnrollmentFragment("$noHome.$short") }) + assertEquals(EnrollmentErrorCode.E_QR_ENCODING, codeOf { parseEnrollmentFragment("$noHome.!!") }) + } + @Test fun rejectsShortSecret() { val short = encodeBase64url(ByteArray(16) { 11 })