From b026f1aa5b168957aa78cee819adb4c859976950 Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 00:51:10 -0700 Subject: [PATCH 01/14] feat(apple): the tailnet login on every public identity surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 025 §3.6 (D7) and VibeField petition T4: the Apple plane could not say who was signed in. A grep for LoginName over apple/ was empty, so the phone could not label a peer as its own account's, nor scope account-owned state. The login is now a first-class field, optional and honest — absent, never fabricated. An empty string on the wire becomes nil, because RFC 022's honesty rule forbids surfacing "" as an identity and RFC 025 §3.2 fails closed on an empty login. - AuthenticatedPeer gains loginName/displayName (the WhoIs answer's UserProfile); BackendPeer and BackendStatus gain loginName (a peer's, and the node's own). Every new init parameter defaults to nil, so no existing call site changes. - Peer.loginName joins the public snapshot AND its equality, so a SwiftUI row re-renders when a login appears. Hashing stays ref-only. - MeshModel.loginName projects the node's own login beside phase/peers. - LoopbackBackend grows the seams the gate's tests need: a per-node login on join, setLogin for a profile switch, and setWithholdWhoIsLogin to model a WhoIs answer that resolves a node ID but no login. FINDING — RFC 025 §3.3 specified the Swift mapping as status.User[String(peer.UserID)]. That is not expressible: the pinned TailscaleKit decodes IpnState.PeerStatus WITHOUT UserID, and Status.SelfStatus is a PeerStatus too, so neither a peer's nor the node's own login can be keyed out of Status.User — which does exist, and is keyed by the stringified user id, with nothing to key it. Verified against the vendored source (.vendor/libtailscale-59d4bb82…/swift/TailscaleKit/LocalAPI/Types.swift:230-249), the shipped arm64-apple-ios.swiftinterface, and this repo's own libtailscale patches (which touch only NotifyWatchOpt). RFC 025 §3.3 now carries a dated correction at its source. tsnet's JSON does carry UserID; TailscaleKit drops it at decode, and LocalAPIClient.backendStatus() returns a decoded Status with no raw-bytes variant. So TailscaleKitBackend.refreshStatus now also GETs /localapi/v0/status through the same authenticated loopback the WhoIs path uses, decodes only Self.UserID / Peer[].{ID,UserID} / User{}, and overlays the logins onto the mapped BackendStatus. Cost: one extra loopback GET per status refresh. A failed overlay leaves every login nil, so a gated node admits nobody — the fail-closed answer. When PeerStatus grows UserID the overlay collapses back into the decoder. The decoders live in Sources/TruffleTailscale/LocalAPIIdentity.swift, OUTSIDE TailscaleKitBackend's `#if os(iOS) && canImport(TailscaleKit)` — the same reason TailscaleEndpoint is its own file. That guard compiles to nothing on the macOS host, so decoding placed inside it has no test anywhere. #188's WhoIs address normalisation is untouched; its LocalAPI HTTP plumbing is factored into a shared localAPIGet and both callers use it, with the WhoIs error strings byte-identical. What is real: every field is wired end to end and covered by tests on the macOS host. What is NOT witnessed: the production LocalAPI reads themselves — TailscaleKitBackend is iOS-only and has no device gate in this lane, so the status overlay and the WhoIs profile are proven by their decoders and by compilation for both iOS slices, not by a live tailnet. Gates: swift test --package-path apple 70 -> 95 tests in 17 suites, exit 0 · root "as published" swift package resolve + build + test likewise 95/17 · xcodebuild -scheme TruffleTailscale for both generic/platform=iOS and generic/platform=iOS Simulator BUILD SUCCEEDED, zero warnings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- .../Truffle/Backend/LoopbackBackend.swift | 31 +++- .../Truffle/Backend/NetworkBackend.swift | 32 ++++- apple/Sources/Truffle/Mesh/Peer.swift | 9 +- apple/Sources/TruffleSwiftUI/MeshModel.swift | 5 + .../TruffleTailscale/LocalAPIIdentity.swift | 132 ++++++++++++++++++ .../TailscaleKitBackend.swift | 82 +++++++---- 6 files changed, 254 insertions(+), 37 deletions(-) create mode 100644 apple/Sources/TruffleTailscale/LocalAPIIdentity.swift diff --git a/apple/Sources/Truffle/Backend/LoopbackBackend.swift b/apple/Sources/Truffle/Backend/LoopbackBackend.swift index a30f9f91..775ff1e5 100644 --- a/apple/Sources/Truffle/Backend/LoopbackBackend.swift +++ b/apple/Sources/Truffle/Backend/LoopbackBackend.swift @@ -15,6 +15,10 @@ public actor LoopbackNetwork { var hostname: String var ip: String var online: Bool + /// The node owner's tailnet login (RFC 025 §3.3). `nil` models a + /// backend that cannot report one — the gate's fail-closed row. + var loginName: String? + var displayName: String? /// Hidden nodes can dial and are WhoIs-resolvable but never appear /// in snapshots or join announcements — simulates an inbound hello /// racing ahead of the netmap (RFC 024 §7.2). @@ -29,6 +33,9 @@ public actor LoopbackNetwork { /// When true, `whoIs` reports no concrete identity (exercises the /// fail-closed path). private var withholdWhoIs = false + /// When true, `whoIs` still reports a concrete node ID but no login — + /// the RFC 025 §3.4 row "`nodeId` ok, `loginName` absent". + private var withholdWhoIsLogin = false public init() {} @@ -36,17 +43,28 @@ public actor LoopbackNetwork { withholdWhoIs = value } + public func setWithholdWhoIsLogin(_ value: Bool) { + withholdWhoIsLogin = value + } + + /// Change a registered node's login after `join` (a profile switch). + public func setLogin(tailscaleId: String, loginName: String?) { + nodes[tailscaleId]?.loginName = loginName + } + /// Register a node and return its backend. `hostname` should follow the /// `truffle-{appId}-{slug}` scheme for discovery (RFC 024 §7.2). /// `hidden` nodes stay out of snapshots/announcements (raced-netmap /// simulation) until `reveal(tailscaleId:)`. public func join( - tailscaleId: String, hostname: String, hidden: Bool = false + tailscaleId: String, hostname: String, hidden: Bool = false, + loginName: String? = nil, displayName: String? = nil ) -> LoopbackBackend { let ip = "100.64.0.\(nextIP)" nextIP += 1 let node = Node( - tailscaleId: tailscaleId, hostname: hostname, ip: ip, online: true, hidden: hidden) + tailscaleId: tailscaleId, hostname: hostname, ip: ip, online: true, + loginName: loginName, displayName: displayName, hidden: hidden) nodes[tailscaleId] = node let backend = LoopbackBackend(network: self, tailscaleId: tailscaleId, ip: ip) nodes[tailscaleId]?.backend = backend @@ -90,7 +108,8 @@ public actor LoopbackNetwork { hostname: node.hostname, dnsName: "\(node.hostname).loopback.ts.net", tailnetIPs: [node.ip], - online: node.online) + online: node.online, + loginName: node.loginName) } func snapshot(for selfId: String) -> BackendStatus { @@ -105,6 +124,7 @@ public actor LoopbackNetwork { dnsName: "\(me.hostname).loopback.ts.net", tailnetIPs: [me.ip], tailscaleId: me.tailscaleId, + loginName: me.loginName, peers: peers) } @@ -159,7 +179,10 @@ public actor LoopbackNetwork { let ip = remoteEndpoint.split(separator: ":").first.map(String.init) ?? "" let match = nodes.values.first { $0.ip == ip } return AuthenticatedPeer( - tailscaleId: match?.tailscaleId ?? "", remoteAddresses: [remoteEndpoint]) + tailscaleId: match?.tailscaleId ?? "", + remoteAddresses: [remoteEndpoint], + loginName: withholdWhoIsLogin ? nil : match?.loginName, + displayName: withholdWhoIsLogin ? nil : match?.displayName) } } diff --git a/apple/Sources/Truffle/Backend/NetworkBackend.swift b/apple/Sources/Truffle/Backend/NetworkBackend.swift index bf8fc7d6..dc7a787a 100644 --- a/apple/Sources/Truffle/Backend/NetworkBackend.swift +++ b/apple/Sources/Truffle/Backend/NetworkBackend.swift @@ -31,13 +31,30 @@ public protocol MeshListener: Sendable { /// stable Tailscale node ID and the normalized remote addresses used for the /// comparison. An empty `tailscaleId` means WhoIs produced no concrete /// identity — the production inbound policy fails closed on that. +/// +/// `loginName` / `displayName` carry the caller's tailnet user profile +/// (RFC 025 §3.6, D7). Both are ABSENT, never fabricated: a WhoIs answer with +/// no user profile — or with an empty string in one — leaves the field `nil`. +/// A tagged node reports Tailscale's `tagged-devices` pseudo-login, which is +/// passed through unchanged rather than special-cased. public struct AuthenticatedPeer: Sendable, Hashable { public let tailscaleId: String public let remoteAddresses: [String] + /// The caller's tailnet login (`UserProfile.LoginName`), e.g. + /// `alice@corp.com`. The login gate's only authority — never + /// self-declared (RFC 025 §3.7, D8). + public let loginName: String? + /// The caller's human-readable profile name (`UserProfile.DisplayName`). + public let displayName: String? - public init(tailscaleId: String, remoteAddresses: [String]) { + public init( + tailscaleId: String, remoteAddresses: [String], loginName: String? = nil, + displayName: String? = nil + ) { self.tailscaleId = tailscaleId self.remoteAddresses = remoteAddresses + self.loginName = loginName + self.displayName = displayName } } @@ -48,16 +65,21 @@ public struct BackendPeer: Sendable, Equatable { public var dnsName: String? public var tailnetIPs: [String] public var online: Bool + /// The login of the tailnet user who owns this node (RFC 025 §3.3) — + /// a netmap fact, `nil` when the backend cannot report one. A gated node + /// treats a row without a login as NOT a peer (fail closed). + public var loginName: String? public init( tailscaleId: String, hostname: String, dnsName: String? = nil, - tailnetIPs: [String] = [], online: Bool = true + tailnetIPs: [String] = [], online: Bool = true, loginName: String? = nil ) { self.tailscaleId = tailscaleId self.hostname = hostname self.dnsName = dnsName self.tailnetIPs = tailnetIPs self.online = online + self.loginName = loginName } } @@ -71,12 +93,15 @@ public struct BackendStatus: Sendable, Equatable { public var tailnetIPs: [String] /// Our own stable Tailscale node ID (empty until known). public var tailscaleId: String + /// The login this node is signed in as (RFC 025 §3.6, D7) — `nil` until + /// known, never fabricated. A tagged node reports `tagged-devices`. + public var loginName: String? public var peers: [BackendPeer] public init( running: Bool = false, needsLogin: Bool = false, needsMachineAuth: Bool = false, authURL: String? = nil, dnsName: String? = nil, tailnetIPs: [String] = [], - tailscaleId: String = "", peers: [BackendPeer] = [] + tailscaleId: String = "", loginName: String? = nil, peers: [BackendPeer] = [] ) { self.running = running self.needsLogin = needsLogin @@ -85,6 +110,7 @@ public struct BackendStatus: Sendable, Equatable { self.dnsName = dnsName self.tailnetIPs = tailnetIPs self.tailscaleId = tailscaleId + self.loginName = loginName self.peers = peers } } diff --git a/apple/Sources/Truffle/Mesh/Peer.swift b/apple/Sources/Truffle/Mesh/Peer.swift index 538029fe..ddb2a795 100644 --- a/apple/Sources/Truffle/Mesh/Peer.swift +++ b/apple/Sources/Truffle/Mesh/Peer.swift @@ -44,6 +44,11 @@ public struct Peer: Identifiable, Hashable, Sendable { public let tailscaleId: String public let generation: UInt64 + /// The login of the tailnet user who owns this node (RFC 025 §3.6, D7) — + /// `nil` when Layer 3 reported none. On a gated node every listed peer + /// matched the node's allow-list, so this is the login that passed it. + public let loginName: String? + public let displayName: String public let hostname: String public let tailnetIPs: [String] @@ -54,12 +59,13 @@ public struct Peer: Identifiable, Hashable, Sendable { init( ref: PeerRef, deviceId: String?, tailscaleId: String, generation: UInt64, displayName: String, hostname: String, tailnetIPs: [String], online: Bool, - appId: String?, isLocal: Bool + appId: String?, isLocal: Bool, loginName: String? = nil ) { self.ref = ref self.deviceId = deviceId self.tailscaleId = tailscaleId self.generation = generation + self.loginName = loginName self.displayName = displayName self.hostname = hostname self.tailnetIPs = tailnetIPs @@ -81,6 +87,7 @@ public struct Peer: Identifiable, Hashable, Sendable { && lhs.tailnetIPs == rhs.tailnetIPs && lhs.online == rhs.online && lhs.appId == rhs.appId + && lhs.loginName == rhs.loginName } public func hash(into hasher: inout Hasher) { diff --git a/apple/Sources/TruffleSwiftUI/MeshModel.swift b/apple/Sources/TruffleSwiftUI/MeshModel.swift index 676be2e8..9df90495 100644 --- a/apple/Sources/TruffleSwiftUI/MeshModel.swift +++ b/apple/Sources/TruffleSwiftUI/MeshModel.swift @@ -16,6 +16,9 @@ import Truffle public final class MeshModel { public private(set) var phase: MeshPhase = .stopped public private(set) var peers: [Peer] = [] + /// The tailnet login this node is signed in as (RFC 025 §3.6, D7), as of + /// the last Layer 3 status. `nil` until known — never fabricated. + public private(set) var loginName: String? public private(set) var authURL: URL? public private(set) var lastError: String? @@ -83,10 +86,12 @@ public final class MeshModel { authURL = nil } peers = await node.peers() + loginName = await node.loginName case .authRequired(let url): authURL = url case .peerUpsert, .peerLeft: peers = await node.peers() + loginName = await node.loginName case .message: break // chat-level concerns live in the host app case .health(let message): diff --git a/apple/Sources/TruffleTailscale/LocalAPIIdentity.swift b/apple/Sources/TruffleTailscale/LocalAPIIdentity.swift new file mode 100644 index 00000000..76417887 --- /dev/null +++ b/apple/Sources/TruffleTailscale/LocalAPIIdentity.swift @@ -0,0 +1,132 @@ +import Foundation + +/// Pure decoding of the two LocalAPI answers that carry tailnet **identity** +/// (RFC 025 §3.3/§3.6): the WhoIs response for an accepted connection, and +/// the login half of the node status. +/// +/// These types live outside `TailscaleKitBackend.swift`'s +/// `#if os(iOS) && canImport(TailscaleKit)` on purpose — the same reason +/// `TailscaleEndpoint` does. The decoding is the part that can be wrong in a +/// way tests can catch, and the macOS test target is the only place that runs. +/// +/// ## Why the status logins are read separately +/// +/// TailscaleKit's `IpnState.PeerStatus` models neither `UserID` nor a login, +/// and `IpnState.Status.SelfStatus` is a `PeerStatus` too, so +/// `status.User[String(peer.UserID)]` — the mapping RFC 025 §3.3 specifies — +/// cannot be expressed against the vendored binding at all: the field the map +/// is keyed by is dropped at decode. The JSON tsnet serves does carry it, so +/// the logins are read from the same `/localapi/v0/status` endpoint with a +/// decoder that keeps `UserID`, and merged onto the mapped `BackendStatus`. +/// Absent, never fabricated: a failed or partial read leaves the fields `nil`, +/// and a gated node then admits nobody (fail closed, RFC 025 §3.2). + +// MARK: - WhoIs + +/// A LocalAPI `/localapi/v0/whois` answer (`tailscale.com/client/tailscale/apitype`). +/// +/// `UserProfile` is optional: a tagged node or an unresolvable caller has +/// none, and the fields stay `nil` rather than becoming empty strings. +struct WhoIsResponse: Decodable, Equatable { + struct NodeInfo: Decodable, Equatable { + let StableID: String + let Addresses: [String]? + } + + struct UserProfileInfo: Decodable, Equatable { + let LoginName: String? + let DisplayName: String? + } + + let Node: NodeInfo + let UserProfile: UserProfileInfo? + + /// The caller's login, or `nil` when absent or empty on the wire. + var loginName: String? { LocalAPIIdentity.present(UserProfile?.LoginName) } + /// The caller's profile name, or `nil` when absent or empty on the wire. + var displayName: String? { LocalAPIIdentity.present(UserProfile?.DisplayName) } +} + +// MARK: - Status logins + +/// The login-bearing subset of a LocalAPI `/localapi/v0/status` answer. +/// +/// Only the fields RFC 025 §3.3 needs are modelled; everything else in the +/// status keeps coming from TailscaleKit's own decode, which stays the +/// authority for the rest of `BackendStatus`. +struct LocalAPIStatusLogins: Decodable, Equatable { + struct NodeRow: Decodable, Equatable { + let ID: String? + let UserID: Int64? + } + + struct Profile: Decodable, Equatable { + let LoginName: String? + let DisplayName: String? + } + + let SelfStatus: NodeRow? + let Peer: [String: NodeRow]? + let User: [String: Profile]? + + enum CodingKeys: String, CodingKey { + case Peer, User + case SelfStatus = "Self" + } +} + +/// The logins a status answer yields, keyed the way `BackendStatus` is: the +/// node's own login, and each peer's by **stable node ID**. +struct LoginOverlay: Equatable, Sendable { + var selfLogin: String? + var byStableNodeId: [String: String] + + init(selfLogin: String? = nil, byStableNodeId: [String: String] = [:]) { + self.selfLogin = selfLogin + self.byStableNodeId = byStableNodeId + } + + /// Resolve every node row's `UserID` through the status's user map. + /// A row with no `UserID`, no `ID`, or no matching profile contributes + /// nothing — its peer keeps a `nil` login. + init(_ decoded: LocalAPIStatusLogins) { + func login(for row: LocalAPIStatusLogins.NodeRow?) -> String? { + guard let userID = row?.UserID else { return nil } + return LocalAPIIdentity.present(decoded.User?[String(userID)]?.LoginName) + } + selfLogin = login(for: decoded.SelfStatus) + var byStableNodeId: [String: String] = [:] + for row in decoded.Peer?.values ?? [String: LocalAPIStatusLogins.NodeRow]().values { + guard let stableID = LocalAPIIdentity.present(row.ID), let name = login(for: row) + else { continue } + byStableNodeId[stableID] = name + } + self.byStableNodeId = byStableNodeId + } + + /// Merge onto a mapped status. This overlay is the authority for the + /// login fields of the snapshot it was read with: a peer it has no login + /// for gets `nil`, never a stale value from an earlier read. + func applied(to status: BackendStatus) -> BackendStatus { + var merged = status + merged.loginName = selfLogin + merged.peers = status.peers.map { peer in + var row = peer + row.loginName = byStableNodeId[peer.tailscaleId] + return row + } + return merged + } +} + +// MARK: - Shared helpers + +enum LocalAPIIdentity { + /// `nil` for an absent OR empty string: an empty login is not a login + /// (RFC 025 §3.2 fails closed on it), and RFC 022's honesty rule forbids + /// surfacing `""` as if it were an identity. + static func present(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return value + } +} diff --git a/apple/Sources/TruffleTailscale/TailscaleKitBackend.swift b/apple/Sources/TruffleTailscale/TailscaleKitBackend.swift index 02564a45..bedca275 100644 --- a/apple/Sources/TruffleTailscale/TailscaleKitBackend.swift +++ b/apple/Sources/TruffleTailscale/TailscaleKitBackend.swift @@ -151,10 +151,50 @@ } public func whoIs(remoteEndpoint: String) async throws -> AuthenticatedPeer { - guard let node else { throw MeshError.stopped } + guard node != nil else { throw MeshError.stopped } guard let endpoint = TailscaleEndpoint(remoteEndpoint) else { throw MeshError.protocolViolation("invalid accepted Tailscale endpoint") } + let data = try await localAPIGet( + path: "/localapi/v0/whois", + queryItems: [URLQueryItem(name: "addr", value: endpoint.whoIsAddress)], + label: "WhoIs") + let decoded = try JSONDecoder().decode(WhoIsResponse.self, from: data) + let stableID = decoded.Node.StableID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !stableID.isEmpty else { + throw MeshError.protocolViolation("LocalAPI WhoIs returned no stable node ID") + } + let addresses = decoded.Node.Addresses?.compactMap { + TailscaleEndpoint(Self.stripPrefix($0))?.ip + } ?? [] + guard addresses.contains(endpoint.ip) else { + throw MeshError.protocolViolation( + "LocalAPI WhoIs address does not match accepted endpoint") + } + return AuthenticatedPeer( + tailscaleId: stableID, + remoteAddresses: addresses + [remoteEndpoint], + loginName: decoded.loginName, + displayName: decoded.displayName) + } + + /// The tailnet logins for the current netmap (RFC 025 §3.3), read + /// from the same status endpoint TailscaleKit reads — with a decoder + /// that keeps `UserID`, which its binding drops. See + /// `LocalAPIIdentity.swift` for why this cannot come from + /// `IpnState.Status`. + private func statusLogins() async throws -> LoginOverlay { + let data = try await localAPIGet( + path: "/localapi/v0/status", queryItems: nil, label: "status") + return LoginOverlay( + try JSONDecoder().decode(LocalAPIStatusLogins.self, from: data)) + } + + /// One authenticated GET against this node's LocalAPI loopback. + private func localAPIGet( + path: String, queryItems: [URLQueryItem]?, label: String + ) async throws -> Data { + guard let node else { throw MeshError.stopped } let (sessionConfiguration, loopback) = try await URLSessionConfiguration.tailscaleSession(node) guard let ip = loopback.ip, let port = loopback.port else { @@ -164,10 +204,10 @@ components.scheme = "http" components.host = ip components.port = port - components.path = "/localapi/v0/whois" - components.queryItems = [URLQueryItem(name: "addr", value: endpoint.whoIsAddress)] + components.path = path + components.queryItems = queryItems guard let url = components.url else { - throw MeshError.transport("could not form LocalAPI WhoIs URL") + throw MeshError.transport("could not form LocalAPI \(label) URL") } var request = URLRequest(url: url) request.timeoutInterval = 15 @@ -178,23 +218,9 @@ .data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - throw MeshError.transport("LocalAPI WhoIs failed with HTTP \(status)") - } - let decoded = try JSONDecoder().decode(WhoIsResponse.self, from: data) - let stableID = decoded.Node.StableID.trimmingCharacters(in: .whitespacesAndNewlines) - guard !stableID.isEmpty else { - throw MeshError.protocolViolation("LocalAPI WhoIs returned no stable node ID") + throw MeshError.transport("LocalAPI \(label) failed with HTTP \(status)") } - let addresses = decoded.Node.Addresses?.compactMap { - TailscaleEndpoint(Self.stripPrefix($0))?.ip - } ?? [] - guard addresses.contains(endpoint.ip) else { - throw MeshError.protocolViolation( - "LocalAPI WhoIs address does not match accepted endpoint") - } - return AuthenticatedPeer( - tailscaleId: stableID, - remoteAddresses: addresses + [remoteEndpoint]) + return data } public func makeURLSession( @@ -296,7 +322,13 @@ private func refreshStatus(emitChange: Bool) async throws -> BackendStatus { guard let localAPI else { throw MeshError.stopped } let raw = try await localAPI.backendStatus() - let mapped = Self.map(raw) + var mapped = Self.map(raw) + // The logins TailscaleKit's status binding drops (RFC 025 §3.3). + // A failed read leaves them nil — absent, never fabricated; a + // gated node then admits nobody, which is the fail-closed answer. + if let overlay = try? await statusLogins() { + mapped = overlay.applied(to: mapped) + } if emitChange, mapped != latest { emit(.status(mapped)) } if let authURL = mapped.authURL.flatMap(URL.init(string:)) { emit(.authRequired(authURL)) @@ -394,14 +426,6 @@ } } - private struct WhoIsResponse: Decodable { - struct NodeInfo: Decodable { - let StableID: String - let Addresses: [String]? - } - let Node: NodeInfo - } - private struct TailscaleLogAdapter: LogSink, @unchecked Sendable { let logFileHandle: Int32? = nil private let logger: (any MeshLogger)? From 84fa42a7d81b1b909b55d1240290e5f3825f7e4b Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 00:51:41 -0700 Subject: [PATCH 02/14] =?UTF-8?q?feat(apple):=20the=20login=20gate=20?= =?UTF-8?q?=E2=80=94=20the=20tailnet=20login=20as=20the=20mesh=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 025 §3.1-§3.4 (D1-D5) and VibeField petition T5, Swift half. Until now a Swift node admitted every tailnet node carrying the app's hostname prefix, and its hello admitted any caller that claimed the app id. The Swift core is a truffle peer that speaks the same hello, so without this gate a foreign desktop could dial the phone's listener and feed its store. A node declares the gate once, for its lifetime: MeshConfiguration(appId: ..., deviceName: ..., loginAllow: ["*@corp.com"]) Empty (the default) is today's behaviour EXACTLY: the whole tailnet, hostname-prefix discovery, and the existing IdentityPolicy alone on the inbound path. Nothing on the wire changes for an ungated node, and the hello envelope stays at version 2 — the login is never self-declared, WhoIs is its only authority (D8). - LoginGlob (Identity/LoginGlob.swift) is the grammar: a faithful port of Go's path.Match — scanChunk / matchChunk / getEsc — walking Unicode SCALARS, not grapheme clusters, so `?` and the class ranges count and order exactly what Go's rune and Rust's char do. match(_:_:) is the case-sensitive primitive and throws BadPattern rather than trapping; allowed(_:login:) is the gate — empty list true, absent or empty login under a non-empty list false, malformed globs skipped. One grammar, three planes. - Layer 3: MeshNode.upsertFromLayer3 admits a row only if Hostname.isAppPeer(...) && LoginGlob.allowed(loginAllow, login:). On a gated node a row with NO login is not a peer. Provisional entries from a raced inbound hello keep merging as before — that hello already passed the gate at Handshake.server, so re-gating here would drop a peer the node has a live authenticated session with. - The hello: Handshake.server gains loginAllow (defaulted, so every existing caller and test compiles unchanged) and implements §3.4's table in order — validate hello, then absent authenticated identity -> 4003, then claimed tailscale_id mismatch -> 4003, then login absent or matching no glob -> 4004 (SessionCloseCode.loginRefused, MeshError.loginRefused(login:)). All of it BEFORE our hello is sent, so a refused caller never learns our identity block. The dialing side needs no new check: a gated node only dials peers Layer 3 reported, and Layer 3 filtered them. - A gate is never bypassed by the test policy: on a gated node an absent authenticated identity is refused under EITHER IdentityPolicy, including .allowUnverified, because without WhoIs there is no login to gate on. - A tagged node's tagged-devices pseudo-login is passed through, not special-cased: refused by a personal glob, admitted by one that names it. MeshNode also carries its T4 half here — the self login from the last status (MeshNode.loginName), the registry entry's login, and makePeer/localPeer projecting it — because the same file owns both and splitting the hunks would leave neither commit building. Honest degradation rather than a silent empty mesh: where the Rust provider refuses to start when its sidecar cannot report logins (§3.3), a Swift node has no sidecar to interrogate, so a gated node that sees a login-less app peer in a Layer 3 snapshot emits ONE .health notice. Note this fires from apply(status:), which is the only Layer 3 path production takes — TailscaleKitBackend emits .status, .authRequired and .health, never .peerUpsert; the .peerUpsert path is the loopback's. Close-code docs corrected at their source: SessionCloseCode gains 4004 and says 4003 now also covers a gated node under any policy; HelloValidationError records that validation covers only 4001/4002 and that the refusals depending on evidence outside the hello are decided afterwards in Handshake.server. Tested by the following commit: both of the Rust port's glob tables verbatim, every row of the §3.4 table asserted on the close code the CLIENT sees, and a gated loopback pair. All three gates green — swift test --package-path apple and the root "as published" manifest at 95 tests in 17 suites, and both iOS slices BUILD SUCCEEDED. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- .../Sources/Truffle/Identity/LoginGlob.swift | 236 ++++++++++++++++++ apple/Sources/Truffle/Mesh/MeshError.swift | 3 + apple/Sources/Truffle/Mesh/MeshNode.swift | 62 ++++- apple/Sources/Truffle/Mesh/MeshTypes.swift | 21 ++ apple/Sources/Truffle/Session/Handshake.swift | 42 +++- .../Sources/Truffle/Wire/HelloEnvelope.swift | 8 + .../Sources/Truffle/Wire/SessionLimits.swift | 7 +- 7 files changed, 367 insertions(+), 12 deletions(-) create mode 100644 apple/Sources/Truffle/Identity/LoginGlob.swift diff --git a/apple/Sources/Truffle/Identity/LoginGlob.swift b/apple/Sources/Truffle/Identity/LoginGlob.swift new file mode 100644 index 00000000..4bba139c --- /dev/null +++ b/apple/Sources/Truffle/Identity/LoginGlob.swift @@ -0,0 +1,236 @@ +/// Login allow-lists (RFC 025 §3.2). +/// +/// A node may declare which tailnet **logins** may join its session plane. +/// The list is a set of shell-style globs evaluated against a caller's WhoIs +/// `loginName` — the same gate the Go sidecar applies to served routes +/// (RFC 023 §9.7, `allowedLogin` in `sidecar-slim/main.go`) and the Rust core +/// applies to its peer filter and hello (`network/login_allow.rs`). One +/// grammar and one test table cover all three planes. +/// +/// The grammar is Go's `path.Match`, applied after lowercasing both sides: +/// +/// - `*` matches any run (including empty) of characters other than `/`; +/// - `?` matches exactly one character other than `/`; +/// - `[abc]`, `[a-z]`, `[^abc]` character classes (ranges, negation, `\` +/// escapes inside); +/// - `\x` matches `x` literally; +/// - a malformed pattern (an unterminated class, a trailing `\`, an empty or +/// reversed range) never matches and never traps. +/// +/// An **empty list means no gate**. A non-empty list against an **absent or +/// empty login fails closed** — tagged nodes report Tailscale's +/// `tagged-devices` pseudo-login and only match a glob that names it. +/// +/// Matching walks Unicode **scalars**, not grapheme clusters, so `?` and the +/// class ranges count and order exactly what Go's `rune` and Rust's `char` +/// count and order. +public enum LoginGlob { + /// A pattern `path.Match` would reject with `ErrBadPattern`. + public struct BadPattern: Error, Equatable, CustomStringConvertible, Sendable { + public init() {} + public var description: String { "syntax error in login glob" } + } + + /// The gate: does `login` pass `globs`? + /// + /// `globs` empty → `true` (no gate). `login` `nil` or empty with a + /// non-empty list → `false` (fail closed). Otherwise `true` iff at least + /// one glob matches, case-insensitively; malformed globs are skipped. + public static func allowed(_ globs: [String], login: String?) -> Bool { + if globs.isEmpty { return true } + guard let login, !login.isEmpty else { return false } + let lowered = login.lowercased() + return globs.contains { glob in + ((try? match(glob.lowercased(), lowered)) ?? false) + } + } + + /// A faithful port of Go's `path.Match(pattern, name)`: case-sensitive, + /// `*` and `?` never cross `/`. Callers wanting the gate's semantics use + /// ``allowed(_:login:)``, which lowercases and treats a throw as + /// "no match". + public static func match(_ pattern: String, _ name: String) throws -> Bool { + var pattern = ArraySlice(Array(pattern.unicodeScalars)) + var name = ArraySlice(Array(name.unicodeScalars)) + + patternLoop: while !pattern.isEmpty { + let (star, chunk, rest) = scanChunk(pattern) + pattern = rest + if star && chunk.isEmpty { + // A trailing `*` matches the rest of the name unless it has a `/`. + return !name.contains("/") + } + // Look for a match at the current position. + let (t, ok, err) = matchChunk(chunk, name) + // If this is the last chunk, the name must be exhausted here; + // otherwise a later chunk could still match via the star. + if ok && (t.isEmpty || !pattern.isEmpty) { + name = t + continue + } + if err { throw BadPattern() } + if star { + // Look for a match skipping i+1 characters. Cannot skip `/`. + let scalars = Array(name) + var i = 0 + while i < scalars.count && scalars[i] != "/" { + let (t, ok, err) = matchChunk(chunk, name.dropFirst(i + 1)) + if ok { + // If this is the last chunk, the name must be exhausted. + if pattern.isEmpty && !t.isEmpty { + i += 1 + continue + } + name = t + continue patternLoop + } + if err { throw BadPattern() } + i += 1 + } + } + // Before answering "no match", check the remainder of the pattern + // is syntactically valid (Go reports ErrBadPattern first). + while !pattern.isEmpty { + let (_, chunk, rest) = scanChunk(pattern) + pattern = rest + let (_, _, err) = matchChunk(chunk, ArraySlice()) + if err { throw BadPattern() } + } + return false + } + return name.isEmpty + } + + // MARK: - Go `path.Match` internals + + /// Split `pattern` into a leading run of `*`s, the next literal chunk (up + /// to but not including the next unescaped `*` outside a class), and the + /// rest. + private static func scanChunk( + _ pattern: ArraySlice + ) -> (star: Bool, chunk: ArraySlice, rest: ArraySlice) { + var star = false + var p = pattern + while p.first == "*" { + p = p.dropFirst() + star = true + } + let scalars = Array(p) + var inRange = false + var i = 0 + scan: while i < scalars.count { + switch scalars[i] { + case "\\": + // An escaped character never ends the chunk. + if i + 1 < scalars.count { i += 1 } + case "[": + inRange = true + case "]": + inRange = false + case "*": + if !inRange { break scan } + default: + break + } + i += 1 + } + return (star, p.prefix(i), p.dropFirst(i)) + } + + /// Match `chunk` (which has no `*`) against the start of `s`. Returns the + /// remainder of `s`, whether it matched, and whether the chunk was + /// malformed. Like Go, syntax is checked to the end of the chunk even + /// after the match has already failed. + private static func matchChunk( + _ chunk: ArraySlice, _ s: ArraySlice + ) -> (rest: ArraySlice, ok: Bool, err: Bool) { + var chunk = chunk + var s = s + var failed = false + while let head = chunk.first { + if !failed && s.isEmpty { failed = true } + switch head { + case "[": + // Character class. + var r: Unicode.Scalar = "\0" + if !failed { + r = s.first! + s = s.dropFirst() + } + chunk = chunk.dropFirst() + // Possibly negated. + var negated = false + if chunk.first == "^" { + negated = true + chunk = chunk.dropFirst() + } + // Parse all ranges. + var matched = false + var nrange = 0 + while true { + if chunk.first == "]" && nrange > 0 { + chunk = chunk.dropFirst() + break + } + guard let (lo, afterLo) = getEsc(chunk) else { + return (ArraySlice(), false, true) + } + chunk = afterLo + var hi = lo + if chunk.first == "-" { + guard let (h, afterHi) = getEsc(chunk.dropFirst()) else { + return (ArraySlice(), false, true) + } + hi = h + chunk = afterHi + } + if lo <= r && r <= hi { matched = true } + nrange += 1 + } + if matched == negated { failed = true } + case "?": + if !failed { + if s.first! == "/" { failed = true } + s = s.dropFirst() + } + chunk = chunk.dropFirst() + case "\\": + chunk = chunk.dropFirst() + if chunk.isEmpty { + return (ArraySlice(), false, true) + } + // Fall through to the literal comparison. + fallthrough + default: + if !failed { + if chunk.first! != s.first! { failed = true } + s = s.dropFirst() + } + chunk = chunk.dropFirst() + } + } + if failed { + return (ArraySlice(), false, false) + } + return (s, true, false) + } + + /// Read one possibly-escaped character of a class body. `nil` is Go's + /// `ErrBadPattern`: an empty body, a `-` or `]` where a character is + /// required, a trailing `\`, or a class that ends right after the + /// character. + private static func getEsc( + _ chunk: ArraySlice + ) -> (scalar: Unicode.Scalar, rest: ArraySlice)? { + guard let head = chunk.first, head != "-", head != "]" else { return nil } + var c = chunk + if c.first == "\\" { + c = c.dropFirst() + if c.isEmpty { return nil } + } + let r = c.first! + let rest = c.dropFirst() + if rest.isEmpty { return nil } + return (r, rest) + } +} diff --git a/apple/Sources/Truffle/Mesh/MeshError.swift b/apple/Sources/Truffle/Mesh/MeshError.swift index eed0fb09..ee3acb2e 100644 --- a/apple/Sources/Truffle/Mesh/MeshError.swift +++ b/apple/Sources/Truffle/Mesh/MeshError.swift @@ -11,6 +11,9 @@ public enum MeshError: Error, Sendable, Equatable { case peerGone(String) case identityUnavailable(String) case identityMismatch(claimed: String, authenticated: String) + /// The caller's WhoIs login is absent from, or matches no glob in, this + /// node's `loginAllow` list (RFC 025 §3.4, D4). Close code 4004. + case loginRefused(login: String?) case invalidPayload(String) case payloadTooLarge(actual: Int, limit: Int) case protocolViolation(String) diff --git a/apple/Sources/Truffle/Mesh/MeshNode.swift b/apple/Sources/Truffle/Mesh/MeshNode.swift index a890e595..ea794a0f 100644 --- a/apple/Sources/Truffle/Mesh/MeshNode.swift +++ b/apple/Sources/Truffle/Mesh/MeshNode.swift @@ -35,6 +35,9 @@ public actor MeshNode { var hostname: String var tailnetIPs: [String] var online: Bool + /// The owner's tailnet login as Layer 3 reported it (RFC 025 §3.3); + /// `nil` for a provisional entry whose netmap row has not arrived. + var loginName: String? /// Confirmed identity after a completed hello; nil for candidates. var identity: PeerIdentity? /// Created from an inbound hello that raced ahead of the netmap @@ -86,6 +89,10 @@ public actor MeshNode { private var localTailscaleId = "" private var localDnsName: String? private var localIPs: [String] = [] + private var localLoginName: String? + /// Set once a gated node has seen a Layer 3 snapshot with no self login, + /// so the health notice is emitted once rather than per refresh. + private var warnedAboutMissingLogins = false // MARK: - Lifecycle @@ -270,6 +277,14 @@ public actor MeshNode { public var dnsName: String? { localDnsName } public var tailnetIPs: [String] { localIPs } + /// The tailnet login this node is signed in as (RFC 025 §3.6, D7), from + /// the last Layer 3 status. `nil` until known — never fabricated. + public var loginName: String? { localLoginName } + + /// The login allow-list this node was started with (RFC 025 §3.1). + /// Empty means ungated. Fixed for the node's lifetime. + public var loginAllow: [String] { config.loginAllow } + public var localPeer: Peer { Peer( ref: PeerRef(tailscaleId: localTailscaleId, generation: 0), @@ -281,7 +296,8 @@ public actor MeshNode { tailnetIPs: localIPs, online: phaseValue == .running, appId: appId.value, - isLocal: true) + isLocal: true, + loginName: localLoginName) } /// Each access mints a NEW independently-buffered stream (RFC 024 §6.2): @@ -413,7 +429,8 @@ public actor MeshNode { tailnetIPs: entry.tailnetIPs, online: entry.online, appId: entry.identity != nil ? appId.value : nil, - isLocal: false) + isLocal: false, + loginName: entry.loginName) } /// Generation-checked live lookup for peer-taking calls (RFC 024 §6.3). @@ -543,6 +560,7 @@ public actor MeshNode { localTailscaleId = status.tailscaleId localDnsName = status.dnsName localIPs = status.tailnetIPs + localLoginName = status.loginName if status.running { setPhase(.running) @@ -567,6 +585,22 @@ public actor MeshNode { seen.insert(peer.tailscaleId) upsertFromLayer3(peer) } + // RFC 025 §3.3: a gated node cannot admit a peer whose login Layer 3 + // never reported. Say so — once — rather than presenting an + // unexplained empty mesh (the Rust provider refuses to start; a Swift + // node has no sidecar to interrogate, so it reports honestly). + if !config.loginAllow.isEmpty, !warnedAboutMissingLogins, + status.peers.contains(where: { + $0.loginName == nil + && Hostname.isAppPeer(hostname: $0.hostname, appId: appId.value) + }) + { + warnedAboutMissingLogins = true + emit( + .health( + "login gate active but Layer 3 reported an app peer with no login; " + + "peers without a login are not admitted")) + } // Entries absent from a full snapshot have left Layer 3 — EXCEPT // provisional entries, whose netmap event hasn't arrived yet // (RFC 024 §7.2: preserve, then merge). @@ -576,15 +610,21 @@ public actor MeshNode { } } - /// Candidate filtering (RFC 024 §7.2): only hostnames matching - /// `truffle-{appId}-{slug}` enter the registry — except provisional - /// entries created by a raced inbound hello, which merge Layer 3 - /// metadata into the same generation. + /// Candidate filtering (RFC 024 §7.2, RFC 025 §3.3): a hostname matching + /// `truffle-{appId}-{slug}` AND — on a login-gated node — a login on the + /// allow-list. A gated node treats a row WITHOUT a login as not a peer + /// (fail closed). + /// + /// Provisional entries created by a raced inbound hello merge Layer 3 + /// metadata into the same generation as before: that hello already passed + /// the gate in `Handshake.server`, so re-gating it here would drop a peer + /// the node has an authenticated session with. private func upsertFromLayer3(_ peer: BackendPeer) { if var existing = entries[peer.tailscaleId] { existing.hostname = peer.hostname existing.tailnetIPs = peer.tailnetIPs existing.online = peer.online + existing.loginName = peer.loginName ?? existing.loginName existing.provisional = false entries[peer.tailscaleId] = existing emit(.peerUpsert(makePeer(from: existing))) @@ -593,6 +633,9 @@ public actor MeshNode { guard Hostname.isAppPeer(hostname: peer.hostname, appId: appId.value) else { return } + guard LoginGlob.allowed(config.loginAllow, login: peer.loginName) else { + return + } generationCounter += 1 let entry = RegistryEntry( tailscaleId: peer.tailscaleId, @@ -600,6 +643,7 @@ public actor MeshNode { hostname: peer.hostname, tailnetIPs: peer.tailnetIPs, online: peer.online, + loginName: peer.loginName, identity: nil, provisional: false) entries[peer.tailscaleId] = entry @@ -717,6 +761,7 @@ public actor MeshNode { hostname: "", tailnetIPs: [], online: true, + loginName: nil, identity: identity, provisional: true) entries[tailscaleId] = entry @@ -851,7 +896,7 @@ public actor MeshNode { // handshake deadline (RFC 024 §8.1 step 4), not just the hello. let identity = try await withDeadline( tuning.handshakeTimeout, label: "inbound handshake" - ) { [transport, backend, localHello, identityPolicy] in + ) { [transport, backend, localHello, identityPolicy, loginAllow = config.loginAllow] in let frames = try await transport.serverFrames(over: accepted.connection) let authenticated = try? await backend.whoIs( remoteEndpoint: accepted.remoteEndpoint) @@ -859,7 +904,8 @@ public actor MeshNode { frames: frames, localHello: localHello, authenticated: authenticated, - policy: identityPolicy) + policy: identityPolicy, + loginAllow: loginAllow) return InboundHandshake(frames: frames, identity: identity) } await adoptInbound(identity) diff --git a/apple/Sources/Truffle/Mesh/MeshTypes.swift b/apple/Sources/Truffle/Mesh/MeshTypes.swift index a3107a90..920a355e 100644 --- a/apple/Sources/Truffle/Mesh/MeshTypes.swift +++ b/apple/Sources/Truffle/Mesh/MeshTypes.swift @@ -65,6 +65,25 @@ public struct MeshConfiguration: Sendable { public var ephemeral: Bool public var auth: MeshAuth + /// Login allow-list — the tailnet **logins** that may join this node's + /// mesh (RFC 025 §3.1/§3.2, D1/D2). Shell-style globs in Go `path.Match` + /// grammar, matched case-insensitively by ``LoginGlob``. + /// + /// **Empty (the default) = no gate**: today's behaviour exactly — the + /// whole tailnet, hostname-prefix discovery, and the existing + /// ``Handshake/IdentityPolicy`` alone on the inbound path. + /// + /// **Non-empty = gated**: only peers whose Layer 3 login matches are + /// reported (``MeshNode/peers()`` and `peerUpsert`), and every inbound + /// hello whose WhoIs login does not match is refused with close code + /// 4004 before our own hello is revealed. Under a gate an absent login + /// fails closed on both paths, and a caller with no authenticated + /// identity is refused with 4003 REGARDLESS of the identity policy. + /// + /// The list is fixed for the node's lifetime; to change it, restart the + /// node (RFC 025 §3.1). + public var loginAllow: [String] + public var logger: (any MeshLogger)? public init( @@ -74,6 +93,7 @@ public struct MeshConfiguration: Sendable { controlURL: URL? = nil, ephemeral: Bool = false, auth: MeshAuth = .existingState, + loginAllow: [String] = [], logger: (any MeshLogger)? = nil ) { self.appId = appId @@ -82,6 +102,7 @@ public struct MeshConfiguration: Sendable { self.controlURL = controlURL self.ephemeral = ephemeral self.auth = auth + self.loginAllow = loginAllow self.logger = logger } } diff --git a/apple/Sources/Truffle/Session/Handshake.swift b/apple/Sources/Truffle/Session/Handshake.swift index a618fe8f..8fd17f18 100644 --- a/apple/Sources/Truffle/Session/Handshake.swift +++ b/apple/Sources/Truffle/Session/Handshake.swift @@ -135,11 +135,29 @@ public enum Handshake { /// a missing or empty stable node ID rejects with 4003; under /// `.allowUnverified` (tests only) the claim is accepted unverified — /// mirroring, explicitly, what desktop currently does implicitly. + /// + /// `loginAllow` is the node's login gate (RFC 025 §3.4, D4). Empty is + /// today's behaviour exactly. Non-empty applies the §3.4 table, in this + /// order, all of it BEFORE our own hello is sent so an impostor never + /// learns our identity block: + /// + /// | bridge identity | ungated | gated | + /// |--------------------------------------|--------------------|------------| + /// | absent / no `tailscaleId` | policy decides | **4003** | + /// | `tailscaleId` ≠ claimed | 4003 | 4003 | + /// | ok, `loginName` absent | accept | **4004** | + /// | ok, `loginName` matches no glob | accept | **4004** | + /// | ok, `loginName` matches | accept | accept | + /// + /// A gate is never bypassed by `.allowUnverified`: on a gated node an + /// absent identity is refused under EITHER policy, because the login the + /// gate needs can only come from an authenticated WhoIs answer. public static func server( frames: any SessionFrames, localHello: HelloEnvelope, authenticated: AuthenticatedPeer?, - policy: IdentityPolicy + policy: IdentityPolicy, + loginAllow: [String] = [] ) async throws -> PeerIdentity { let remote: HelloEnvelope do { @@ -162,16 +180,23 @@ public enum Handshake { throw map(error) } + let gated = !loginAllow.isEmpty let authenticatedId = authenticated?.tailscaleId ?? "" if authenticatedId.isEmpty { + let refuse: Bool switch policy { case .failClosed: + refuse = true + case .allowUnverified: + // A gate is never bypassed by the test policy: without an + // authenticated identity there is no login to gate on. + refuse = gated + } + if refuse { await frames.close( code: SessionCloseCode.identityMismatch, reason: "identity unavailable") throw MeshError.identityUnavailable( "no authenticated identity for incoming connection") - case .allowUnverified: - break } } else if authenticatedId != identity.tailscaleId { await frames.close( @@ -181,6 +206,17 @@ public enum Handshake { claimed: identity.tailscaleId, authenticated: authenticatedId) } + if gated { + // WhoIs is the only authority for the login — the hello never + // declares one (RFC 025 §3.7, D8). + let login = authenticated?.loginName + guard LoginGlob.allowed(loginAllow, login: login) else { + await frames.close( + code: SessionCloseCode.loginRefused, reason: "login refused") + throw MeshError.loginRefused(login: login) + } + } + let payload = String(decoding: try localHello.encoded(), as: UTF8.self) try await frames.send(.text(payload)) return identity diff --git a/apple/Sources/Truffle/Wire/HelloEnvelope.swift b/apple/Sources/Truffle/Wire/HelloEnvelope.swift index 72b51fc5..e8c635d8 100644 --- a/apple/Sources/Truffle/Wire/HelloEnvelope.swift +++ b/apple/Sources/Truffle/Wire/HelloEnvelope.swift @@ -80,6 +80,14 @@ public struct HelloEnvelope: Codable, Sendable, Equatable { // MARK: - Validation (port of websocket.rs::validate_hello) /// Classified hello failures. Each maps to an RFC 017 close code. +/// +/// Validation covers only what the hello itself can be wrong about: 4001 and +/// 4002. The refusals that depend on evidence OUTSIDE the hello are decided +/// afterwards by `Handshake.server`, which closes with +/// `SessionCloseCode.identityMismatch` (4003) for a contradicted or absent +/// WhoIs identity and `SessionCloseCode.loginRefused` (4004) for a login the +/// node's `loginAllow` does not admit (RFC 025 §3.4). The login is never +/// declared in the hello — WhoIs is its only authority (RFC 025 §3.7, D8). public enum HelloValidationError: Error, Sendable, Equatable { /// Malformed / invalid hello → close code 4002. case malformed(String) diff --git a/apple/Sources/Truffle/Wire/SessionLimits.swift b/apple/Sources/Truffle/Wire/SessionLimits.swift index cd46d348..0a304877 100644 --- a/apple/Sources/Truffle/Wire/SessionLimits.swift +++ b/apple/Sources/Truffle/Wire/SessionLimits.swift @@ -58,8 +58,13 @@ public enum SessionCloseCode { /// Malformed, invalid, or missing hello envelope. public static let helloProtocol: UInt16 = 4002 /// Claimed `tailscale_id` contradicts the authenticated identity, or no - /// authenticated identity was available under the fail-closed policy. + /// authenticated identity was available — under the fail-closed policy, + /// or on a login-gated node under ANY policy (RFC 025 §3.4). public static let identityMismatch: UInt16 = 4003 + /// The caller's WhoIs login is absent from, or matches no glob in, this + /// node's `loginAllow` list (RFC 025 §3.4/§4, D4). Sent before our own + /// hello, so a refused caller never learns our identity block. + public static let loginRefused: UInt16 = 4004 /// RFC 6455 normal closure. public static let normal: UInt16 = 1000 } From cea361174389110ae118e2ea2b858041df019065 Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 00:52:04 -0700 Subject: [PATCH 03/14] =?UTF-8?q?test(apple):=20the=20glob=20tables,=20the?= =?UTF-8?q?=20=C2=A73.4=20hello=20table,=20and=20a=20gated=20loopback=20pa?= =?UTF-8?q?ir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 25 new tests (70 -> 95 in 12 -> 17 suites), all on the macOS host. - LoginGlobTests reproduces the Rust port's tables VERBATIM (crates/truffle-core/src/network/login_allow.rs): the Go reference table from TestAllowedLogin, the path.Match behaviour table, and the bad-pattern table. If a row here disagrees with a row there, one of the three planes has drifted and the gate is no longer one grammar. All three passed on the first run of the port, including the rows that matter for the scalar-vs-grapheme choice ("a?b" vs "a☺b", "[a-ζ]*" vs "α"). - HandshakeLoginGateTests walks every row of RFC 025 §3.4 for Handshake.server and asserts the close code the CLIENT's frames see — and asserts it is the FIRST frame the client receives, which is what proves our hello was never revealed to a refused caller. Rows: absent identity (nil AND an empty stable ID) refused 4003 under .allowUnverified, node-id mismatch still 4003 even when the login WOULD have passed, login absent 4004, login unmatched 4004, matching login exchanges hellos, the ungated column accepts a foreign AND an absent login, and tagged-devices is refused by a personal glob but admitted by one that names it. - NodeLoginGateTests drives the gate end to end over the loopback tailnet: a gated pair with a matching glob converges and messages flow; a foreign login is NEITHER listed by Layer 3 NOR admitted at the hello (the ungated peer still discovers and dials the gated node, which is exactly what the hello gate must stop, and the refusal leaves no provisional entry behind); a login-less row on a gated node is not a peer, the node emits its one health notice, and the SAME row with a matching login is then admitted — which is what proves it was dropped for its login and not its hostname. - LocalAPIIdentityTests pins the decoders from literal JSON: the WhoIs answer with and without UserProfile, empty profile strings becoming nil, the tagged-devices passthrough, absent Addresses, and the status overlay — resolution through the stringified user map, a UserID with no profile and a row with no UserID contributing nothing, the merge onto a mapped BackendStatus by stable node ID, and the clearing of a stale login the overlay does not own (otherwise a departed user's login could gate a new node in). Verified not vacuous. Three mutations were applied and the suites re-run: neutering the hello's login guard reddened exactly the three 4004 rows and left the identity/mismatch/ungated rows green; neutering the Layer 3 filter reddened both gated discovery tests and left the ungated one green; making the overlay keep a stale login reddened only clearsStaleLoginsItDoesNotOwn. 13 issues across 3 suites. All three files were then restored and checked back against their pre-mutation SHA-256. No interop fixture was added or changed, so the Rust side needs no mirror. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- .../LocalAPIIdentityTests.swift | 222 ++++++++++++++++++ apple/Tests/TruffleTests/HandshakeTests.swift | 179 ++++++++++++++ apple/Tests/TruffleTests/IdentityTests.swift | 101 ++++++++ .../TruffleTests/NodeLoopbackTests.swift | 215 ++++++++++++++++- 4 files changed, 714 insertions(+), 3 deletions(-) create mode 100644 apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift diff --git a/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift b/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift new file mode 100644 index 00000000..f8fcbf5c --- /dev/null +++ b/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift @@ -0,0 +1,222 @@ +import Foundation +import Testing + +@testable import TruffleTailscale + +/// The LocalAPI identity decoders (RFC 025 §3.3/§3.6), driven from literal +/// JSON so the mapping is pinned without a node, a tailnet, or a device. +/// +/// These live in `TruffleTailscaleTests` and not beside `TailscaleKitBackend` +/// because that file is `#if os(iOS) && canImport(TailscaleKit)` and compiles +/// to nothing on the macOS host the test target runs on — the same reason +/// `TailscaleEndpoint` is its own file. The decoding is the part that can be +/// wrong in a way a test can catch. +private func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(type, from: Data(json.utf8)) +} + +@Suite struct WhoIsResponseTests { + @Test func carriesLoginAndDisplayNameWhenTheProfileIsPresent() throws { + let decoded = try decode( + WhoIsResponse.self, + """ + { + "Node": { + "ID": 4711, + "StableID": "nABC123", + "Name": "truffle-demo-alice.corp.ts.net.", + "Addresses": ["100.64.0.2/32", "fd7a:115c:a1e0::2/128"] + }, + "UserProfile": { + "ID": 12345, + "LoginName": "alice@corp.com", + "DisplayName": "Alice Example", + "ProfilePicURL": "https://example.com/a.png" + } + } + """) + #expect(decoded.Node.StableID == "nABC123") + #expect(decoded.Node.Addresses == ["100.64.0.2/32", "fd7a:115c:a1e0::2/128"]) + #expect(decoded.loginName == "alice@corp.com") + #expect(decoded.displayName == "Alice Example") + } + + /// The shape #188 already handled: no profile at all. The node ID still + /// decodes and the identity fields stay absent rather than becoming "". + @Test func hasNoLoginWhenTheProfileIsAbsent() throws { + let decoded = try decode( + WhoIsResponse.self, + """ + {"Node": {"ID": 1, "StableID": "nNOPROFILE", "Addresses": ["100.64.0.3/32"]}} + """) + #expect(decoded.Node.StableID == "nNOPROFILE") + #expect(decoded.loginName == nil) + #expect(decoded.displayName == nil) + } + + /// An empty string on the wire is not an identity (RFC 022's honesty + /// rule; RFC 025 §3.2 fails closed on an empty login). + @Test func emptyProfileStringsBecomeNil() throws { + let decoded = try decode( + WhoIsResponse.self, + """ + { + "Node": {"ID": 2, "StableID": "nEMPTY", "Addresses": []}, + "UserProfile": {"ID": 0, "LoginName": "", "DisplayName": ""} + } + """) + #expect(decoded.loginName == nil) + #expect(decoded.displayName == nil) + } + + /// A tagged node's pseudo-login is passed through, never special-cased — + /// it only matches a glob that names it (RFC 025 §3.2). + @Test func taggedDevicesLoginIsPassedThrough() throws { + let decoded = try decode( + WhoIsResponse.self, + """ + { + "Node": {"ID": 3, "StableID": "nTAGGED", "Addresses": ["100.64.0.4/32"]}, + "UserProfile": {"ID": 99, "LoginName": "tagged-devices", "DisplayName": "Tagged"} + } + """) + #expect(decoded.loginName == "tagged-devices") + } + + /// Missing `Addresses` stays `nil` rather than failing the decode — the + /// tolerant-reader rule, and the shape #188's address check relies on. + @Test func absentAddressesDecodeAsNil() throws { + let decoded = try decode( + WhoIsResponse.self, #"{"Node": {"ID": 5, "StableID": "nNOADDR"}}"#) + #expect(decoded.Node.Addresses == nil) + } +} + +@Suite struct LoginOverlayTests { + /// A realistic `/localapi/v0/status` answer: `Peer` is keyed by node key + /// while each row's own `ID` is the stable node ID `BackendPeer` uses, + /// and `User` is keyed by the STRINGIFIED numeric user id. + private static let statusJSON = """ + { + "Version": "1.102.3", + "BackendState": "Running", + "AuthURL": "", + "TailscaleIPs": ["100.64.0.1"], + "Self": { + "ID": "nSELF", "UserID": 12345, + "HostName": "truffle-demo-alice", "Online": true + }, + "Peer": { + "nodekey:aaaa": { + "ID": "nBOB", "UserID": 12345, + "HostName": "truffle-demo-bob", "Online": true + }, + "nodekey:bbbb": { + "ID": "nMALLORY", "UserID": 67890, + "HostName": "truffle-demo-mallory", "Online": true + }, + "nodekey:cccc": { + "ID": "nORPHAN", "UserID": 55555, + "HostName": "truffle-demo-orphan", "Online": true + }, + "nodekey:dddd": { + "ID": "nNOUSER", + "HostName": "truffle-demo-nouser", "Online": true + } + }, + "User": { + "12345": {"ID": 12345, "LoginName": "alice@corp.com", "DisplayName": "Alice"}, + "67890": {"ID": 67890, "LoginName": "mallory@evil.com", "DisplayName": "Mallory"} + } + } + """ + + private func overlay() throws -> LoginOverlay { + LoginOverlay(try decode(LocalAPIStatusLogins.self, Self.statusJSON)) + } + + @Test func resolvesSelfAndPeerLoginsThroughTheUserMap() throws { + let overlay = try overlay() + #expect(overlay.selfLogin == "alice@corp.com") + #expect(overlay.byStableNodeId["nBOB"] == "alice@corp.com") + #expect(overlay.byStableNodeId["nMALLORY"] == "mallory@evil.com") + // A UserID with no profile in the map, and a row with no UserID at + // all, contribute nothing — absent, never fabricated. + #expect(overlay.byStableNodeId["nORPHAN"] == nil) + #expect(overlay.byStableNodeId["nNOUSER"] == nil) + #expect(overlay.byStableNodeId.count == 2) + } + + @Test func mergesOntoAMappedStatusByStableNodeId() throws { + let mapped = BackendStatus( + running: true, + dnsName: "truffle-demo-alice.corp.ts.net", + tailnetIPs: ["100.64.0.1"], + tailscaleId: "nSELF", + peers: [ + BackendPeer(tailscaleId: "nBOB", hostname: "truffle-demo-bob"), + BackendPeer(tailscaleId: "nMALLORY", hostname: "truffle-demo-mallory"), + BackendPeer(tailscaleId: "nNOUSER", hostname: "truffle-demo-nouser"), + ]) + let merged = try overlay().applied(to: mapped) + + #expect(merged.loginName == "alice@corp.com") + #expect(merged.peers.map(\.loginName) == ["alice@corp.com", "mallory@evil.com", nil]) + // Everything the overlay does not own is carried through untouched. + #expect(merged.tailscaleId == "nSELF") + #expect(merged.dnsName == "truffle-demo-alice.corp.ts.net") + #expect(merged.tailnetIPs == ["100.64.0.1"]) + #expect(merged.peers.map(\.hostname) == mapped.peers.map(\.hostname)) + #expect(merged.peers.map(\.tailscaleId) == mapped.peers.map(\.tailscaleId)) + #expect(merged.running) + } + + /// The overlay is the authority for the snapshot it was read with: a peer + /// it has no login for is cleared, never left holding an earlier read's + /// value. Otherwise a departed user's login could gate a new node in. + @Test func clearsStaleLoginsItDoesNotOwn() throws { + let stale = BackendStatus( + tailscaleId: "nSELF", + loginName: "someone-else@corp.com", + peers: [ + BackendPeer( + tailscaleId: "nNOUSER", hostname: "truffle-demo-nouser", + loginName: "alice@corp.com") + ]) + let merged = try overlay().applied(to: stale) + #expect(merged.loginName == "alice@corp.com") + #expect(merged.peers[0].loginName == nil) + } + + /// A status with no `Self`, no `Peer` and no `User` decodes and yields + /// nothing — the shape a not-yet-running backend returns. + @Test func emptyStatusYieldsNoLogins() throws { + let overlay = LoginOverlay( + try decode(LocalAPIStatusLogins.self, #"{"BackendState": "NeedsLogin"}"#)) + #expect(overlay.selfLogin == nil) + #expect(overlay.byStableNodeId.isEmpty) + + let merged = overlay.applied( + to: BackendStatus( + tailscaleId: "nSELF", + peers: [BackendPeer(tailscaleId: "nBOB", hostname: "truffle-demo-bob")])) + #expect(merged.loginName == nil) + #expect(merged.peers[0].loginName == nil) + } + + /// An empty `LoginName` in the user map is not a login. + @Test func emptyLoginNameInTheUserMapIsAbsent() throws { + let overlay = LoginOverlay( + try decode( + LocalAPIStatusLogins.self, + """ + { + "Self": {"ID": "nSELF", "UserID": 7}, + "Peer": {"k": {"ID": "nBOB", "UserID": 7}}, + "User": {"7": {"ID": 7, "LoginName": "", "DisplayName": "Nameless"}} + } + """)) + #expect(overlay.selfLogin == nil) + #expect(overlay.byStableNodeId["nBOB"] == nil) + } +} diff --git a/apple/Tests/TruffleTests/HandshakeTests.swift b/apple/Tests/TruffleTests/HandshakeTests.swift index 64a5beca..a41e1b84 100644 --- a/apple/Tests/TruffleTests/HandshakeTests.swift +++ b/apple/Tests/TruffleTests/HandshakeTests.swift @@ -196,3 +196,182 @@ private func makeHello( #expect(try await b.receive() == nil) } } + +// MARK: - The login gate (RFC 025 §3.4, D4) + +/// Every row of the §3.4 table for `Handshake.server`. Each refusal asserts +/// the close code the CLIENT's frames see, and asserts it is the FIRST frame +/// the client receives — which is what proves our hello was never revealed to +/// a caller the gate rejected. +@Suite struct HandshakeLoginGateTests { + private static let gate = ["*@corp.com"] + + private func firstFrame(_ frames: any SessionFrames) async throws -> SessionFrame? { + try await frames.receive() + } + + private func sendClientHello(_ frames: any SessionFrames, tailscaleId: String = "ts-a") + async throws + { + try await frames.send( + .text(String(decoding: try makeHello(tailscaleId: tailscaleId).encoded(), as: UTF8.self)) + ) + } + + // Row 1, gated column: absent identity is refused 4003 under EITHER + // policy — a gate is never bypassed by the test policy. + @Test func gatedRefusesAbsentIdentityEvenUnderAllowUnverified() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + await #expect(throws: MeshError.self) { + try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: nil, policy: .allowUnverified, loginAllow: Self.gate) + } + guard case .close(let code, _) = try await firstFrame(clientFrames) else { + Issue.record("expected close frame") + return + } + #expect(code == SessionCloseCode.identityMismatch) + } + + // Same row via an AuthenticatedPeer whose stable ID is empty. + @Test func gatedRefusesEmptyStableIdEvenUnderAllowUnverified() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + await #expect(throws: MeshError.self) { + try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "", remoteAddresses: [], loginName: "alice@corp.com"), + policy: .allowUnverified, loginAllow: Self.gate) + } + guard case .close(let code, _) = try await firstFrame(clientFrames) else { + Issue.record("expected close frame") + return + } + #expect(code == SessionCloseCode.identityMismatch) + } + + // Row 2: a node id mismatch is still 4003, and is decided BEFORE the + // login — even when the login would have passed the gate. + @Test func gatedNodeIdMismatchStillCloses4003() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + await #expect(throws: MeshError.identityMismatch(claimed: "ts-a", authenticated: "ts-EVIL")) + { + try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "ts-EVIL", remoteAddresses: [], loginName: "alice@corp.com"), + policy: .failClosed, loginAllow: Self.gate) + } + guard case .close(let code, _) = try await firstFrame(clientFrames) else { + Issue.record("expected close frame") + return + } + #expect(code == SessionCloseCode.identityMismatch) + } + + // Row 3: the node id is good but WhoIs carried no login — 4004, closed. + @Test func gatedRefusesAbsentLoginWith4004() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + await #expect(throws: MeshError.loginRefused(login: nil)) { + try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer(tailscaleId: "ts-a", remoteAddresses: []), + policy: .failClosed, loginAllow: Self.gate) + } + guard case .close(let code, _) = try await firstFrame(clientFrames) else { + Issue.record("expected close frame") + return + } + #expect(code == SessionCloseCode.loginRefused) + } + + // Row 4: a real login that matches no glob — 4004. + @Test func gatedRefusesUnmatchedLoginWith4004() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + await #expect(throws: MeshError.loginRefused(login: "mallory@evil.com")) { + try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "ts-a", remoteAddresses: [], loginName: "mallory@evil.com"), + policy: .failClosed, loginAllow: Self.gate) + } + guard case .close(let code, _) = try await firstFrame(clientFrames) else { + Issue.record("expected close frame") + return + } + #expect(code == SessionCloseCode.loginRefused) + } + + // Row 5: a matching login exchanges hellos exactly as before. + @Test func gatedAcceptsMatchingLoginAndExchangesHellos() async throws { + let (clientFrames, serverFrames) = FramePipe.makePair() + let clientHello = makeHello(deviceId: "01HZZZZZZZZZZZZZZZZZZZZZZ1", tailscaleId: "ts-a") + let serverHello = makeHello(deviceId: "01HZZZZZZZZZZZZZZZZZZZZZZ2", tailscaleId: "ts-b") + + async let serverSide = Handshake.server( + frames: serverFrames, + localHello: serverHello, + authenticated: AuthenticatedPeer( + tailscaleId: "ts-a", remoteAddresses: ["100.64.0.1:9417"], + loginName: "Alice@CORP.com", displayName: "Alice"), + policy: .failClosed, + loginAllow: Self.gate) + async let clientSide = Handshake.client( + frames: clientFrames, localHello: clientHello, expectedTailscaleId: "ts-b") + + let (serverSeen, clientSeen) = try await (serverSide, clientSide) + #expect(serverSeen.tailscaleId == "ts-a") + #expect(clientSeen.tailscaleId == "ts-b") + #expect(clientSeen.deviceId == "01HZZZZZZZZZZZZZZZZZZZZZZ2") + } + + // The ungated column: an empty list is today's behaviour exactly, so a + // foreign login — and an absent one — are both still accepted. + @Test func ungatedAcceptsAnyLogin() async throws { + for login in ["mallory@evil.com", nil] { + let (clientFrames, serverFrames) = FramePipe.makePair() + try await sendClientHello(clientFrames) + + let identity = try await Handshake.server( + frames: serverFrames, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "ts-a", remoteAddresses: [], loginName: login), + policy: .failClosed) + #expect(identity.tailscaleId == "ts-a") + } + } + + // A tagged node's pseudo-login is passed through, not special-cased: it + // is refused by a personal glob and admitted by one that names it. + @Test func taggedDevicesPseudoLoginIsNotSpecialCased() async throws { + let (refusedClient, refusedServer) = FramePipe.makePair() + try await sendClientHello(refusedClient) + await #expect(throws: MeshError.loginRefused(login: "tagged-devices")) { + try await Handshake.server( + frames: refusedServer, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "ts-a", remoteAddresses: [], loginName: "tagged-devices"), + policy: .failClosed, loginAllow: Self.gate) + } + + let (acceptedClient, acceptedServer) = FramePipe.makePair() + try await sendClientHello(acceptedClient) + let identity = try await Handshake.server( + frames: acceptedServer, localHello: makeHello(tailscaleId: "ts-b"), + authenticated: AuthenticatedPeer( + tailscaleId: "ts-a", remoteAddresses: [], loginName: "tagged-devices"), + policy: .failClosed, loginAllow: ["tagged-devices"]) + #expect(identity.tailscaleId == "ts-a") + } +} diff --git a/apple/Tests/TruffleTests/IdentityTests.swift b/apple/Tests/TruffleTests/IdentityTests.swift index 8c35fc90..9eb2c870 100644 --- a/apple/Tests/TruffleTests/IdentityTests.swift +++ b/apple/Tests/TruffleTests/IdentityTests.swift @@ -161,3 +161,104 @@ import Testing #expect(!Hostname.isAppPeer(hostname: "laptop", appId: "demo")) } } + +// MARK: - LoginGlob (RFC 025 §3.2 — one grammar, three planes) + +/// Both tables are reproduced verbatim from the Rust port +/// (`crates/truffle-core/src/network/login_allow.rs`), which in turn +/// reproduces the Go reference (`TestAllowedLogin` / `path.Match`'s `TestMatch` +/// in `sidecar-slim`). If a row here disagrees with a row there, one of the +/// three planes has drifted and the gate is no longer one grammar. +@Suite struct LoginGlobTests { + @Test func allowedLoginMatchesTheGoTable() { + let cases: [(name: String, globs: [String], login: String, want: Bool)] = [ + ("empty globs allow all", [], "anyone@example.com", true), + ("empty globs allow even empty login", [], "", true), + ("non-empty gate, empty login fails closed", ["*@corp.com"], "", false), + ("exact match", ["alice@corp.com"], "alice@corp.com", true), + ("exact non-match", ["alice@corp.com"], "bob@corp.com", false), + ("domain glob matches", ["*@corp.com"], "alice@corp.com", true), + ("domain glob rejects other domain", ["*@corp.com"], "alice@evil.com", false), + ("case-insensitive glob vs login", ["*@CORP.com"], "Alice@corp.COM", true), + ("case-insensitive exact", ["Alice@Corp.Com"], "alice@corp.com", true), + ("second glob in list matches", ["*@other.com", "*@corp.com"], "bob@corp.com", true), + ("no glob in list matches", ["*@other.com", "*@more.com"], "bob@corp.com", false), + ("star does not cross slash", ["*@corp.com"], "a/b@corp.com", false), + ("invalid glob does not match", ["[unterminated"], "alice@corp.com", false), + ("invalid glob skipped, valid one still matches", ["[bad", "*@corp.com"], + "alice@corp.com", true), + ] + for row in cases { + #expect( + LoginGlob.allowed(row.globs, login: row.login) == row.want, + "\(row.name): allowed(\(row.globs), login: \"\(row.login)\")") + } + // `nil` is the absent login: fails closed under a gate, passes without one. + #expect(!LoginGlob.allowed(["*@corp.com"], login: nil)) + #expect(LoginGlob.allowed([], login: nil)) + // A tagged node only passes a glob that names the pseudo-login. + #expect(!LoginGlob.allowed(["*@corp.com"], login: "tagged-devices")) + #expect(LoginGlob.allowed(["tagged-devices"], login: "tagged-devices")) + } + + @Test func globMatchFollowsPathMatch() throws { + func ok(_ p: String, _ n: String) throws -> Bool { try LoginGlob.match(p, n) } + #expect(try ok("abc", "abc")) + #expect(try ok("*", "abc")) + #expect(try ok("*c", "abc")) + #expect(try !ok("a*", "a/b")) + #expect(try ok("a*", "ab")) + #expect(try !ok("a*", "abc/d")) + #expect(try ok("a*/b", "abc/b")) + #expect(try !ok("a*/b", "a/c/b")) + #expect(try ok("a*b*c*d*e*/f", "axbxcxdxe/f")) + #expect(try ok("a*b*c*d*e*/f", "axbxcxdxexxx/f")) + #expect(try !ok("a*b*c*d*e*/f", "axbxcxdxe/xxx/f")) + #expect(try !ok("a*b*c*d*e*/f", "axbxcxdxexxx/fff")) + #expect(try ok("a*b?c*x", "abxbbxdbxebxczzx")) + #expect(try !ok("a*b?c*x", "abxbbxdbxebxczzy")) + #expect(try ok("ab[c]", "abc")) + #expect(try ok("ab[b-d]", "abc")) + #expect(try !ok("ab[e-g]", "abc")) + #expect(try !ok("ab[^c]", "abc")) + #expect(try !ok("ab[^b-d]", "abc")) + #expect(try ok("ab[^e-g]", "abc")) + #expect(try ok("a\\*b", "a*b")) + #expect(try !ok("a\\*b", "ab")) + #expect(try ok("a?b", "a☺b")) + #expect(try ok("a[^a]b", "a☺b")) + #expect(try !ok("a???b", "a☺b")) + #expect(try !ok("a[^a][^a][^a]b", "a☺b")) + #expect(try ok("[a-ζ]*", "α")) + #expect(try !ok("*[a-ζ]", "A")) + #expect(try ok("a?b", "a/b") == false) + #expect(try ok("a*b", "a/b") == false) + #expect(try ok("[\\]a]", "]")) + #expect(try ok("[\\-]", "-")) + #expect(try ok("[x\\-]", "x")) + #expect(try ok("[x\\-]", "-")) + #expect(try !ok("[x\\-]", "z")) + #expect(try ok("[\\-x]", "x")) + #expect(try ok("[\\-x]", "-")) + #expect(try !ok("[\\-x]", "a")) + #expect(try ok("*x", "xxx")) + #expect(try !ok("", "a")) + #expect(try ok("", "")) + } + + @Test func globMatchReportsBadPatternsLikeGo() { + for bad in [ + "[]a]", "[-]", "[x-]", "[-x]", "\\", "[a-b-c]", "[", "[^", "[^bc", "a[", + "[unterminated", + ] { + #expect(throws: LoginGlob.BadPattern.self, "\(bad) must be a bad pattern") { + try LoginGlob.match(bad, "a") + } + } + // A bad pattern is an error even when an earlier chunk already failed + // to match — Go checks the remainder's syntax before answering false. + #expect(throws: LoginGlob.BadPattern.self) { + try LoginGlob.match("a*[", "b") + } + } +} diff --git a/apple/Tests/TruffleTests/NodeLoopbackTests.swift b/apple/Tests/TruffleTests/NodeLoopbackTests.swift index 4bb8a7bf..7d9d282e 100644 --- a/apple/Tests/TruffleTests/NodeLoopbackTests.swift +++ b/apple/Tests/TruffleTests/NodeLoopbackTests.swift @@ -56,18 +56,21 @@ struct PongDroppingTransport: FrameTransport { deviceName: String, advertisedHostname: String? = nil, hidden: Bool = false, - identityPolicy: Handshake.IdentityPolicy = .failClosed + identityPolicy: Handshake.IdentityPolicy = .failClosed, + loginName: String? = nil, + loginAllow: [String] = [] ) async throws -> (MeshNode, URL) { let derived = Hostname.tailscaleHostname( appId: try AppId(parsing: appId), deviceName: DeviceName(deviceName)) let hostname = advertisedHostname ?? derived let backend = await network.join( - tailscaleId: tailscaleId, hostname: hostname, hidden: hidden) + tailscaleId: tailscaleId, hostname: hostname, hidden: hidden, + loginName: loginName) let dir = tempDir() let node = try await MeshNode.start( MeshConfiguration( appId: appId, deviceName: deviceName, stateDirectory: dir, - auth: .existingState), + auth: .existingState, loginAllow: loginAllow), backend: backend, frameTransport: LengthPrefixFrameTransport(), identityPolicy: identityPolicy) @@ -563,3 +566,209 @@ struct PongDroppingTransport: FrameTransport { await alice.stop() } } + +// MARK: - The login gate, end to end (RFC 025 §3.3/§3.4, D1–D5) + +/// A gated pair over the loopback tailnet: the Layer 3 filter, the hello +/// refusal, and the fail-closed row where Layer 3 reports no login at all. +@Suite struct NodeLoginGateTests { + struct ChatPayload: Codable, Equatable { + var text: String + } + + private func tempDir() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("truffle-gate-test-\(UUID().uuidString)") + } + + private func startNode( + network: LoopbackNetwork, + tailscaleId: String, + deviceName: String, + loginName: String? = nil, + loginAllow: [String] = [] + ) async throws -> (MeshNode, URL) { + let hostname = Hostname.tailscaleHostname( + appId: try AppId(parsing: "demo"), deviceName: DeviceName(deviceName)) + let backend = await network.join( + tailscaleId: tailscaleId, hostname: hostname, loginName: loginName) + let dir = tempDir() + let node = try await MeshNode.start( + MeshConfiguration( + appId: "demo", deviceName: deviceName, stateDirectory: dir, + auth: .existingState, loginAllow: loginAllow), + backend: backend, + frameTransport: LengthPrefixFrameTransport(), + identityPolicy: .failClosed) + return (node, dir) + } + + /// Await one value with a deadline, so a missing event fails the test + /// instead of hanging it. + private func firstOrNil( + timeout: Duration, _ produce: @escaping @Sendable () async -> T? + ) async -> T? { + await withTaskGroup(of: T?.self) { group in + group.addTask { await produce() } + group.addTask { + try? await Task.sleep(for: timeout) + return nil + } + let first = await group.next() ?? nil + group.cancelAll() + return first + } + } + + /// (a) A matching glob: the pair converges and messages flow, exactly as + /// an ungated pair does. + @Test func gatedPairWithMatchingLoginConverges() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "bob@CORP.com", loginAllow: ["*@corp.com"]) + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + let inbox = Mailbox() + let subscription = await bob.onMessage(namespace: "chat") { message in + await inbox.put(message) + } + + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("gated alice never listed bob under a matching glob") + return + } + // The login is a first-class field on the snapshot (D7), carried + // through Layer 3 in the case the netmap reported it. + #expect(bobPeer.loginName == "bob@CORP.com") + #expect(await alice.loginName == "alice@corp.com") + #expect(await alice.localPeer.loginName == "alice@corp.com") + #expect(await alice.loginAllow == ["*@corp.com"]) + + try await alice.sendJSON( + to: bobPeer, namespace: "chat", payload: ChatPayload(text: "hi bob")) + guard let received = await inbox.take() else { + Issue.record("bob received nothing") + return + } + #expect(try received.decodePayload(ChatPayload.self) == ChatPayload(text: "hi bob")) + #expect(received.from.tailscaleId == "ts-a") + + await subscription.cancel() + await alice.stop() + await bob.stop() + } + + /// (b) A foreign glob: the peer is never listed, AND the hello that peer + /// dials with is refused — the two halves of the gate, separately. + @Test func foreignLoginIsNeitherListedNorAdmitted() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + // Bob is ungated and on a different login: he still discovers and + // dials Alice, which is exactly what the hello gate must stop. + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "mallory@evil.com") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + try await alice.waitUntilRunning(timeout: .seconds(2)) + try await bob.waitUntilRunning(timeout: .seconds(2)) + + // Layer 3: Alice never lists Bob, though the hostname prefix matches. + #expect(try await alice.peer("ts-b", waitMs: 500) == nil) + #expect(await alice.peers().isEmpty) + + // Layer 4/5: Bob DOES list Alice and dials her; the hello is refused. + guard let alicePeer = try await bob.peer("ts-a", waitMs: 2_000) else { + Issue.record("ungated bob should still discover alice") + return + } + await #expect(throws: MeshError.self) { + try await bob.sendJSON( + to: alicePeer, namespace: "chat", payload: ChatPayload(text: "let me in")) + } + // The refusal left no provisional entry behind on the gated node. + #expect(try await alice.peer("ts-b") == nil) + #expect(await alice.peers().isEmpty) + + await alice.stop() + await bob.stop() + } + + /// (c) Fail closed: on a gated node a Layer 3 row with NO login is not a + /// peer — and the node says so rather than showing an empty mesh. + @Test func gatedNodeTreatsLoginlessRowAsNotAPeer() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + defer { try? FileManager.default.removeItem(at: dirA) } + + let notices = Mailbox() + let stream = await alice.events + let drain = Task { + for await event in stream { + if case .health(let message) = event { + _ = await notices.put(message) + } + } + } + defer { drain.cancel() } + + // A well-named app peer whose netmap row carries no login at all. + _ = await network.join( + tailscaleId: "ts-nologin", + hostname: Hostname.tailscaleHostname( + appId: try AppId(parsing: "demo"), deviceName: DeviceName("Ghost")), + loginName: nil) + try await alice.refresh() + + #expect(try await alice.peer("ts-nologin") == nil) + #expect(await alice.peers().isEmpty) + + let notice = await firstOrNil(timeout: .seconds(2)) { await notices.take() } + #expect(notice?.contains("login gate active") == true) + + // The same row WITH a matching login is admitted — proving the row + // was dropped for its login and not for its hostname. + await network.setLogin(tailscaleId: "ts-nologin", loginName: "ghost@corp.com") + try await alice.refresh() + let admitted = try await alice.peer("ts-nologin", waitMs: 1_000) + #expect(admitted?.loginName == "ghost@corp.com") + + await alice.stop() + } + + /// An ungated node is unchanged: a login-less row is still a peer. + @Test func ungatedNodeStillAdmitsLoginlessRows() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice") + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) + #expect(bobPeer != nil) + #expect(bobPeer?.loginName == nil) + #expect(await alice.loginName == nil) + #expect(await alice.loginAllow.isEmpty) + + await alice.stop() + await bob.stop() + } +} From 45f37207694c4759404004d245a4b39587574d9e Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 00:52:17 -0700 Subject: [PATCH 04/14] =?UTF-8?q?docs(rfc):=20RFC=20024=20=C2=A78.1.2=20?= =?UTF-8?q?=E2=80=94=20the=20login=20gate=20on=20the=20Swift=20plane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 025 is the normative text for the grammar, the Layer 3 filter and the hello table; §8.1.2 records only what the Swift surfaces are and where they live, and points at RFC 025 for the rest. RFC 025 itself is untouched here — it lands from the Rust branch. - §8.1 item 5's close-code list is corrected AT ITS SOURCE rather than only downstream: 4004 is added, and 4003 now reads "or no authenticated identity at all", which is what a gated node does under either IdentityPolicy. - §8.1.2 names LoginGlob and its two entry points, the MeshConfiguration declaration, every surface the login appears on (including that Peer.loginName is part of Peer's equality so a SwiftUI row re-renders), the Layer 3 predicate and why provisional entries are exempt, the ordered §3.4 table with its close codes and thrown errors, and what is unchanged for an ungated node. - It closes by pointing at RFC 025 §3.3's dated correction for the one place TailscaleKit's binding could not supply what the RFC specified, and records that the status-login overlay collapses back into the decoder when IpnState.PeerStatus grows UserID. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- docs/rfcs/024-truffle-swift.md | 59 +++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/docs/rfcs/024-truffle-swift.md b/docs/rfcs/024-truffle-swift.md index d6cb510e..62ee477f 100644 --- a/docs/rfcs/024-truffle-swift.md +++ b/docs/rfcs/024-truffle-swift.md @@ -597,7 +597,7 @@ Phase 1 (they are what Swift↔Swift messaging runs on); Phase 2 is *verificatio 2. **Role ordering:** the dialing/client side completes the RFC 6455 upgrade, sends its hello, then reads the server hello. The accepting/server side upgrades, reads and validates the client hello, performs inbound identity verification, then sends its hello. 3. **Hello frame:** emitted as a WebSocket **Text** frame containing hello v2 (§8.2). Receivers accept Text or Binary JSON for compatibility. The first application-level frame in each direction must be hello; up to 16 control frames may precede it. 4. **Timeouts:** hello read timeout **5s**. The complete incoming upgrade + hello exchange is bounded by **10s**. -5. **Close codes:** `appId` mismatch → **4001**; malformed, invalid, or missing hello → **4002**; claimed `tailscale_id` contradicting authenticated identity → **4003**. A rejected hello never confirms the peer and never enables application traffic. +5. **Close codes:** `appId` mismatch → **4001**; malformed, invalid, or missing hello → **4002**; claimed `tailscale_id` contradicting authenticated identity, or no authenticated identity at all → **4003**; a WhoIs login that no `loginAllow` glob admits → **4004** (added 2026-09-16, §8.1.2). A rejected hello never confirms the peer and never enables application traffic. 6. **Application frames:** compact JSON envelopes (§8.3) are emitted as WebSocket **Binary** frames; receivers also accept Text frames containing JSON. 7. **Bounds:** maximum WebSocket frame/message size **16 MiB**; maximum **256** simultaneous incoming upgrade/hello handshakes. Envelope field and payload bounds in §8.3 apply within that transport limit. 8. **Keepalive:** after hello, send Ping every **10s** and require a Pong within **30s**. Peers must answer Ping according to RFC 6455. Ping payload contents are not semantically significant. @@ -619,6 +619,63 @@ desired Swift security policy. It does not prevent interop when WhoIs succeeds, and the difference must be covered by the live interop matrix. Swift cannot claim 4003 support until the Phase 0 backend exposes WhoIs. +#### 8.1.2 Login gate (RFC 025, 2026-09-16) + +RFC 025 makes a node's **tailnet login** the mesh boundary. Its §3 is the +normative text for the grammar, the Layer 3 filter, and the hello table; this +section records only what the Swift surfaces are and where they live. + +A node declares the gate once, for its lifetime: + +```swift +MeshConfiguration(appId: "field-tools", deviceName: "Alice's iPhone", + loginAllow: ["*@corp.com"]) // empty (default) = no gate +``` + +- **`LoginGlob`** (`Sources/Truffle/Identity/LoginGlob.swift`) is the grammar — + a port of Go's `path.Match`, matched after lowercasing both sides. + `LoginGlob.match(_:_:)` is the case-sensitive primitive and throws + `LoginGlob.BadPattern` on a malformed pattern; `LoginGlob.allowed(_:login:)` + is the gate: empty list → `true`, absent or empty login under a non-empty + list → `false`, malformed globs skipped. Both of the Rust port's test tables + (`network/login_allow.rs`) are reproduced verbatim in `LoginGlobTests`, so + the Go sidecar, the Rust core, and the Swift core cannot drift. +- **The login is on every identity surface** (RFC 025 §3.6, D7), optional and + never fabricated — an empty string on the wire becomes `nil`: + `AuthenticatedPeer.loginName` / `.displayName`, `BackendPeer.loginName`, + `BackendStatus.loginName`, `Peer.loginName` (part of `Peer`'s equality, so a + SwiftUI row re-renders when it changes), `MeshNode.loginName` (self) and + `MeshNode.loginAllow`, and `MeshModel.loginName`. A tagged node's + `tagged-devices` pseudo-login is passed through, not special-cased. +- **Layer 3** — `MeshNode.upsertFromLayer3` admits a row only if + `Hostname.isAppPeer(...) && LoginGlob.allowed(loginAllow, login:)`. On a + gated node a row with no login is **not a peer**. Provisional entries from a + raced inbound hello keep merging as before: that hello already passed the + gate, so re-gating it would drop a peer the node has a live session with. + A gated node that sees a login-less app peer emits one `.health` notice, so + a mesh emptied by the gate is never silent. +- **The hello** — `Handshake.server(..., loginAllow:)` implements RFC 025 + §3.4's table in order: validate hello → absent authenticated identity → + **4003** (on a gated node under EITHER `IdentityPolicy`; a gate is never + bypassed by `.allowUnverified`, because without WhoIs there is no login to + gate on) → claimed `tailscale_id` mismatch → **4003** → login absent or + matching no glob → **4004** `SessionCloseCode.loginRefused`, thrown as + `MeshError.loginRefused(login:)`. All of it happens **before** our hello is + sent, so a refused caller never learns our identity block. The dialing side + needs no new check: a gated node only dials peers Layer 3 reported. +- **Unchanged**: the hello envelope stays at version 2 and never carries a + login — WhoIs is the only authority (RFC 025 §3.7, D8). An empty + `loginAllow` is today's behaviour exactly, including the existing fail-open + under `.allowUnverified`. + +Where TailscaleKit's binding could not supply the login RFC 025 §3.3 +specifies, see that section's dated correction: `IpnState.PeerStatus` decodes +without `UserID`, so `TailscaleKitBackend` reads `/localapi/v0/status` itself +for the logins and overlays them onto the mapped `BackendStatus` +(`Sources/TruffleTailscale/LocalAPIIdentity.swift`, covered by +`LocalAPIIdentityTests` on the macOS host). When `PeerStatus` grows `UserID`, +the overlay collapses into the decoder. + ### 8.2 Hello envelope (hello v2 — `session/hello.rs`, RFC 017 §8) ```json From 3fc787394218f72411b61e492689210a8552d4fe Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 00:58:14 -0700 Subject: [PATCH 05/14] test(apple): an unresolvable owner is not an owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four tests asked for on review, covering the gap between "the status names a UserID" and "we know who that is". The overlay resolves a row's UserID through the status's User{} map; a UserID the map does not describe must therefore resolve to NOTHING, on both the peer and the self side. - unresolvableUserIdYieldsNoLoginRatherThanEmptyString: the `nORPHAN` row names user 55555, which User{} has no profile for. Its login comes out nil — and is explicitly asserted not to be "" — while the resolvable row beside it still carries its login, so the nil is the missing profile and not a wholesale decode failure. - aGatedNodeDropsAnUnresolvableOwnersRow asserts the consequence at the seam where the overlay and the gate meet, using the exact predicate MeshNode.upsertFromLayer3 applies. Of four rows — a UserID with no profile, a row with no UserID at all, a real login on the wrong domain, and a matching login — only the last survives the gate. - unresolvableSelfUserIdLeavesTheSelfLoginNil: the same rule for the node's own login, so BackendStatus.loginName and therefore MeshNode.loginName come out nil rather than "". - aPeerlessStatusStillResolvesTheSelfLogin records an upstream fact for whoever is tempted to read the lighter endpoint: on tailscale 1.102.3 StatusWithoutPeers (status?peers=false) deliberately keeps the self user's profile in User{} (tailscale/tailscale#19894). So an empty Peer{} means "no peers were asked for", never "unknown owner". The overlay reads the FULL status because the peers' logins need it; this test guards the self half if that ever changes. Verified not vacuous: mutating LoginOverlay.applied(to:) to fabricate a login for a row it has none for — the exact bug the "absent, never fabricated" rule forbids — turned five rows red, with the gate test reporting admitted ["nORPHAN", "nNOUSER", "nBOB"] against the expected ["nBOB"]. The file was then restored and checked back against its pre-mutation SHA-256. Gates after: swift test --package-path apple 95 -> 99 tests in 17 suites, exit 0 · root "as published" resolve + build + test likewise 99/17 · xcodebuild -scheme TruffleTailscale BUILD SUCCEEDED with zero warnings for both generic/platform=iOS Simulator and generic/platform=iOS. Note for anyone re-running these: the two xcodebuild destinations must run SERIALLY. Run concurrently they share one DerivedData and the second dies with "unable to attach DB ... database is locked", which reads as a build failure and is only contention. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- .../LocalAPIIdentityTests.swift | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift b/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift index f8fcbf5c..37165324 100644 --- a/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift +++ b/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift @@ -219,4 +219,88 @@ private func decode(_ type: T.Type, _ json: String) throws -> T { #expect(overlay.selfLogin == nil) #expect(overlay.byStableNodeId["nBOB"] == nil) } + + /// A `UserID` the status's `User{}` does not describe resolves to NOTHING + /// — absent, never fabricated, and never the empty string. `nORPHAN` + /// names user 55555, which the map has no profile for. + @Test func unresolvableUserIdYieldsNoLoginRatherThanEmptyString() throws { + let merged = try overlay().applied( + to: BackendStatus( + tailscaleId: "nSELF", + peers: [ + BackendPeer(tailscaleId: "nORPHAN", hostname: "truffle-demo-orphan"), + BackendPeer(tailscaleId: "nBOB", hostname: "truffle-demo-bob"), + ])) + #expect(merged.peers[0].loginName == nil) + #expect(merged.peers[0].loginName != "") + #expect(merged.peers[1].loginName == "alice@corp.com") + } + + /// …and a gated node therefore DROPS that row. This asserts the exact + /// predicate `MeshNode.upsertFromLayer3` applies, at the seam where the + /// overlay and the gate meet: an unresolvable owner is not an owner, so + /// the peer is not a peer. + @Test func aGatedNodeDropsAnUnresolvableOwnersRow() throws { + let gate = ["*@corp.com"] + let merged = try overlay().applied( + to: BackendStatus( + tailscaleId: "nSELF", + peers: [ + BackendPeer(tailscaleId: "nORPHAN", hostname: "truffle-demo-orphan"), + BackendPeer(tailscaleId: "nNOUSER", hostname: "truffle-demo-nouser"), + BackendPeer(tailscaleId: "nBOB", hostname: "truffle-demo-bob"), + BackendPeer(tailscaleId: "nMALLORY", hostname: "truffle-demo-mallory"), + ])) + let admitted = merged.peers + .filter { LoginGlob.allowed(gate, login: $0.loginName) } + .map(\.tailscaleId) + // nORPHAN: UserID with no profile. nNOUSER: no UserID at all. + // nMALLORY: a real login on the wrong domain. Only nBOB survives. + #expect(admitted == ["nBOB"]) + } + + /// The node's OWN login gets the same treatment: a self `UserID` the map + /// does not describe leaves `BackendStatus.loginName` — and so + /// `MeshNode.loginName` — nil, not "". + @Test func unresolvableSelfUserIdLeavesTheSelfLoginNil() throws { + let overlay = LoginOverlay( + try decode( + LocalAPIStatusLogins.self, + """ + { + "BackendState": "Running", + "Self": {"ID": "nSELF", "UserID": 999}, + "Peer": {"k": {"ID": "nBOB", "UserID": 12345}}, + "User": {"12345": {"ID": 12345, "LoginName": "alice@corp.com"}} + } + """)) + #expect(overlay.selfLogin == nil) + #expect(overlay.selfLogin != "") + // The peer whose owner IS described still resolves, so the nil above + // is the missing profile and not a wholesale decode failure. + #expect(overlay.byStableNodeId["nBOB"] == "alice@corp.com") + + let merged = overlay.applied(to: BackendStatus(tailscaleId: "nSELF")) + #expect(merged.loginName == nil) + } + + /// The overlay reads the FULL status, but record the upstream fact that + /// makes a lighter read possible: on tailscale 1.102.3 `status?peers=false` + /// still carries the SELF user's profile in `User{}` (tailscale/tailscale + /// #19894), so a peer-less status resolves the self login. An empty + /// `Peer{}` therefore means "no peers asked for", never "unknown owner". + @Test func aPeerlessStatusStillResolvesTheSelfLogin() throws { + let overlay = LoginOverlay( + try decode( + LocalAPIStatusLogins.self, + """ + { + "BackendState": "Running", + "Self": {"ID": "nSELF", "UserID": 12345}, + "User": {"12345": {"ID": 12345, "LoginName": "alice@corp.com"}} + } + """)) + #expect(overlay.selfLogin == "alice@corp.com") + #expect(overlay.byStableNodeId.isEmpty) + } } From 96cf0f43373367062819e85b965f17d87d50c871 Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 00:58:34 -0700 Subject: [PATCH 06/14] =?UTF-8?q?docs(rfc):=20=C2=A78.1.2=20carries=20the?= =?UTF-8?q?=20overlay=20mechanism=20itself,=20not=20a=20pointer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On review: RFC 025 §3.3 is normative for the RULE — the login is a Layer 3 fact, and a gated node treats a row without one as not a peer — but not for the mechanism, which on the Apple plane could not be what it first described. The first draft of §8.1.2 pointed at RFC 025's dated correction for the mechanism too, which leaves this plane's actual behaviour readable only by chasing another document. It is now written out here, and RFC 025 is cited only for the rule. Now stated in §8.1.2: - Why the specified mapping is not expressible: the pinned TailscaleKit decodes IpnState.PeerStatus without UserID, Status.SelfStatus is a PeerStatus too, and Status.User exists keyed by the stringified user id with nothing to key it. - The exact read: one additional authenticated GET of the FULL LocalAPI status, /localapi/v0/status, over the same loopback the WhoIs path uses, decoding only Self.UserID, Peer[].{ID,UserID} and User{} — with the note that Peer is keyed by node key while each row's own ID is the stable node ID BackendPeer uses. - The cost, said plainly: one extra loopback GET per status refresh, and a refresh runs on every IPN bus notify. - Which endpoint and why the full one: the lighter status?peers=false would still resolve the SELF login, because on tailscale 1.102.3 StatusWithoutPeers keeps the self user's profile in User{} (tailscale/tailscale#19894). So an empty Peer{} means "no peers were asked for", never "unknown owner" — a distinction that only matters if this moves to the lighter endpoint, and one the new tests guard. - That an unresolvable UserID, a row with no UserID, and a failed overlay read all resolve identically: the login is absent, never fabricated and never "", a gated node admits nobody, and the one-shot .health notice keeps the emptied mesh from being silent. - The future-work line: when TailscaleKit's PeerStatus decodes UserID, the overlay collapses into the decoder and the extra GET goes away. - Why the decoders sit outside TailscaleKitBackend's `#if os(iOS) && canImport(TailscaleKit)` — that guard compiles to nothing on the macOS host, so decoding placed inside it would have no test anywhere. Docs only; no code changed. Gates re-run all the same: 99 tests in 17 suites on both manifests, both iOS slices BUILD SUCCEEDED. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- docs/rfcs/024-truffle-swift.md | 45 ++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/docs/rfcs/024-truffle-swift.md b/docs/rfcs/024-truffle-swift.md index 62ee477f..e36aece1 100644 --- a/docs/rfcs/024-truffle-swift.md +++ b/docs/rfcs/024-truffle-swift.md @@ -668,13 +668,44 @@ MeshConfiguration(appId: "field-tools", deviceName: "Alice's iPhone", `loginAllow` is today's behaviour exactly, including the existing fail-open under `.allowUnverified`. -Where TailscaleKit's binding could not supply the login RFC 025 §3.3 -specifies, see that section's dated correction: `IpnState.PeerStatus` decodes -without `UserID`, so `TailscaleKitBackend` reads `/localapi/v0/status` itself -for the logins and overlays them onto the mapped `BackendStatus` -(`Sources/TruffleTailscale/LocalAPIIdentity.swift`, covered by -`LocalAPIIdentityTests` on the macOS host). When `PeerStatus` grows `UserID`, -the overlay collapses into the decoder. +##### Where the login comes from on this plane + +RFC 025 §3.3 is normative for the **rule** — the login is a Layer 3 fact, and a +gated node treats a row without one as not a peer. It is not normative for the +mechanism, which on the Apple plane could not be what it first described (that +sentence now carries a dated correction there): the pinned TailscaleKit decodes +`IpnState.PeerStatus` **without `UserID`**, and `Status.SelfStatus` is a +`PeerStatus` too, so nothing in the decoded status can key `Status.User` — which +does exist, and is keyed by the stringified user id, with nothing to key it. + +The mechanism is therefore: + +- `TailscaleKitBackend.refreshStatus` issues **one additional authenticated GET + of the full LocalAPI status, `/localapi/v0/status`**, over the same loopback + the WhoIs path uses, and decodes only `Self.UserID`, `Peer[].{ID,UserID}` and + `User{}`. `Peer` is keyed by node key, so each row's own `ID` is the stable + node ID `BackendPeer` uses, and its `UserID` resolves through `User{}`. +- The result overlays the logins onto the mapped `BackendStatus`; TailscaleKit's + own decode stays the authority for every other field. Cost: one extra loopback + GET per status refresh, and a refresh runs on every IPN bus notify. +- The **full** status is read deliberately, because the peers' logins need it. + The lighter `status?peers=false` would still resolve the SELF login — on + tailscale 1.102.3 `StatusWithoutPeers` keeps the self user's profile in + `User{}` (tailscale/tailscale#19894) — so an empty `Peer{}` means "no peers + were asked for", never "unknown owner". That distinction matters only if this + ever moves to the lighter endpoint. +- A `UserID` the map does not describe, a row carrying no `UserID`, and a failed + overlay read all resolve identically: the login is **absent** — never + fabricated, never `""`. A gated node then admits nobody, which is the + fail-closed answer, with the one-shot `.health` notice above so the emptied + mesh is not silent. +- **Future work:** when TailscaleKit's `PeerStatus` decodes `UserID`, the overlay + collapses into the decoder and this extra GET goes away. + +The decoders are `Sources/TruffleTailscale/LocalAPIIdentity.swift`, deliberately +outside `TailscaleKitBackend.swift`'s `#if os(iOS) && canImport(TailscaleKit)`: +that guard compiles to nothing on the macOS host, so decoding placed inside it +would have no test anywhere. `LocalAPIIdentityTests` covers it there. ### 8.2 Hello envelope (hello v2 — `session/hello.rs`, RFC 017 §8) From e0bcbb27271f0bc507a50fe8d627e3496e4b3914 Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 01:12:03 -0700 Subject: [PATCH 07/14] fix(apple): a login the gate refuses is a departure, not an update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH-1 from review, and a real hole in the gate I shipped. I wrote upsertFromLayer3's existing-entry branch to merge Layer 3 metadata and return BEFORE both guards, and documented that as deliberate — "that hello already passed the gate". It is wrong for the case that matters: a stable node ID survives a device transfer, so a node that RE-SIGNS under a different login arrives as an UPDATE to the existing row, not as a new one. The gate therefore ran only at entry creation. The reviewer proved it live: gated Alice admits Bob as bob@corp.com, Bob re-signs as mallory@evil.com, and Alice keeps him, dials him, and accepts his frames. RFC 025 §3.3's rule, as amended: a row whose login the gate REFUSES is a departure. The entry is evicted, its session closed, and peerLeft emitted — exactly as if the peer had left the tailnet. A later row that passes readmits it as a NEW generation, because a rejoin is never the same row (RFC 022 §7.7). - upsertFromLayer3 now returns whether the row must be evicted. It is sync and removeEntry is async, so both callers do the eviction: apply(status:)'s peer loop and the .peerUpsert push path. removeEntry already closes the session and emits peerLeft, so a refused login produces exactly the events a departure does. - An ABSENT login on an existing row stays STICKY rather than evicting: a transient failure to read the logins must not empty a gated mesh. Only a login that is present and refused is a departure. - Provisional entries are not exempt. They still merge, but they are evicted on a refused login like any other row — a raced hello is not a permanent pass. - Ungated nodes are unaffected: LoginGlob.allowed([], login:) is always true, so nothing is ever evicted without a gate. Also LOW from the same review: adoptInbound now carries the WhoIs loginName that PASSED the hello gate onto the provisional entry, which knew it and discarded it. confirm(identity:tailscaleId:loginName:) never overwrites a known login with nothing. MEDIUM-1 lands here too, because it is the same file. `MeshNode.whoIs( remoteEndpoint:)` is new and public: a passthrough to the backend's WhoIs, so an app that opens its own port with `listen(port:)` has an identity to gate on. The raw plane is deliberately NOT gated by loginAllow (RFC 025 §3.7, D9) — the node's list admits Layer 3 peers and session-plane hellos, and the app owns admission on its own port. Until now `MeshAcceptedConnection` carried only `remoteEndpoint` and there was no way to resolve it, so RFC 025 §3.7's assumption that "the app reads the connection's identity" had nothing to read. The MeshNode half of MEDIUM-2 is here as well: establishSession no longer echoes a 4002 at a remote that already closed with its own code. The Handshake half is the next commit. Tested by the following commit, and verified against the ORIGINAL bug: reverting this branch's guard reproduces the reviewer's scenario exactly — no peerLeft, the peer retained, send does NOT throw, and the generation unchanged at 1. Gates: swift test --package-path apple 99 -> 104 tests in 17 suites, exit 0 · root "as published" likewise 104/17 · xcodebuild -scheme TruffleTailscale BUILD SUCCEEDED with zero warnings for both iOS destinations, run serially. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- apple/Sources/Truffle/Mesh/MeshNode.swift | 81 ++++++++++++++++++----- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/apple/Sources/Truffle/Mesh/MeshNode.swift b/apple/Sources/Truffle/Mesh/MeshNode.swift index ea794a0f..00843e6e 100644 --- a/apple/Sources/Truffle/Mesh/MeshNode.swift +++ b/apple/Sources/Truffle/Mesh/MeshNode.swift @@ -515,6 +515,19 @@ public actor MeshNode { return try await backend.dial(host: dialHost(for: entry), port: port) } + /// WhoIs for an address this node accepted on the RAW plane + /// (RFC 025 §3.7, D9). `listen(port:)` is NOT gated by `loginAllow` — the + /// node's list admits peers and session-plane hellos, and the app owns + /// admission on its own port. This is how an app gates an accepted + /// connection: resolve `MeshAcceptedConnection.remoteEndpoint`, then + /// decide on `loginName` (with `LoginGlob.allowed` if it wants the same + /// grammar). Throws when the lookup fails; an empty `tailscaleId` means + /// WhoIs produced no concrete identity and must be treated as untrusted. + public func whoIs(remoteEndpoint: String) async throws -> AuthenticatedPeer { + guard !isStopped else { throw MeshError.stopped } + return try await backend.whoIs(remoteEndpoint: remoteEndpoint) + } + public func listen(port: UInt16) async throws -> any MeshListener { guard !isStopped else { throw MeshError.stopped } guard port != SessionLimits.sessionPort else { @@ -546,7 +559,9 @@ public actor MeshNode { case .status(let status): await apply(status: status) case .peerUpsert(let peer): - upsertFromLayer3(peer) + if upsertFromLayer3(peer) { + await removeEntry(tailscaleId: peer.tailscaleId) + } case .peerLeft(let tailscaleId): await removeEntry(tailscaleId: tailscaleId) case .authRequired(let url): @@ -583,7 +598,9 @@ public actor MeshNode { var seen = Set() for peer in status.peers { seen.insert(peer.tailscaleId) - upsertFromLayer3(peer) + if upsertFromLayer3(peer) { + await removeEntry(tailscaleId: peer.tailscaleId) + } } // RFC 025 §3.3: a gated node cannot admit a peer whose login Layer 3 // never reported. Say so — once — rather than presenting an @@ -615,12 +632,29 @@ public actor MeshNode { /// allow-list. A gated node treats a row WITHOUT a login as not a peer /// (fail closed). /// - /// Provisional entries created by a raced inbound hello merge Layer 3 - /// metadata into the same generation as before: that hello already passed - /// the gate in `Handshake.server`, so re-gating it here would drop a peer - /// the node has an authenticated session with. - private func upsertFromLayer3(_ peer: BackendPeer) { + /// The gate runs on entry CREATION and on every later row for an entry + /// that already exists. A stable node ID survives a device transfer, so + /// the netmap reports a re-signed node as an UPDATE, not a new row: if the + /// gate ran only at creation, a peer admitted as `bob@corp.com` would keep + /// its place after re-signing as a foreign login. A row whose login the + /// gate REFUSES is therefore a departure — the caller evicts the entry, + /// which closes its session and emits `peerLeft`. + /// + /// An ABSENT login on an existing row is sticky instead: a transient + /// overlay failure must not empty a gated mesh. Provisional entries from a + /// raced inbound hello keep merging — that hello passed the gate in + /// `Handshake.server` — but they are evicted on a refused login like any + /// other row. + /// + /// - Returns: `true` when this row must be evicted. Eviction is async and + /// this is not, so the caller performs it. + private func upsertFromLayer3(_ peer: BackendPeer) -> Bool { if var existing = entries[peer.tailscaleId] { + if let login = peer.loginName, + !LoginGlob.allowed(config.loginAllow, login: login) + { + return true + } existing.hostname = peer.hostname existing.tailnetIPs = peer.tailnetIPs existing.online = peer.online @@ -628,13 +662,13 @@ public actor MeshNode { existing.provisional = false entries[peer.tailscaleId] = existing emit(.peerUpsert(makePeer(from: existing))) - return + return false } guard Hostname.isAppPeer(hostname: peer.hostname, appId: appId.value) else { - return + return false } guard LoginGlob.allowed(config.loginAllow, login: peer.loginName) else { - return + return false } generationCounter += 1 let entry = RegistryEntry( @@ -648,6 +682,7 @@ public actor MeshNode { provisional: false) entries[peer.tailscaleId] = entry emit(.peerUpsert(makePeer(from: entry))) + return false } private func removeEntry(tailscaleId: String) async { @@ -713,7 +748,12 @@ public actor MeshNode { expectedTailscaleId: tailscaleId) } } catch { - await frames.close(code: SessionCloseCode.helloProtocol, reason: "handshake failed") + // A refusal (4001–4004) already closed the socket from the far + // end; echoing a close would be noise. Anything else gets one. + if !Handshake.isRefusal(error) { + await frames.close( + code: SessionCloseCode.helloProtocol, reason: "handshake failed") + } throw error } @@ -748,9 +788,11 @@ public actor MeshNode { /// Record a completed hello: confirm an existing candidate, or create a /// provisional entry when the hello raced ahead of the netmap /// (RFC 024 §7.2). - private func confirm(identity: PeerIdentity, tailscaleId: String) { + private func confirm(identity: PeerIdentity, tailscaleId: String, loginName: String? = nil) { if var entry = entries[tailscaleId] { entry.identity = identity + // Never overwrite a known login with nothing. + if let loginName { entry.loginName = loginName } entries[tailscaleId] = entry emit(.peerUpsert(makePeer(from: entry))) } else { @@ -761,7 +803,10 @@ public actor MeshNode { hostname: "", tailnetIPs: [], online: true, - loginName: nil, + // On a gated node this login is the one that PASSED the hello + // gate, so the provisional row is honest about who it admitted + // instead of waiting for the netmap to say. + loginName: loginName, identity: identity, provisional: true) entries[tailscaleId] = entry @@ -906,7 +951,9 @@ public actor MeshNode { authenticated: authenticated, policy: identityPolicy, loginAllow: loginAllow) - return InboundHandshake(frames: frames, identity: identity) + return InboundHandshake( + frames: frames, identity: identity, + loginName: authenticated?.loginName) } await adoptInbound(identity) } catch { @@ -918,10 +965,14 @@ public actor MeshNode { private struct InboundHandshake: Sendable { let frames: any SessionFrames let identity: PeerIdentity + /// The WhoIs login this caller passed the gate with, if any. + let loginName: String? } private func adoptInbound(_ handshake: InboundHandshake) async { - confirm(identity: handshake.identity, tailscaleId: handshake.identity.tailscaleId) + confirm( + identity: handshake.identity, tailscaleId: handshake.identity.tailscaleId, + loginName: handshake.loginName) if let previous = sessions.removeValue(forKey: handshake.identity.tailscaleId) { previous.pump?.cancel() previous.heartbeat?.cancel() From 0329895893d3e759c5a6efa5000d05be39ade588 Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 01:13:01 -0700 Subject: [PATCH 08/14] fix(apple): the dialer can tell a refusal from a broken pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEDIUM-2 from review, Handshake half. `receiveHello` turned every close frame into `protocolViolation`, so a 4004 the gate sent arrived at the dialing side indistinguishable from a dead socket — `loginRefused` was only ever thrown in the SERVER role, by the side that ran the gate. A dialer could not tell "this node refuses my login" from "the connection broke", which is exactly the distinction a caller needs in order to stop retrying. Mirroring the Rust core: - `MeshError.helloRefused(code: UInt16, reason: String)` is the dialing side's view of a refusal, carrying the code the remote actually sent: 4001 app mismatch, 4002 hello protocol, 4003 identity, 4004 login refused. - `receiveHello` raises it for ANY application close (4000–4999) received before the hello. Codes outside that range stay `protocolViolation`, since they are transport-level closures and not a peer's decision. - `MeshError.loginRefused(login:)` is unchanged and remains the SERVER-role error — the two are different viewpoints on the same event, and both are wanted. - Neither role echoes a 4002 back at a refusal. The socket is already closed from the far end, so the echo does nothing except bury the reason in the logs of whoever reads them next. `Handshake.isRefusal` is the single predicate both roles and `MeshNode.establishSession` use. Verified against the pre-fix behaviour: disabling the 4000–4999 classification reproduces the reported symptom exactly — the dialer receives `protocolViolation("peer closed connection before hello (code 4004: login refused)")` instead of `helloRefused(code: 4004, reason: "login refused")`. Gates: swift test --package-path apple 104 tests in 17 suites, exit 0 · root "as published" likewise 104/17 · xcodebuild -scheme TruffleTailscale BUILD SUCCEEDED with zero warnings for both iOS destinations. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- apple/Sources/Truffle/Mesh/MeshError.swift | 8 ++++- apple/Sources/Truffle/Session/Handshake.swift | 35 +++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apple/Sources/Truffle/Mesh/MeshError.swift b/apple/Sources/Truffle/Mesh/MeshError.swift index ee3acb2e..53f8b3fe 100644 --- a/apple/Sources/Truffle/Mesh/MeshError.swift +++ b/apple/Sources/Truffle/Mesh/MeshError.swift @@ -12,8 +12,14 @@ public enum MeshError: Error, Sendable, Equatable { case identityUnavailable(String) case identityMismatch(claimed: String, authenticated: String) /// The caller's WhoIs login is absent from, or matches no glob in, this - /// node's `loginAllow` list (RFC 025 §3.4, D4). Close code 4004. + /// node's `loginAllow` list (RFC 025 §3.4, D4). Close code 4004. This is + /// the SERVER-role error — the side that ran the gate. case loginRefused(login: String?) + /// The remote closed with an application code (4000–4999) before sending + /// its hello: 4001 app mismatch, 4002 hello protocol, 4003 identity, + /// 4004 login refused. This is the DIALING side's view of a refusal, and + /// it carries the code so a caller can tell a gate from a broken pipe. + case helloRefused(code: UInt16, reason: String) case invalidPayload(String) case payloadTooLarge(actual: Int, limit: Int) case protocolViolation(String) diff --git a/apple/Sources/Truffle/Session/Handshake.swift b/apple/Sources/Truffle/Session/Handshake.swift index 8fd17f18..b4161cab 100644 --- a/apple/Sources/Truffle/Session/Handshake.swift +++ b/apple/Sources/Truffle/Session/Handshake.swift @@ -73,6 +73,14 @@ public enum Handshake { throw MeshError.protocolViolation("too many control frames before hello") } case .close(let code, let reason): + // An application close before the hello is the remote's + // REFUSAL, not a broken pipe — 4001 app mismatch, 4002 hello + // protocol, 4003 identity, 4004 login (RFC 025 §3.4). Carry + // the code so the dialing side can tell them apart; the Rust + // core surfaces the same distinction. + if (4000...4999).contains(code) { + throw MeshError.helloRefused(code: code, reason: reason) + } throw MeshError.protocolViolation( "peer closed connection before hello (code \(code): \(reason))") } @@ -100,9 +108,13 @@ public enum Handshake { } } catch { // Malformed / missing hello → 4002, mirroring desktop. Without - // this close the remote side would wait out its own timeout. - await frames.close( - code: SessionCloseCode.helloProtocol, reason: "hello not received") + // this close the remote side would wait out its own timeout. A + // REFUSAL is different: the remote already closed with its own + // code, so echoing 4002 at a dead socket only hides the reason. + if !isRefusal(error) { + await frames.close( + code: SessionCloseCode.helloProtocol, reason: "hello not received") + } throw error } @@ -165,10 +177,12 @@ public enum Handshake { try await receiveHello(frames) } } catch { - // Malformed / missing hello → 4002, mirroring desktop. Without - // this close the remote side would wait out its own timeout. - await frames.close( - code: SessionCloseCode.helloProtocol, reason: "hello not received") + // As in the client role: a remote that already closed with its own + // application code gets no 4002 echoed back at it. + if !isRefusal(error) { + await frames.close( + code: SessionCloseCode.helloProtocol, reason: "hello not received") + } throw error } @@ -224,6 +238,13 @@ public enum Handshake { // MARK: helpers + /// True for the error `receiveHello` raises when the remote closed with + /// an application code instead of sending a hello. + static func isRefusal(_ error: any Error) -> Bool { + guard let error = error as? MeshError, case .helloRefused = error else { return false } + return true + } + static func map(_ error: HelloValidationError) -> MeshError { switch error { case .malformed(let msg): From b8d918690b506c705f20c247cac421f59e706ae1 Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 01:13:29 -0700 Subject: [PATCH 09/14] test(apple): the gate runs on every row, and a refusal says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tests for the review findings, each written to fail against the code as it was rather than to describe the code as it now is. - aPeerThatResignsAsAForeignLoginIsEvicted is the HIGH-1 scenario end to end: gated Alice admits Bob at bob@corp.com and establishes a real session; setLogin re-signs the SAME stable node ID as mallory@evil.com; Alice must emit peerLeft, drop the row, and stop resolving the snapshot taken while he was admitted. Then the reverse — re-signing back onto the allow-list readmits him at a NEW generation, which is what distinguishes a rejoin from a row that never left. - anAbsentLoginOnAnAdmittedRowIsSticky pins the other half of the rule, so the fix cannot be over-applied: a row that loses its login keeps its place, its generation, and the login it was admitted with. - aRefusedDialerSeesTheCloseCodeNotAProtocolViolation is MEDIUM-2 from the dialing side: the error is helloRefused(code: 4004, reason: "login refused"), code and reason both. - whoIsResolvesAnAcceptedAddressOnTheRawPlane is MEDIUM-1: MeshNode.whoIs returns the authenticated login and display name for an accepted address, and the test gates on it with LoginGlob.allowed both ways, which is the sequence an app on its own port would write. - aProvisionalEntryCarriesTheLoginThatPassedTheGate is LOW, using a hidden node — WhoIs-resolvable and able to dial, absent from snapshots — so the entry can only have come from the hello. Its hostname is empty and its login is the one WhoIs supplied. Verified against the ORIGINAL defects, not merely green. Reverting the eviction guard reproduces the reviewer's scenario exactly: no peerLeft (departed → nil), the peer retained, send does NOT throw, and the generation unchanged at 1. Disabling the 4000–4999 close classification yields protocolViolation("peer closed connection before hello (code 4004: login refused)") in place of helloRefused. Six issues across the suite; both source files were then restored and checked back against their pre-mutation SHA-256. Gates: swift test --package-path apple 99 -> 104 tests in 17 suites, exit 0 · root "as published" likewise 104/17 · xcodebuild -scheme TruffleTailscale BUILD SUCCEEDED with zero warnings for generic/platform=iOS Simulator and generic/platform=iOS, run SERIALLY — concurrently they share one DerivedData and the second dies with "unable to attach DB ... database is locked", which reads as a build failure and is only contention. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- .../TruffleTests/NodeLoopbackTests.swift | 212 +++++++++++++++++- 1 file changed, 211 insertions(+), 1 deletion(-) diff --git a/apple/Tests/TruffleTests/NodeLoopbackTests.swift b/apple/Tests/TruffleTests/NodeLoopbackTests.swift index 7d9d282e..d800b8a6 100644 --- a/apple/Tests/TruffleTests/NodeLoopbackTests.swift +++ b/apple/Tests/TruffleTests/NodeLoopbackTests.swift @@ -586,12 +586,14 @@ struct PongDroppingTransport: FrameTransport { tailscaleId: String, deviceName: String, loginName: String? = nil, + displayName: String? = nil, loginAllow: [String] = [] ) async throws -> (MeshNode, URL) { let hostname = Hostname.tailscaleHostname( appId: try AppId(parsing: "demo"), deviceName: DeviceName(deviceName)) let backend = await network.join( - tailscaleId: tailscaleId, hostname: hostname, loginName: loginName) + tailscaleId: tailscaleId, hostname: hostname, loginName: loginName, + displayName: displayName) let dir = tempDir() let node = try await MeshNode.start( MeshConfiguration( @@ -750,6 +752,214 @@ struct PongDroppingTransport: FrameTransport { await alice.stop() } + /// HIGH-1 (found on review): the gate must run on every row, not only at + /// entry creation. A stable node ID survives a device transfer, so a + /// re-signed node arrives as an UPDATE to the existing row — and before + /// this fix an admitted peer kept its place after re-signing as a foreign + /// login, sessions and all. + @Test func aPeerThatResignsAsAForeignLoginIsEvicted() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "bob@corp.com") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + let departures = Mailbox() + let stream = await alice.events + let drain = Task { + for await event in stream { + if case .peerLeft(let peer) = event { _ = await departures.put(peer.tailscaleId) } + } + } + defer { drain.cancel() } + + // Admitted, and a real session established. + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("alice never admitted bob") + return + } + try await alice.sendJSON( + to: bobPeer, namespace: "chat", payload: ChatPayload(text: "hi")) + let admittedGeneration = bobPeer.generation + + // The device is transferred: same stable node ID, foreign login. + await network.setLogin(tailscaleId: "ts-b", loginName: "mallory@evil.com") + try await alice.refresh() + + // A refused login is a DEPARTURE: the row goes, and removeEntry — + // which is what emits this event — is also what closes the session. + let departed = await firstOrNil(timeout: .seconds(2)) { await departures.take() } + #expect(departed == "ts-b") + #expect(try await alice.peer("ts-b") == nil) + #expect(await alice.peers().isEmpty) + // The snapshot taken while he was admitted no longer resolves. + await #expect(throws: MeshError.peerGone(bobPeer.ref.description)) { + try await alice.sendJSON( + to: bobPeer, namespace: "chat", payload: ChatPayload(text: "still there?")) + } + + // And the reverse: re-signing back onto the allow-list readmits him, + // as a NEW generation — a rejoin is never the same row. + await network.setLogin(tailscaleId: "ts-b", loginName: "bob@corp.com") + try await alice.refresh() + guard let readmitted = try await alice.peer("ts-b", waitMs: 1_000) else { + Issue.record("alice never readmitted bob") + return + } + #expect(readmitted.loginName == "bob@corp.com") + #expect(readmitted.generation != admittedGeneration) + + await alice.stop() + await bob.stop() + } + + /// An ABSENT login on an existing row is sticky, NOT an eviction: a + /// transient overlay failure must not empty a gated mesh. + @Test func anAbsentLoginOnAnAdmittedRowIsSticky() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "bob@corp.com") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("alice never admitted bob") + return + } + await network.setLogin(tailscaleId: "ts-b", loginName: nil) + try await alice.refresh() + + let kept = try await alice.peer("ts-b") + #expect(kept != nil) + #expect(kept?.generation == bobPeer.generation) + #expect(kept?.loginName == "bob@corp.com") + + await alice.stop() + await bob.stop() + } + + /// MEDIUM-2: the dialer must be able to tell a gate from a broken pipe. + /// The refusal reaches the dialing side as the close code the gate sent. + @Test func aRefusedDialerSeesTheCloseCodeNotAProtocolViolation() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "mallory@evil.com") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + guard let alicePeer = try await bob.peer("ts-a", waitMs: 2_000) else { + Issue.record("ungated bob should still discover alice") + return + } + await #expect( + throws: MeshError.helloRefused( + code: SessionCloseCode.loginRefused, reason: "login refused") + ) { + try await bob.sendJSON( + to: alicePeer, namespace: "chat", payload: ChatPayload(text: "let me in")) + } + + await alice.stop() + await bob.stop() + } + + /// MEDIUM-1: the raw plane has an identity surface. `listen(port:)` is NOT + /// gated by loginAllow, so an app that opens its own port gates itself + /// with this — the login it returns is the one WhoIs authenticated. + @Test func whoIsResolvesAnAcceptedAddressOnTheRawPlane() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "bob@corp.com", displayName: "Bob Example") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000), + let bobIP = bobPeer.tailnetIPs.first + else { + Issue.record("alice never discovered bob's address") + return + } + let identity = try await alice.whoIs(remoteEndpoint: "\(bobIP):40001") + #expect(identity.tailscaleId == "ts-b") + #expect(identity.loginName == "bob@corp.com") + #expect(identity.displayName == "Bob Example") + // The grammar an app would gate with is the same one the node uses. + #expect(LoginGlob.allowed(["*@corp.com"], login: identity.loginName)) + #expect(!LoginGlob.allowed(["*@other.com"], login: identity.loginName)) + + await alice.stop() + await bob.stop() + } + + /// LOW: a provisional entry from a raced inbound hello carries the login + /// that PASSED the gate, rather than waiting for the netmap to say. Bob is + /// hidden — WhoIs-resolvable and able to dial, but absent from snapshots. + @Test func aProvisionalEntryCarriesTheLoginThatPassedTheGate() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let bobHostname = Hostname.tailscaleHostname( + appId: try AppId(parsing: "demo"), deviceName: DeviceName("Bob")) + let bobBackend = await network.join( + tailscaleId: "ts-b", hostname: bobHostname, hidden: true, + loginName: "bob@corp.com") + let dirB = tempDir() + let bob = try await MeshNode.start( + MeshConfiguration( + appId: "demo", deviceName: "Bob", stateDirectory: dirB, auth: .existingState), + backend: bobBackend, + frameTransport: LengthPrefixFrameTransport(), + identityPolicy: .failClosed) + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + guard let alicePeer = try await bob.peer("ts-a", waitMs: 2_000) else { + Issue.record("bob never discovered alice") + return + } + try await bob.sendJSON( + to: alicePeer, namespace: "chat", payload: ChatPayload(text: "hello")) + + // Alice knows him only from the hello — no netmap row exists yet. + guard let provisional = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("alice never created a provisional entry for bob") + return + } + #expect(provisional.hostname.isEmpty) + #expect(provisional.deviceId != nil) + #expect(provisional.loginName == "bob@corp.com") + + await alice.stop() + await bob.stop() + } + /// An ungated node is unchanged: a login-less row is still a peer. @Test func ungatedNodeStillAdmitsLoginlessRows() async throws { let network = LoopbackNetwork() From 94b27a456ab1097af3c9dd4baa8365d5b6842cbf Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 01:13:29 -0700 Subject: [PATCH 10/14] =?UTF-8?q?docs(rfc):=20=C2=A78.1.2=20=E2=80=94=20th?= =?UTF-8?q?e=20gate=20runs=20on=20every=20row,=20and=20the=20raw=20plane?= =?UTF-8?q?=20is=20not=20gated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections to §8.1.2 from the review, each at its source. - The Layer 3 bullet said provisional entries were the exception and left the impression the predicate runs at entry creation. It now states that the predicate runs on creation AND on every later row, explains WHY (a stable node ID survives a device transfer, so a re-signed node arrives as an update), and gives the rule: a refused login is a departure — evicted, session closed, peerLeft emitted, readmitted later as a new generation. The sticky-on-absent half is stated beside it so the rule cannot be over-read, and provisional entries are described as carrying the login they passed the gate with rather than as exempt from it. - A new bullet records that the dialing side distinguishes a refusal from a broken pipe via MeshError.helloRefused(code:reason:), that neither role echoes a 4002 at an already-closed socket, and that loginRefused(login:) remains the server-role error. - A new bullet records that the raw plane is NOT gated by loginAllow (RFC 025 §3.7, D9) and how an app gates an accepted connection itself with MeshNode.whoIs(remoteEndpoint:) — including that an empty tailscaleId in the answer means WhoIs produced no concrete identity and must be treated as untrusted. Docs only; no code changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- docs/rfcs/024-truffle-swift.md | 37 +++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/rfcs/024-truffle-swift.md b/docs/rfcs/024-truffle-swift.md index e36aece1..878c7219 100644 --- a/docs/rfcs/024-truffle-swift.md +++ b/docs/rfcs/024-truffle-swift.md @@ -649,11 +649,23 @@ MeshConfiguration(appId: "field-tools", deviceName: "Alice's iPhone", `tagged-devices` pseudo-login is passed through, not special-cased. - **Layer 3** — `MeshNode.upsertFromLayer3` admits a row only if `Hostname.isAppPeer(...) && LoginGlob.allowed(loginAllow, login:)`. On a - gated node a row with no login is **not a peer**. Provisional entries from a - raced inbound hello keep merging as before: that hello already passed the - gate, so re-gating it would drop a peer the node has a live session with. - A gated node that sees a login-less app peer emits one `.health` notice, so - a mesh emptied by the gate is never silent. + gated node a row with no login is **not a peer**. + The predicate runs on entry **creation AND on every later row for an entry + that already exists** — not only at creation. A stable node ID survives a + device transfer, so the netmap reports a re-signed node as an UPDATE to the + existing row; a gate that ran only at creation would let a peer admitted as + `bob@corp.com` keep its place, its session and its frames after re-signing + as a foreign login. A row whose login the gate **refuses is a departure**: + the entry is evicted, its session closed, and `peerLeft` emitted, exactly as + if the peer had left the tailnet. A later row that passes readmits it as a + NEW generation, because a rejoin is never the same row (RFC 022 §7.7). + An **absent** login on an existing row is sticky instead of an eviction, so + a transient failure to read the logins cannot empty a gated mesh; a gated + node that sees a login-less app peer emits one `.health` notice, so a mesh + emptied by the gate is never silent. Provisional entries from a raced inbound + hello merge as before — that hello already passed the gate — and carry the + WhoIs login they passed it with, rather than waiting for the netmap; they are + evicted on a refused login like any other row. - **The hello** — `Handshake.server(..., loginAllow:)` implements RFC 025 §3.4's table in order: validate hello → absent authenticated identity → **4003** (on a gated node under EITHER `IdentityPolicy`; a gate is never @@ -663,6 +675,21 @@ MeshConfiguration(appId: "field-tools", deviceName: "Alice's iPhone", `MeshError.loginRefused(login:)`. All of it happens **before** our hello is sent, so a refused caller never learns our identity block. The dialing side needs no new check: a gated node only dials peers Layer 3 reported. +- **The dialing side** distinguishes a refusal from a broken pipe. Any + application close (4000–4999) received before the hello surfaces as + `MeshError.helloRefused(code:reason:)`, carrying the code the remote sent — + 4001 app mismatch, 4002 hello protocol, 4003 identity, 4004 login. Neither + role echoes a 4002 back at a refusal, because the socket is already closed + from the far end and the echo would only bury the reason. + `MeshError.loginRefused(login:)` remains the SERVER-role error, raised by the + side that ran the gate. +- **The raw plane is NOT gated** by `loginAllow` (RFC 025 §3.7, D9): the list + admits Layer 3 peers and session-plane hellos, and an app that opens its own + port with `listen(port:)` owns admission there. `MeshNode.whoIs(remoteEndpoint:)` + is how it does so — resolve `MeshAcceptedConnection.remoteEndpoint`, then + decide on the returned `loginName`, with `LoginGlob.allowed` if the app wants + the same grammar the node uses. An empty `tailscaleId` in the answer means + WhoIs produced no concrete identity and must be treated as untrusted. - **Unchanged**: the hello envelope stays at version 2 and never carries a login — WhoIs is the only authority (RFC 025 §3.7, D8). An empty `loginAllow` is today's behaviour exactly, including the existing fail-open From 1b430ee1f1ed726885181664ef5e27b68f6d0833 Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 01:16:31 -0700 Subject: [PATCH 11/14] test(apple): let a real MeshNode witness the gate, not an inline predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the 29d4d4d delta: aGatedNodeDropsAnUnresolvableOwnersRow (211e711) re-implemented the gate inline — merged.peers.filter { LoginGlob.allowed(gate, login:) } — and never touched MeshNode. So it asserted my construction against my construction: it would pass with the node's gate guard deleted, and it silently dropped the isAppPeer half that sits beside the login check. That is a test of the grammar wearing the name of a test of the node. - NodeLoginGateTests.aGatedNodeAdmitsOnlyTheRowThatPassesBothHalves is the real witness: a live MeshNode over a LoopbackNetwork applying its OWN predicate to five rows — a UserID with no profile, a row with no UserID, a resolvable owner on the wrong domain, an ALLOWED login on a node that is not an app peer, and one row passing both halves. Only the last is admitted. The stranger row is the half the inline predicate would have let through; the two absent-login rows are kept separate because they are separate nodes, not because the node can tell them apart — the overlay renders both as no login. - The overlay-level row stays, renamed to onlyAResolvableAllowedOwnerYieldsAnAcceptedLogin, with a comment that says what it actually does: checks the overlay's OUTPUT against the gate's grammar, cannot witness the node applying it, and names the test that can. Narrower than its old name claimed, and still worth pinning. Verified as the reviewer asked: deleting the creation-time guard (MeshNode.swift, `guard LoginGlob.allowed(config.loginAllow, login:)`) turns the new row red with exactly the right diagnostic — admitted ["ts-bob", "ts-mallory", "ts-nouser", "ts-orphan"] against the expected ["ts-bob"] — while ts-stranger stays out, so the row isolates the login half cleanly. The overlay-level test stayed GREEN under that same deletion, which is the blindness the review found, now stated in its own comment. gatedNodeTreatsLoginlessRowAsNotAPeer and foreignLoginIsNeitherListedNorAdmitted also reddened; aPeerThatResignsAsAForeignLoginIsEvicted correctly did not, since it exercises the existing-entry eviction path and not the creation guard. MeshNode.swift was then restored and checked back against its pre-mutation SHA-256. Gates: swift test --package-path apple 104 -> 105 tests in 17 suites, exit 0 · root "as published" likewise 105/17 · xcodebuild -scheme TruffleTailscale BUILD SUCCEEDED with zero warnings for both iOS destinations, run serially. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- .../LocalAPIIdentityTests.swift | 14 ++++-- .../TruffleTests/NodeLoopbackTests.swift | 49 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift b/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift index 37165324..cff80abd 100644 --- a/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift +++ b/apple/Tests/TruffleTailscaleTests/LocalAPIIdentityTests.swift @@ -236,11 +236,15 @@ private func decode(_ type: T.Type, _ json: String) throws -> T { #expect(merged.peers[1].loginName == "alice@corp.com") } - /// …and a gated node therefore DROPS that row. This asserts the exact - /// predicate `MeshNode.upsertFromLayer3` applies, at the seam where the - /// overlay and the gate meet: an unresolvable owner is not an owner, so - /// the peer is not a peer. - @Test func aGatedNodeDropsAnUnresolvableOwnersRow() throws { + /// Checks the overlay's OUTPUT against the gate's grammar — not against a + /// node. It re-states `LoginGlob.allowed` here rather than driving + /// `MeshNode`, so it cannot witness the node applying the predicate (nor + /// the `isAppPeer` half beside it); `NodeLoginGateTests`' + /// `aGatedNodeAdmitsOnlyTheRowThatPassesBothHalves` is the witness for + /// that. What this pins is narrower and still worth pinning: of the row + /// shapes the overlay can emit, only a resolvable owner on an allowed + /// login yields a login the grammar accepts. + @Test func onlyAResolvableAllowedOwnerYieldsAnAcceptedLogin() throws { let gate = ["*@corp.com"] let merged = try overlay().applied( to: BackendStatus( diff --git a/apple/Tests/TruffleTests/NodeLoopbackTests.swift b/apple/Tests/TruffleTests/NodeLoopbackTests.swift index d800b8a6..5d8e2e57 100644 --- a/apple/Tests/TruffleTests/NodeLoopbackTests.swift +++ b/apple/Tests/TruffleTests/NodeLoopbackTests.swift @@ -960,6 +960,55 @@ struct PongDroppingTransport: FrameTransport { await bob.stop() } + /// The witness the overlay-level row cannot be: a REAL `MeshNode` over a + /// real `LoopbackNetwork`, applying its own predicate to every row shape + /// the status overlay can produce — plus the `isAppPeer` half, which an + /// inline re-statement of the login check silently drops. + /// + /// The overlay renders BOTH "a `UserID` with no profile" and "a row with + /// no `UserID`" as an absent login, so those two arrive at the node + /// identically; they are kept as separate rows here because they are + /// separate nodes on the tailnet, not because the node can tell them + /// apart. + @Test func aGatedNodeAdmitsOnlyTheRowThatPassesBothHalves() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + defer { try? FileManager.default.removeItem(at: dirA) } + + func appHostname(_ name: String) throws -> String { + Hostname.tailscaleHostname( + appId: try AppId(parsing: "demo"), deviceName: DeviceName(name)) + } + + // A UserID the status's User{} does not describe. + _ = await network.join( + tailscaleId: "ts-orphan", hostname: try appHostname("Orphan"), loginName: nil) + // A row carrying no UserID at all. + _ = await network.join( + tailscaleId: "ts-nouser", hostname: try appHostname("NoUser"), loginName: nil) + // A resolvable owner, on the wrong domain. + _ = await network.join( + tailscaleId: "ts-mallory", hostname: try appHostname("Mallory"), + loginName: "mallory@evil.com") + // An allowed login that is NOT an app peer — the half a login-only + // predicate would admit. + _ = await network.join( + tailscaleId: "ts-stranger", hostname: "workstation-corp", + loginName: "bob@corp.com") + // Both halves. + _ = await network.join( + tailscaleId: "ts-bob", hostname: try appHostname("Bob"), loginName: "bob@corp.com") + + try await alice.refresh() + let admitted = await alice.peers().map(\.tailscaleId).sorted() + #expect(admitted == ["ts-bob"]) + #expect(try await alice.peer("ts-bob")?.loginName == "bob@corp.com") + + await alice.stop() + } + /// An ungated node is unchanged: a login-less row is still a peer. @Test func ungatedNodeStillAdmitsLoginlessRows() async throws { let network = LoopbackNetwork() From a3b2e9d3c107023be025b67834e39864ba00a0b0 Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 01:43:59 -0700 Subject: [PATCH 12/14] fix(apple): a peer we cannot attribute reports no login, and is not dialed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 025 §3.3 as refined (e34e838): the sticky last-known login was wrong in both directions, and this fixes both. REPORTING. upsertFromLayer3's existing-entry branch kept the previous login when a row carried none (`peer.loginName ?? existing.loginName`). That made the node assert an owner the current netmap does not name — the absent-never- fabricated rule (RFC 022) applies to a login that can no longer be SOURCED just as much as to one never had. The row's login now passes through as-is, so it reads nil. The entry itself is still KEPT: that is the part the stickiness was protecting, and it is protected directly instead, so a transient overlay failure still cannot empty a gated mesh. `confirm` is unchanged and still restores a login WhoIs authenticated, so an inbound hello re-attributes a peer Layer 3 has gone quiet about. DIALING. A gated node now opens no NEW connection to a kept peer whose login is nil: send / sendBytes / sendJSON / confirmIdentity (via `session(for:)`) and the raw `dial(to:port:)` throw the new `MeshError.loginUnknown(peer:)` instead. We would otherwise be opening a connection to someone we cannot attribute, while the inbound gate already refuses that same peer's fresh hello (WhoIs with no login → 4004) — both directions now agree. The rule is about OPENING, never tearing down: - an EXISTING session stands, so a momentary gap in the logins cannot flap a live connection; - a dial already in flight under a known login is JOINED, not re-judged — the guard sits after the in-flight check, so it only ever stops a dial STARTING; - an ungated node ignores the field entirely (`config.loginAllow.isEmpty` short- circuits before the login is ever consulted). `MeshError.loginUnknown(peer: String)` carries the peer's ref description, like `peerGone`. The spelling matches what the VibeField consumers were briefed to expect. Tested by the following commit. Verified by DELETING both `requireKnownLogin` call sites rather than mutating the logic: that reddens exactly the three dial assertions (send, raw dial, confirmIdentity) and leaves the reporting test and the live-session assertion green, which is the attribution I wanted. Gates: swift test --package-path apple 105 -> 107 tests in 17 suites, exit 0 · root "as published" likewise 107/17 · xcodebuild -scheme TruffleTailscale BUILD SUCCEEDED with zero warnings for both iOS destinations, run serially. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- apple/Sources/Truffle/Mesh/MeshError.swift | 6 ++++ apple/Sources/Truffle/Mesh/MeshNode.swift | 40 +++++++++++++++++++--- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/apple/Sources/Truffle/Mesh/MeshError.swift b/apple/Sources/Truffle/Mesh/MeshError.swift index 53f8b3fe..9e1c1525 100644 --- a/apple/Sources/Truffle/Mesh/MeshError.swift +++ b/apple/Sources/Truffle/Mesh/MeshError.swift @@ -15,6 +15,12 @@ public enum MeshError: Error, Sendable, Equatable { /// node's `loginAllow` list (RFC 025 §3.4, D4). Close code 4004. This is /// the SERVER-role error — the side that ran the gate. case loginRefused(login: String?) + /// A GATED node declined to open a NEW connection to a kept peer whose + /// current Layer 3 row cannot name its owner (RFC 025 §3.3). The peer is + /// still listed — its `loginName` reads `nil` — and any EXISTING session + /// to it still works; only opening a new one is refused. An ungated node + /// never raises this. + case loginUnknown(peer: String) /// The remote closed with an application code (4000–4999) before sending /// its hello: 4001 app mismatch, 4002 hello protocol, 4003 identity, /// 4004 login refused. This is the DIALING side's view of a refusal, and diff --git a/apple/Sources/Truffle/Mesh/MeshNode.swift b/apple/Sources/Truffle/Mesh/MeshNode.swift index 00843e6e..64400736 100644 --- a/apple/Sources/Truffle/Mesh/MeshNode.swift +++ b/apple/Sources/Truffle/Mesh/MeshNode.swift @@ -512,6 +512,10 @@ public actor MeshNode { public func dial(to peer: Peer, port: UInt16) async throws -> any MeshConnection { let entry = try resolveLive(peer) + // The raw plane is not gated for INBOUND connections (§3.7, D9), but + // an outbound dial is this node's own act: a gated node does not open + // one to a peer it cannot attribute (RFC 025 §3.3). + try requireKnownLogin(entry) return try await backend.dial(host: dialHost(for: entry), port: port) } @@ -640,10 +644,16 @@ public actor MeshNode { /// gate REFUSES is therefore a departure — the caller evicts the entry, /// which closes its session and emits `peerLeft`. /// - /// An ABSENT login on an existing row is sticky instead: a transient - /// overlay failure must not empty a gated mesh. Provisional entries from a - /// raced inbound hello keep merging — that hello passed the gate in - /// `Handshake.server` — but they are evicted on a refused login like any + /// A row that names NO owner neither evicts nor keeps the last-known + /// login: the entry stays (a transient failure to read the logins must not + /// empty a gated mesh) and its `loginName` goes `nil`, because the row + /// names no owner and so neither do we — RFC 022's absent-never-fabricated + /// rule applies to a login we can no longer source as much as to one we + /// never had. A gated node then opens no NEW session to that peer + /// (`requireKnownLogin`), which is the dial-side half of the same rule. + /// Provisional entries from a raced inbound hello keep merging — that hello + /// passed the gate in `Handshake.server`, and `confirm` restores the login + /// WhoIs authenticated — but they are evicted on a refused login like any /// other row. /// /// - Returns: `true` when this row must be evicted. Eviction is async and @@ -658,7 +668,7 @@ public actor MeshNode { existing.hostname = peer.hostname existing.tailnetIPs = peer.tailnetIPs existing.online = peer.online - existing.loginName = peer.loginName ?? existing.loginName + existing.loginName = peer.loginName existing.provisional = false entries[peer.tailscaleId] = existing emit(.peerUpsert(makePeer(from: existing))) @@ -714,13 +724,33 @@ public actor MeshNode { entry.tailnetIPs.first ?? entry.hostname } + /// A gated node opens no NEW connection to a kept peer whose current row + /// cannot name its owner (RFC 025 §3.3): we would be dialing someone we + /// cannot attribute, and the inbound gate already refuses that peer's own + /// fresh hello (WhoIs with no login → 4004), so both directions agree. + /// + /// It is a rule about OPENING, never about tearing down: an existing + /// session stands, so a momentary gap in the logins cannot flap a live + /// connection. An ungated node ignores the field entirely. + private func requireKnownLogin(_ entry: RegistryEntry) throws { + guard config.loginAllow.isEmpty || entry.loginName != nil else { + throw MeshError.loginUnknown( + peer: PeerRef( + tailscaleId: entry.tailscaleId, generation: entry.generation + ).description) + } + } + /// Get or create the session for a peer. Concurrent callers share one /// in-flight dial (no duplicate sessions across actor reentrancy). private func session(for entry: RegistryEntry) async throws -> SessionState { if let existing = sessions[entry.tailscaleId] { return existing } if let inFlight = dialsInFlight[entry.tailscaleId] { + // A dial already opened under a known login is joined, not + // re-judged: the check below guards STARTING one. return try await inFlight.value } + try requireKnownLogin(entry) let tailscaleId = entry.tailscaleId let host = dialHost(for: entry) let dial = Task { [weak self] () throws -> SessionState in From b9484a5d23bb36b2a4c31e11a2324bf627a86518 Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 01:43:59 -0700 Subject: [PATCH 13/14] test(apple): the kept peer reports no owner, and is not dialed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three rows for RFC 025 §3.3's refinement, each written to fail against the behaviour it replaces. - anAbsentLoginIsKeptAsAPeerButReportedAbsent is the inverse of the test it replaces (anAbsentLoginOnAnAdmittedRowIsSticky). It asserts BOTH halves, because either alone would pass under a wrong fix: the entry survives and keeps its generation, AND its loginName goes nil rather than holding the last-known value. A later resolvable row restores the login in the SAME generation, which is what separates "went quiet" from "left and rejoined". - aGatedNodeWillNotOpenASessionToAnUnattributablePeer uses two peers on purpose. Bob has an OPEN session when his login goes absent; Carol is admitted with no session. Bob's session must keep delivering — asserted with a real message through a real subscription, because a fix that tore down the live session would otherwise pass every other assertion here. Carol's send, raw dial and confirmIdentity must each throw loginUnknown(peer:). Restoring her login makes her dialable again. - anUngatedNodeDialsAPeerWithNoLogin pins the short circuit: without a gate an absent login is simply a fact about a peer, never a refusal. Verified by DELETION, not mutation. Removing both requireKnownLogin call sites — the call sites, not the predicate — reddens exactly three assertions, all in the dial test: send throws nothing, the raw dial reports dialFailed("connection refused: 100.64.0.3:9500") instead of loginUnknown(peer: "ts-c:2"), and confirmIdentity throws nothing. The reporting test stays green under that deletion, and the live-session assertion stays green too, which is the attribution I wanted: each half of the rule has its own witness. Separately restoring the sticky `?? existing.loginName` reddens the reporting test alone (kept?.loginName → "bob@corp.com" against nil). MeshNode.swift was restored after each run and checked back against its pre-mutation SHA-256. Gates: swift test --package-path apple 105 -> 107 tests in 17 suites, exit 0 · root "as published" likewise 107/17 · xcodebuild -scheme TruffleTailscale BUILD SUCCEEDED with zero warnings for both iOS destinations, run serially. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- .../TruffleTests/NodeLoopbackTests.swift | 131 +++++++++++++++++- 1 file changed, 127 insertions(+), 4 deletions(-) diff --git a/apple/Tests/TruffleTests/NodeLoopbackTests.swift b/apple/Tests/TruffleTests/NodeLoopbackTests.swift index 5d8e2e57..7d2232e6 100644 --- a/apple/Tests/TruffleTests/NodeLoopbackTests.swift +++ b/apple/Tests/TruffleTests/NodeLoopbackTests.swift @@ -819,9 +819,13 @@ struct PongDroppingTransport: FrameTransport { await bob.stop() } - /// An ABSENT login on an existing row is sticky, NOT an eviction: a - /// transient overlay failure must not empty a gated mesh. - @Test func anAbsentLoginOnAnAdmittedRowIsSticky() async throws { + /// A row that names no owner is KEPT but reports no login (RFC 025 §3.3 as + /// refined). Two halves, and both matter: the entry survives, so a + /// transient failure to read the logins cannot empty a gated mesh; and the + /// last-known login does NOT stick, because the row names no owner and so + /// neither may we. A later resolvable row restores it, in the same + /// generation — this is not a departure. + @Test func anAbsentLoginIsKeptAsAPeerButReportedAbsent() async throws { let network = LoopbackNetwork() let (alice, dirA) = try await startNode( network: network, tailscaleId: "ts-a", deviceName: "Alice", @@ -838,13 +842,132 @@ struct PongDroppingTransport: FrameTransport { Issue.record("alice never admitted bob") return } + #expect(bobPeer.loginName == "bob@corp.com") + await network.setLogin(tailscaleId: "ts-b", loginName: nil) try await alice.refresh() let kept = try await alice.peer("ts-b") #expect(kept != nil) #expect(kept?.generation == bobPeer.generation) - #expect(kept?.loginName == "bob@corp.com") + #expect(kept?.loginName == nil) + + // Restored, same generation: a login that comes back is not a rejoin. + await network.setLogin(tailscaleId: "ts-b", loginName: "bob@corp.com") + try await alice.refresh() + let restored = try await alice.peer("ts-b") + #expect(restored?.loginName == "bob@corp.com") + #expect(restored?.generation == bobPeer.generation) + + await alice.stop() + await bob.stop() + } + + /// The dial-side half: a gated node opens no NEW session to a peer whose + /// row cannot name its owner, but never tears down one that is already + /// open. Both halves are asserted here because a fix that closed the live + /// session would also make the first assertion pass. + @Test func aGatedNodeWillNotOpenASessionToAnUnattributablePeer() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice", + loginName: "alice@corp.com", loginAllow: ["*@corp.com"]) + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob", + loginName: "bob@corp.com", loginAllow: ["*@corp.com"]) + let (carol, dirC) = try await startNode( + network: network, tailscaleId: "ts-c", deviceName: "Carol", + loginName: "carol@corp.com", loginAllow: ["*@corp.com"]) + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + try? FileManager.default.removeItem(at: dirC) + } + + let bobInbox = Mailbox() + let subBob = await bob.onMessage(namespace: "chat") { await bobInbox.put($0) } + + // Bob: a session is OPEN before his login goes absent. + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("alice never admitted bob") + return + } + try await alice.sendJSON( + to: bobPeer, namespace: "chat", payload: ChatPayload(text: "before")) + #expect(await bobInbox.take() != nil) + + // Carol: admitted, but NO session opened yet. + guard try await alice.peer("ts-c", waitMs: 2_000) != nil else { + Issue.record("alice never admitted carol") + return + } + + await network.setLogin(tailscaleId: "ts-b", loginName: nil) + await network.setLogin(tailscaleId: "ts-c", loginName: nil) + try await alice.refresh() + + // The live session stands — no flap. + guard let bobNow = try await alice.peer("ts-b") else { + Issue.record("bob should still be listed") + return + } + try await alice.sendJSON( + to: bobNow, namespace: "chat", payload: ChatPayload(text: "after")) + #expect(await bobInbox.take() != nil) + + // Carol has no session to stand on, so opening one is refused. + guard let carolNow = try await alice.peer("ts-c") else { + Issue.record("carol should still be listed") + return + } + #expect(carolNow.loginName == nil) + await #expect(throws: MeshError.loginUnknown(peer: carolNow.ref.description)) { + try await alice.sendJSON( + to: carolNow, namespace: "chat", payload: ChatPayload(text: "who are you?")) + } + // The raw plane's outbound dial is this node's act too. + await #expect(throws: MeshError.loginUnknown(peer: carolNow.ref.description)) { + _ = try await alice.dial(to: carolNow, port: 9500) + } + await #expect(throws: MeshError.loginUnknown(peer: carolNow.ref.description)) { + _ = try await alice.confirmIdentity(of: carolNow) + } + + // A login that comes back makes her dialable again. + await network.setLogin(tailscaleId: "ts-c", loginName: "carol@corp.com") + try await alice.refresh() + guard let carolBack = try await alice.peer("ts-c") else { + Issue.record("carol should still be listed") + return + } + let confirmed = try await alice.confirmIdentity(of: carolBack) + #expect(confirmed.deviceId != nil) + + await subBob.cancel() + await alice.stop() + await bob.stop() + await carol.stop() + } + + /// An UNGATED node ignores the field: an absent login never blocks a dial. + @Test func anUngatedNodeDialsAPeerWithNoLogin() async throws { + let network = LoopbackNetwork() + let (alice, dirA) = try await startNode( + network: network, tailscaleId: "ts-a", deviceName: "Alice") + let (bob, dirB) = try await startNode( + network: network, tailscaleId: "ts-b", deviceName: "Bob") + defer { + try? FileManager.default.removeItem(at: dirA) + try? FileManager.default.removeItem(at: dirB) + } + + guard let bobPeer = try await alice.peer("ts-b", waitMs: 2_000) else { + Issue.record("alice never discovered bob") + return + } + #expect(bobPeer.loginName == nil) + let confirmed = try await alice.confirmIdentity(of: bobPeer) + #expect(confirmed.deviceId != nil) await alice.stop() await bob.stop() From 500faecb7e4b5975cc3c46499120cc89c769765e Mon Sep 17 00:00:00 2001 From: James Yong Date: Wed, 16 Sep 2026 01:43:59 -0700 Subject: [PATCH 14/14] =?UTF-8?q?docs(rfc):=20=C2=A78.1.2=20=E2=80=94=20an?= =?UTF-8?q?=20unattributable=20peer=20is=20kept,=20reported=20absent,=20no?= =?UTF-8?q?t=20dialed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 025 §3.3's refinement, recorded on the Swift plane. Cites §3.3 for the rule. - The sticky sentence is gone. §8.1.2 now says a row that names no owner is neither an eviction nor a sticky keep: the entry stays (so a transient failure to read the logins cannot empty a gated mesh) and the login is reported absent, with the reason — RFC 022's absent-never-fabricated rule applies to a login that can no longer be sourced as much as to one never had. - A new bullet states the dial-side half: send / sendBytes / sendJSON / confirmIdentity / raw dial throw MeshError.loginUnknown(peer:) on a gated node; an existing session STANDS and an in-flight dial is joined rather than re-judged, because the rule is about opening and not about tearing down; an ungated node ignores the field. It notes that the inbound gate already refuses such a peer's fresh hello with 4004, so both directions agree. - The provisional-entry sentence gains the fact that `confirm` restores a login WhoIs authenticated even after Layer 3 has stopped naming one. Docs only; no code changed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EKZYYSoZPAXbaw1CYULNKW --- docs/rfcs/024-truffle-swift.md | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/docs/rfcs/024-truffle-swift.md b/docs/rfcs/024-truffle-swift.md index 878c7219..9120dd87 100644 --- a/docs/rfcs/024-truffle-swift.md +++ b/docs/rfcs/024-truffle-swift.md @@ -659,13 +659,18 @@ MeshConfiguration(appId: "field-tools", deviceName: "Alice's iPhone", the entry is evicted, its session closed, and `peerLeft` emitted, exactly as if the peer had left the tailnet. A later row that passes readmits it as a NEW generation, because a rejoin is never the same row (RFC 022 §7.7). - An **absent** login on an existing row is sticky instead of an eviction, so - a transient failure to read the logins cannot empty a gated mesh; a gated - node that sees a login-less app peer emits one `.health` notice, so a mesh - emptied by the gate is never silent. Provisional entries from a raced inbound - hello merge as before — that hello already passed the gate — and carry the - WhoIs login they passed it with, rather than waiting for the netmap; they are - evicted on a refused login like any other row. + A row that names **no owner** is neither an eviction nor a sticky keep + (RFC 025 §3.3 as refined): the entry **stays**, so a transient failure to + read the logins cannot empty a gated mesh, and its `loginName` is reported + **absent** — the last-known login does NOT persist, because the row names no + owner and so neither may we (RFC 022's absent-never-fabricated rule applies + to a login that can no longer be sourced as much as to one never had). A + gated node that sees a login-less app peer emits one `.health` notice, so a + mesh emptied by the gate is never silent. Provisional entries from a raced + inbound hello merge as before — that hello already passed the gate — and + carry the WhoIs login they passed it with; `confirm` restores a login WhoIs + authenticated even after Layer 3 has stopped naming one. Provisional rows are + evicted on a refused login like any other. - **The hello** — `Handshake.server(..., loginAllow:)` implements RFC 025 §3.4's table in order: validate hello → absent authenticated identity → **4003** (on a gated node under EITHER `IdentityPolicy`; a gate is never @@ -675,6 +680,17 @@ MeshConfiguration(appId: "field-tools", deviceName: "Alice's iPhone", `MeshError.loginRefused(login:)`. All of it happens **before** our hello is sent, so a refused caller never learns our identity block. The dialing side needs no new check: a gated node only dials peers Layer 3 reported. +- **The dialing side of an unattributable peer** — a gated node opens no NEW + connection to a kept peer whose current row cannot name its owner + (RFC 025 §3.3): `send`, `sendBytes`, `sendJSON`, `confirmIdentity` and the + raw `dial(to:port:)` all throw `MeshError.loginUnknown(peer:)` instead of + dialing, because we would be opening a connection to someone we cannot + attribute — and the inbound gate already refuses that peer's own fresh hello + (WhoIs with no login → 4004), so both directions agree. It is a rule about + OPENING, never about tearing down: an **existing session stands**, so a + momentary gap in the logins cannot flap a live connection, and a dial already + in flight under a known login is joined rather than re-judged. An ungated node + ignores the field entirely. - **The dialing side** distinguishes a refusal from a broken pipe. Any application close (4000–4999) received before the hello surfaces as `MeshError.helloRefused(code:reason:)`, carrying the code the remote sent —