From 61bbf0393d01993bcb9d1802888dafad08e78b56 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 17:21:31 +0200 Subject: [PATCH 1/5] iPhone: the box session rides FTW Cloud as tun, with the relay as fallback TunnelCarrier carries ftw.session.v1 inside the /v1/app session; its message 1 rides now.tun. FallbackCarrier moves to the relay when the tunnel does not carry. QR v3 names the home; hello_ok.cloud fills it in. The carrier is reported as tunnel. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/Carrier/Carrier.swift | 8 + .../Sources/FTWKit/Carrier/CloudCarrier.swift | 55 ++++- .../Sources/FTWKit/Carrier/CloudWire.swift | 70 ++++++ .../FTWKit/Carrier/FallbackCarrier.swift | 140 ++++++++++++ .../Sources/FTWKit/Carrier/NoiseCarrier.swift | 4 +- .../FTWKit/Carrier/TunnelCarrier.swift | 200 ++++++++++++++++++ .../Sources/FTWKit/Identity/CloudKeys.swift | 5 + .../Sources/FTWKit/Identity/Enrollment.swift | 45 +++- .../Sources/FTWKit/Identity/Pairing.swift | 1 + .../Sources/FTWKit/Protocol/Contract.swift | 7 +- .../Sources/FTWKit/State/AppModel.swift | 36 +++- .../Sources/FTWKit/State/EnergyNode.swift | 2 +- .../Sources/FTWKit/State/NodeLive.swift | 1 + .../Sources/FTWKit/State/NodeWords.swift | 1 + .../Sources/FTWKit/State/SiteModel.swift | 72 +++++-- .../Sources/FTWKit/State/WatchShare.swift | 2 +- .../FTWKit/Sources/FTWKit/Store/Sites.swift | 7 +- .../FTWKit/Sources/FTWShared/Freshness.swift | 12 +- .../FTWKit/Sources/FTWShared/Readings.swift | 14 +- .../Resources/sv.lproj/Localizable.strings | 5 + .../Sources/FTWShared/Watch/WatchGlance.swift | 2 +- .../Tests/FTWKitTests/ContractTests.swift | 2 +- .../Resources/cloud-app-vectors.json | 25 +++ .../FTWKitTests/Resources/cloud-vectors.json | 100 ++++++++- .../Resources/cloud-vectors.json | 70 +++++- protocol/registry.yaml | 8 +- 26 files changed, 837 insertions(+), 57 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/FallbackCarrier.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift 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..616761ca 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,12 @@ 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? /// 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 +71,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 +156,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 +196,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 +229,9 @@ 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 now = CloudWire.now(hz: looking ? CloudWire.hzLooking : CloudWire.hzAway, pair: pair, tun: tunnel?.takeFirst()) guard let msg1 = try? hs.writeMessage(now) else { return } handshake = hs socket.send(msg1) @@ -216,6 +254,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 } @@ -315,6 +361,7 @@ public final class CloudCarrier { 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..cf854ae2 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/FallbackCarrier.swift @@ -0,0 +1,140 @@ +import Foundation +import FTWShared + +/// 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 a frame from the box has come through +/// it, a refusal, an unreachable service or a session ended before any +/// 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. +/// +/// `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 + /// A frame from the box has come through the tunnel since it last + /// opened. + private var carrying = false + 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 = tunnel.status { + toRelay() + } 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() { active.beginSession() } + + public func wake() { 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) { + if !carrying { + carrying = true + wait?.cancel() + wait = nil + } + onFrame(bytes) + } + + private func tunnelStatus(_ s: CarrierStatus) { + guard !closed, !onRelay else { return } + if case .closed(_, let retryable) = s { + if !retryable || !carrying { + toRelay() + 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() + } + } + + private func toRelay() { + guard !closed, !onRelay, let makeRelay else { + if !closed, makeRelay == nil { setStatus(active.status) } + return + } + 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..f8f0fad2 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift @@ -0,0 +1,200 @@ +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 + + 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 + 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 + } + + @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 + } + attempt = 0 + 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 + + /// The box dropped the session, or FTW Cloud had no box to give it to. + /// A new one starts after a backoff, so a box that is away is not asked + /// in a loop. + private func end() { + first = nil + ended = true + 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..1169df07 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -139,6 +139,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 +168,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 +186,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, 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..48b7b38b 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? + /// A new phone's pairing proof for message 1, until the box has spoken. + @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,39 @@ 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 new phone needs before the + /// roster lists it. It is used only while the box has never spoken. + 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, neverHeard { + 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 +1081,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 = cloudIdentity?.home ?? storedHome 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 +1102,9 @@ extension SiteModel { } ) cloud = c + cloudHome = home cloudStatus = c.status - installPushSink() + if cloudOn { installPushSink() } c.start() } @@ -1077,13 +1112,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 } @@ -1113,10 +1151,18 @@ extension SiteModel { return } adoptCloud(identity, offset: nil) + cloudPair = 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/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/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/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. From f8d829c97182d0f42194856d497ea8f4a9358c3b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 17:31:03 +0200 Subject: [PATCH 2/5] iPhone tunnel: tests against Go's vectors, a fake service and box, and the whole app Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/FTWApp.swift | 12 + .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 6 +- .../Sources/FTWKit/State/AppModel.swift | 5 +- .../Sources/FTWKit/State/SiteModel.swift | 5 +- .../Tests/FTWKitTests/CloudCarrierTests.swift | 49 +- .../Tests/FTWKitTests/CloudWireTests.swift | 61 +- .../Tests/FTWKitTests/IdentityTests.swift | 39 +- .../FTWKitTests/TunnelCarrierTests.swift | 633 ++++++++++++++++++ .../Tests/FTWSharedTests/PushAlertTests.swift | 2 +- 9 files changed, 798 insertions(+), 14 deletions(-) create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/TunnelCarrierTests.swift 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/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index 1169df07..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) @@ -189,7 +192,7 @@ public final class AppModel { 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, home: stored.cloudHome, pairingCode: code) + 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 diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 48b7b38b..b36c315e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1137,8 +1137,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() 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/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/TunnelCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/TunnelCarrierTests.swift new file mode 100644 index 00000000..d3ef586b --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/TunnelCarrierTests.swift @@ -0,0 +1,633 @@ +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? + 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 } + 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] = [] + + init(paired: Bool = true, pair: Bytes? = nil) { + service = FakeCloudService(scheduler: scheduler) + service.tunBox = box + if paired { + service.roster = [device.publicKey] + box.paired = [device.publicKey] + } + cloud = CloudCarrier(endpoint: 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] s in self.statuses.append(s) } + ) + 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))) }) + } + + /// 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) + } + + /// 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) + } + + /// 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) + } + + /// 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) + #expect(world.service.nowBodies.count == 2) + #expect(world.service.nowBodies.last?["pair"] == nil) + } + + /// 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)") From 238ad2a917d3c6a57430432bee29b66102b61440 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 17:41:06 +0200 Subject: [PATCH 3/5] iPhone tunnel: a live test against ftwcloudd and a box, the relay unreachable; Android reads QR v3 Co-Authored-By: Claude Opus 5.5 --- .../Tests/FTWKitTests/LiveTunnelTests.swift | 103 ++++++++++++++++++ .../kotlin/energy/ftw/identity/Enrollment.kt | 49 +++++++-- .../energy/ftw/identity/EnrollmentTest.kt | 37 ++++++- 3 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/LiveTunnelTests.swift 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/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 }) From 49569498559ad777caa128c227d23ac616d88583 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 17:42:13 +0200 Subject: [PATCH 4/5] docs: the box session through FTW Cloud, the relay as fallback Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +++--- README.md | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) 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 From 42d7d23ce679501f03f8e3e0bfe37ab1ff9487aa Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 18:10:51 +0200 Subject: [PATCH 5/5] iPhone tunnel: trust it only after the box answers a transport frame; no restart loop on an end The box takes a device's slot on the phone's first transport frame, so its handshake answer alone does not show the session is this phone's. An end cannot say whether the session was replaced or unknown, so the backoff after one starts over only after a session carried for 30 s. From review: message 1 survives a retry on the other service key, a wake gives the tunnel the fallback's wait again, the kept home id wins over a cached identity, the pairing proof rides while a code is held, and the relay taking over is logged. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/Carrier/CloudCarrier.swift | 16 ++- .../FTWKit/Carrier/FallbackCarrier.swift | 56 ++++++--- .../FTWKit/Carrier/TunnelCarrier.swift | 26 +++- .../Sources/FTWKit/State/SiteModel.swift | 12 +- .../FTWKitTests/TunnelCarrierTests.swift | 114 +++++++++++++++++- 5 files changed, 193 insertions(+), 31 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift index 616761ca..1d32d7bb 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift @@ -55,6 +55,10 @@ public final class CloudCarrier { /// 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? @@ -231,7 +235,9 @@ public final class CloudCarrier { } // The box session's message 1 rides along when it waits, so that // session is up one round trip after TLS. - let now = CloudWire.now(hz: looking ? CloudWire.hzLooking : CloudWire.hzAway, pair: pair, tun: tunnel?.takeFirst()) + 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) @@ -274,6 +280,7 @@ public final class CloudCarrier { } deadline?.cancel() handshake = nil + riding = nil transport = NoiseTransport(keys) attempt = 0 refusals = 0 @@ -294,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)) @@ -355,6 +366,7 @@ public final class CloudCarrier { socket?.close() socket = nil handshake = nil + riding = nil transport = nil } diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/FallbackCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/FallbackCarrier.swift index cf854ae2..12c4a159 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/FallbackCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/FallbackCarrier.swift @@ -1,16 +1,24 @@ 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 a frame from the box has come through -/// it, a refusal, an unreachable service or a session ended before any -/// answer moves this carrier to the relay, and so does `tunnelWaitMs` -/// without one. A tunnel that carried and then dropped gets the same window +/// 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 @@ -27,9 +35,12 @@ public final class FallbackCarrier: Carrier { private var active: Carrier private let makeRelay: (@MainActor () -> Carrier)? private let scheduler: Scheduler - /// A frame from the box has come through the tunnel since it last - /// opened. + /// 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 @@ -48,8 +59,8 @@ public final class FallbackCarrier: Carrier { onFrame: { [weak self] bytes in self?.tunnelFrame(bytes) }, onStatus: { [weak self] s in self?.tunnelStatus(s) } ) - if case .closed = tunnel.status { - toRelay() + if case .closed(let reason, _) = tunnel.status { + toRelay(reason) } else { startWait() } @@ -63,9 +74,21 @@ public final class FallbackCarrier: Carrier { @discardableResult public func send(_ frame: Bytes) -> Bool { active.send(frame) } - public func beginSession() { active.beginSession() } + public func beginSession() { + framesThisSession = 0 + active.beginSession() + } - public func wake() { active.wake() } + /// 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 } @@ -80,7 +103,8 @@ public final class FallbackCarrier: Carrier { // MARK: Inside private func tunnelFrame(_ bytes: Bytes) { - if !carrying { + framesThisSession += 1 + if !carrying, framesThisSession >= 2 { carrying = true wait?.cancel() wait = nil @@ -90,9 +114,9 @@ public final class FallbackCarrier: Carrier { private func tunnelStatus(_ s: CarrierStatus) { guard !closed, !onRelay else { return } - if case .closed(_, let retryable) = s { + if case .closed(let reason, let retryable) = s { if !retryable || !carrying { - toRelay() + toRelay(reason) return } carrying = false @@ -105,15 +129,17 @@ public final class FallbackCarrier: Carrier { wait?.cancel() wait = scheduler.after(Self.tunnelWaitMs) { [weak self] in guard let self, !self.carrying else { return } - self.toRelay() + self.toRelay("no answer through FTW Cloud") } } - private func toRelay() { + 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 diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift index f8f0fad2..f882cd10 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/TunnelCarrier.swift @@ -18,6 +18,10 @@ import FTWShared 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 @@ -32,6 +36,12 @@ public final class TunnelCarrier: Carrier { /// 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 @@ -58,6 +68,8 @@ public final class TunnelCarrier: Carrier { public func beginSession() { sid = UInt32.random(in: 1...UInt32.max) first = nil + framesThisSession = 0 + carryingSinceMs = nil } @discardableResult @@ -115,7 +127,8 @@ public final class TunnelCarrier: Carrier { end() return } - attempt = 0 + framesThisSession += 1 + if framesThisSession == 2 { carryingSinceMs = scheduler.nowMs } onFrame(tun.d) } @@ -146,12 +159,17 @@ public final class TunnelCarrier: Carrier { // MARK: Inside - /// The box dropped the session, or FTW Cloud had no box to give it to. - /// A new one starts after a backoff, so a box that is away is not asked - /// in a loop. + /// 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) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index b36c315e..a491f29e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -156,7 +156,7 @@ public final class SiteModel { /// 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? - /// A new phone's pairing proof for message 1, until the box has spoken. + /// 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. @@ -1046,8 +1046,9 @@ extension SiteModel { /// 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 new phone needs before the - /// roster lists it. It is used only while the box has never spoken. + /// `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 @@ -1055,7 +1056,7 @@ extension SiteModel { cloudEndpoint = endpoint if let home, CloudWire.validHome(home) { storedHome = home } cloudPair = nil - if let code = pairingCode, let home = storedHome, neverHeard { + if let code = pairingCode, let home = storedHome { cloudPair = CloudWire.pairingHash(home: home, code: code) } startCloud() @@ -1088,7 +1089,7 @@ extension SiteModel { private func startCloud() { guard cloud == nil, !destroyed, let endpoint = cloudEndpoint, let device = cloudDevice, - let socket = cloudSocket, let home = cloudIdentity?.home ?? storedHome else { return } + 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) }, @@ -1152,7 +1153,6 @@ extension SiteModel { return } adoptCloud(identity, offset: nil) - cloudPair = nil if identity.home != storedHome { storedHome = identity.home learnedHome?(identity.home) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/TunnelCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/TunnelCarrierTests.swift index d3ef586b..62d51a36 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/TunnelCarrierTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/TunnelCarrierTests.swift @@ -14,6 +14,9 @@ final class FakeTunnelBox { 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] = [] @@ -39,6 +42,10 @@ final class FakeTunnelBox { } 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) @@ -126,15 +133,18 @@ final class TunnelRig { 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) { + 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: service.endpoint, home: Self.home, deviceKey: device, looking: true, pair: pair, scheduler: scheduler, makeSocket: service.factory) + 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 }) } @@ -153,7 +163,10 @@ final class TunnelRig { prologue: box.prologue, handshakePayload: code, scheduler: scheduler) noise.setHandlers( onFrame: { [unowned self] f in self.frames.append(f) }, - onStatus: { [unowned self] s in self.statuses.append(s) } + onStatus: { [unowned self, weak noise] s in + self.statuses.append(s) + if self.sayHello, s.isOpen { noise?.send([0x68]) } + } ) return noise } @@ -199,6 +212,22 @@ final class TunnelRig { #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 { @@ -254,6 +283,46 @@ final class TunnelRig { #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 { @@ -365,6 +434,22 @@ private func holds(_ hay: Bytes, _ needle: Bytes) -> Bool { #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() @@ -450,6 +535,25 @@ private func holds(_ hay: Bytes, _ needle: Bytes) -> Bool { #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() @@ -587,8 +691,10 @@ private func holds(_ hay: Bytes, _ needle: Bytes) -> Bool { #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.nowBodies.last?["pair"] == nil) + #expect(world.service.pending.isEmpty) } /// A box whose hello does not say `cloud.tun` is reached through the