Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions appleApp/FTW/FTWApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -58,6 +59,17 @@ struct FTWApp: App {
network = NetworkWatch { [weak model] in model?.networkOnline() }
}

/// The relay, or in a debug build `-FTWRelayURL <wss url>`: 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)
Expand Down
6 changes: 5 additions & 1 deletion appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)),
Expand Down
8 changes: 8 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 64 additions & 5 deletions appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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

Expand All @@ -46,6 +49,16 @@ public final class CloudCarrier {
private let scheduler: Scheduler
private let makeSocket: WebSocketFactory
private var looking: Bool
/// A new phone's pairing proof for message 1, while the roster does not
/// list it yet.
private let pair: Bytes?
/// The box session riding this one. Weak: the carrier stack above owns
/// it, and leaves when it closes.
weak var tunnel: TunnelCarrier?
/// The box session's message 1 that rode this dial's message 1, kept
/// until message 2 so a retry on the app's other service key carries it
/// again.
private var riding: CloudWire.Tun?
/// What the service last heard: message 1's `hz`, then each `watch`.
/// Nil until message 1 of this dial goes.
private var told: Bool?
Expand All @@ -62,16 +75,18 @@ public final class CloudCarrier {
private var retry: Cancellable?
private var deadline: Cancellable?
private var shutdown = false
private var dialledAtMs: Double = -.infinity

private var onSnapshot: @MainActor (CloudWire.Snapshot, Double) -> Void = { _, _ in }
private var onEnvelope: @MainActor (CloudWire.Envelope, Double) -> Void = { _, _ in }
private var onStatus: @MainActor (Status) -> Void = { _ in }

public init(endpoint: CloudEndpoint, home: String, deviceKey: Primitives.KeyPair, looking: Bool, scheduler: Scheduler, makeSocket: @escaping WebSocketFactory) {
public init(endpoint: CloudEndpoint, home: String, deviceKey: Primitives.KeyPair, looking: Bool, pair: Bytes? = nil, scheduler: Scheduler, makeSocket: @escaping WebSocketFactory) {
self.endpoint = endpoint
self.home = home
self.deviceKey = deviceKey
self.looking = looking
self.pair = pair
self.scheduler = scheduler
self.makeSocket = makeSocket
}
Expand Down Expand Up @@ -145,12 +160,36 @@ public final class CloudCarrier {
dial()
}

/// The box session woke with the app or the network: a socket that
/// looks open may be dead, so dial afresh, as the relay does. A dial
/// started within the last second is kept.
func redial() {
guard !shutdown else { return }
if status == .connecting, socket != nil, scheduler.nowMs - dialledAtMs < 1_000 { return }
retry?.cancel()
retry = nil
attempt = 0
refusals = 0
keyIndex = 0
drop()
dial()
}

/// One message of the box session, or false when this session is not
/// open and nothing went out.
@discardableResult
func sendTun(_ tun: CloudWire.Tun) -> Bool {
send(CloudWire.msgTun, tun.body)
}

public func close() {
guard !shutdown else { return }
shutdown = true
retry?.cancel()
deadline?.cancel()
drop()
tunnel?.cloudStatus(.closed(code: Self.closeNormal, retrying: false))
tunnel = nil
onSnapshot = { _, _ in }
onEnvelope = { _, _ in }
onStatus = { _ in }
Expand All @@ -161,6 +200,7 @@ public final class CloudCarrier {
private func dial() {
guard !shutdown else { return }
setStatus(.connecting)
dialledAtMs = scheduler.nowMs
generation += 1
let mine = generation
handshake = nil
Expand Down Expand Up @@ -193,7 +233,11 @@ public final class CloudCarrier {
setStatus(.closed(code: CloudWire.closeDenied, retrying: false))
return
}
let now = CloudWire.encodeUnsigned(t: CloudWire.msgNow, body: .map([("hz", .int(looking ? CloudWire.hzLooking : CloudWire.hzAway))]))
// The box session's message 1 rides along when it waits, so that
// session is up one round trip after TLS.
let tun = riding ?? tunnel?.takeFirst()
riding = tun
let now = CloudWire.now(hz: looking ? CloudWire.hzLooking : CloudWire.hzAway, pair: pair, tun: tun)
guard let msg1 = try? hs.writeMessage(now) else { return }
handshake = hs
socket.send(msg1)
Expand All @@ -216,6 +260,14 @@ public final class CloudCarrier {
}
// Only a delivered message proves the path works.
attempt = 0
if env.t == CloudWire.msgTun {
guard let tun = try? CloudWire.Tun(body: env.b) else {
fail(code: CloudWire.closeProtocol, retrying: true)
return
}
tunnel?.receive(tun)
return
}
onEnvelope(env, arrival)
return
}
Expand All @@ -228,6 +280,7 @@ public final class CloudCarrier {
}
deadline?.cancel()
handshake = nil
riding = nil
transport = NoiseTransport(keys)
attempt = 0
refusals = 0
Expand All @@ -248,13 +301,17 @@ public final class CloudCarrier {

private func closed(code: Int) {
let beforeMessage2 = transport == nil
let carried = riding
riding = nil
socket = nil
generation += 1
deadline?.cancel()
switch code {
case CloudWire.closeDenied where beforeMessage2 && keyIndex + 1 < endpoint.serviceKeys.count:
// Perhaps the service already runs on the app's other key.
// Perhaps the service already runs on the app's other key. The
// box session still waits for an answer to its message 1.
keyIndex += 1
riding = carried
dial()
case CloudWire.closeReplaced:
setStatus(.closed(code: code, retrying: false))
Expand Down Expand Up @@ -309,12 +366,14 @@ public final class CloudCarrier {
socket?.close()
socket = nil
handshake = nil
riding = nil
transport = nil
}

private func setStatus(_ next: Status) {
guard next != status else { return }
status = next
tunnel?.cloudStatus(next)
onStatus(next)
}
}
70 changes: 70 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading