waterplane 0.1.19 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/NOTICES.md +0 -26
- package/README.md +19 -18
- package/SECURITY.md +575 -562
- package/node_modules/@waterplane/shared/dist/exposure.js +0 -1
- package/node_modules/@waterplane/shared/dist/exposure.js.map +1 -1
- package/node_modules/@waterplane/shared/dist/plugin/api.d.ts +2 -2
- package/npm-shrinkwrap.json +2 -177
- package/package.json +1 -2
- package/packages/host/dist/clisuggest.d.ts +2 -1
- package/packages/host/dist/clisuggest.js +22 -4
- package/packages/host/dist/clisuggest.js.map +1 -1
- package/packages/host/dist/plugins/pluginregistry.js +3 -1
- package/packages/host/dist/plugins/pluginregistry.js.map +1 -1
- package/packages/host/dist/push.d.ts +3 -2
- package/packages/host/dist/push.js +7 -6
- package/packages/host/dist/push.js.map +1 -1
- package/packages/host/dist/pushsend.d.ts +57 -0
- package/packages/host/dist/pushsend.js +146 -0
- package/packages/host/dist/pushsend.js.map +1 -0
- package/packages/host/dist/question.js +9 -0
- package/packages/host/dist/question.js.map +1 -1
- package/packages/host/dist/render.js +2 -1
- package/packages/host/dist/render.js.map +1 -1
- package/packages/host/dist/screenlog.d.ts +5 -0
- package/packages/host/dist/screenlog.js +7 -2
- package/packages/host/dist/screenlog.js.map +1 -1
- package/packages/host/dist/sessions/askwatch.js +1 -1
- package/packages/host/dist/sessions/askwatch.js.map +1 -1
- package/packages/host/package.json +0 -2
- package/packages/web/dist/assets/{demo-iVbE6mpm.js → demo-CbdAM5Ox.js} +1 -1
- package/packages/web/dist/assets/index-CF6HCu-P.js +170 -0
- package/packages/web/dist/assets/{index-DiZysQBa.css → index-D3OoNfGN.css} +1 -1
- package/packages/web/dist/assets/{sheets-CDv71Sfe.js → sheets-BwjSEjgB.js} +97 -97
- package/packages/web/dist/build-id.txt +1 -1
- package/packages/web/dist/buildstamp.json +1 -1
- package/packages/web/dist/index.html +4 -4
- package/packages/web/dist/sw.js +2 -2
- package/release.json +3 -3
- package/packages/web/dist/assets/index-NA--kuEl.js +0 -170
package/SECURITY.md
CHANGED
|
@@ -1,580 +1,593 @@
|
|
|
1
1
|
# Security Policy
|
|
2
2
|
|
|
3
|
+
waterplane lets a phone drive the real `claude` CLI, and shell commands, on your desktop. This
|
|
4
|
+
page says how to report a problem, where the security boundary is, what counts as a vulnerability,
|
|
5
|
+
and what each protection does and does not stop.
|
|
6
|
+
|
|
3
7
|
## Supported versions
|
|
4
8
|
|
|
5
|
-
This is a
|
|
6
|
-
|
|
7
|
-
it's already fixed on `main` before reporting.
|
|
9
|
+
This is a small open-source project with no release branches. Only the latest commit on `main` is
|
|
10
|
+
supported. Before reporting, check whether the problem is already fixed there.
|
|
8
11
|
|
|
9
12
|
## Reporting a vulnerability
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
for this repository. That gives us a private channel to discuss and fix
|
|
15
|
-
the issue before any public disclosure.
|
|
14
|
+
Report privately, not in a public issue. Open a
|
|
15
|
+
[GitHub private security advisory](../../security/advisories/new) for this repository. That gives us
|
|
16
|
+
a private channel to discuss and fix the issue before any public disclosure.
|
|
16
17
|
|
|
17
|
-
Include
|
|
18
|
-
|
|
19
|
-
appreciated but not required.
|
|
18
|
+
Include the affected file(s) or commit, steps to reproduce, and what you expected instead. A
|
|
19
|
+
proof of concept helps but is not required.
|
|
20
20
|
|
|
21
21
|
### What to expect
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
**no bug bounty**. Realistic expectations:
|
|
23
|
+
One person maintains this, unpaid, alongside other work. There is **no bug bounty**.
|
|
25
24
|
|
|
26
25
|
- Acknowledgment: usually within a few days.
|
|
27
|
-
- Triage (in scope or not, and how
|
|
28
|
-
- Fix
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
An unreadable `auth.json` is moved aside and treated as "no password" —
|
|
61
|
-
still deny-until-set for LAN — and if its bytes may have held
|
|
62
|
-
`gateTailscale`, Tailscale stays gated too until the password is set
|
|
63
|
-
again from loopback. A corrupt file never opens the gate.
|
|
64
|
-
- **Funnel is never trusted.** A loopback request carrying `Tailscale-Funnel-Request` (what
|
|
65
|
-
`tailscale funnel` adds, from the public internet) is classed `public` (`requestPeerKind` in
|
|
66
|
-
`net.ts`, used by the token gate, the host-management routes and `/api/peer*`): it always needs a
|
|
67
|
-
token and can never manage auth or mint pairing codes. Behind `tailscale serve` every socket is
|
|
68
|
-
loopback, so the rate limiter (`ratelimit.ts`) keys on the real client address serve writes into
|
|
69
|
-
`X-Forwarded-For` (only when the socket is loopback and a proxy header is present), not on
|
|
70
|
-
127.0.0.1. `POST /api/auth/login` is rate-limited per address (429 + retry-after) before the
|
|
71
|
-
password hash is checked.
|
|
72
|
-
- **Device tokens.** Every token belongs to one row of the paired-device
|
|
73
|
-
ledger, `<state dir>/devices.json` (mode 0600): 32 random bytes,
|
|
74
|
-
base64url, returned once and kept on disk **only as its sha256**, looked up
|
|
75
|
-
with a constant-time compare. Tokens survive a host restart; revoking a row
|
|
76
|
-
(`POST /api/devices/revoke`, authenticated) kills its token at once and
|
|
77
|
-
closes that device's open sockets — the close frame first, then the socket
|
|
78
|
-
is torn down after one second whatever the peer did with it, and a frame
|
|
79
|
-
that still arrives from it meanwhile is dropped, not dispatched
|
|
80
|
-
(`socketAuthorized`, asked for every frame); setting, changing or clearing
|
|
81
|
-
the password drops every row. A browser gets one from `POST /api/auth/login`
|
|
82
|
-
as an httpOnly cookie. The Android shell (whose cross-site cookie is
|
|
83
|
-
withheld) mints one with `POST /api/auth/token` — **only from a caller that
|
|
84
|
-
is already trusted** (loopback, or a Tailscale peer holding a valid token
|
|
85
|
-
when that path is gated — never LAN or Funnel, even with a token), so no password is retyped over the tailnet, and never while no
|
|
86
|
-
password is set — and carries it as `Authorization: Bearer <tok>` on HTTP
|
|
87
|
-
and as the WebSocket subprotocol `waterplane-tok.<tok>` on the upgrade (the host
|
|
88
|
-
echoes only the base protocol, never the token). No token is valid while
|
|
89
|
-
no password is set.
|
|
90
|
-
On the phone the page keeps its tokens in the app's private localStorage; the
|
|
91
|
-
APK's background link keeps a second copy of each gated desktop's token so it
|
|
92
|
-
can reconnect with the app swiped away, AES-256-GCM sealed under a non-exportable
|
|
93
|
-
Android Keystore key (`ShellLinkTokens`), bound to that desktop's id and address,
|
|
94
|
-
sent only to that desktop's saved tailnet address, deleted when the desktop is
|
|
95
|
-
removed, the token is refused or the link is switched off. **Stated residual:**
|
|
96
|
-
the key needs no user authentication, because nobody is holding the phone when
|
|
97
|
-
the service reconnects, so this protects against a copied file, not against code
|
|
98
|
-
running as this app. That residual holds only while the phone's sign-ins are not
|
|
99
|
-
locked: with *lock the desktop sign-ins* on (next bullet), the link keeps no copy at rest.
|
|
100
|
-
- **Sealed sign-ins (optional, off by default).** Settings › Connection ›
|
|
101
|
-
*lock the desktop sign-ins* (Android app) puts this phone's device tokens in one
|
|
102
|
-
blob under a Keystore key that each use opens with the fingerprint, or on a newer
|
|
103
|
-
shell the phone's PIN, and removes the plaintext. **It guards against a copy of
|
|
104
|
-
the app's storage (a backup, a pulled file), not against a hand holding the
|
|
105
|
-
unlocked phone** (that is the app lock, and the process holds the tokens in plaintext
|
|
106
|
-
memory while running). A desktop with no password has no token, so there is nothing
|
|
107
|
-
to lock for it. The background link, per APK: a newer app keeps each token in
|
|
108
|
-
memory only (alerts continue after a swipe-away until the phone restarts or
|
|
109
|
-
Android stops the app, then "locked — open the app"); an older app is handed no
|
|
110
|
-
token, so alerts from gated desktops wait until the app is opened. Nothing on the
|
|
111
|
-
host changes. **Stated residuals:** a token the desktop revoked stays in the blob
|
|
112
|
-
until the next rewrap; a stray bare token that belongs to no saved desktop can stay
|
|
113
|
-
plaintext (the row says so); passive (kids') tokens are not sealed; on an older
|
|
114
|
-
app the start prompt is the fingerprint only, and on a plain-HTTP route that app
|
|
115
|
-
refuses the key, so its card stays until the route is the tailnet or HTTPS (the row
|
|
116
|
-
refuses to lock in that state, and the host setup form refuses to save a plain
|
|
117
|
-
address while that app has both locks on); the recents thumbnail is as before; and a lost key
|
|
118
|
-
(screen lock removed, or a new fingerprint on a fingerprint-only key) loses the
|
|
119
|
-
sign-ins, so a tailnet-gated desktop must be paired again. A missing sensor never
|
|
120
|
-
counts as a lost key, and a lost one's blob is set aside on the phone
|
|
121
|
-
(`tokenSealDead`), not deleted; nothing restores it yet, and the next lock's new key
|
|
122
|
-
makes it unreadable. A key that is dead but not worded as such by an OEM stays a
|
|
123
|
-
refusal, and the person's way out is *forget the locked sign-ins* (confirmed, behind
|
|
124
|
-
the app lock): it discards the blob by hand, which gives a stranger nothing. "Nothing
|
|
125
|
-
connects before the unlock" is true of the socket only: tokenless HTTP, such as the
|
|
126
|
-
app-update check, runs first, carrying no token.
|
|
127
|
-
- **App lock (optional, off by default).** The Android app can require the
|
|
128
|
-
phone's fingerprint, or on a newer shell its PIN, before it shows its screen
|
|
129
|
-
(Settings › Connection › *lock this app*). **It is a hold-the-phone defence at
|
|
130
|
-
UI level and nothing more.** No host route, token, gate or wire message changes;
|
|
131
|
-
deny-until-set, token rotation, the passive allowlist and the origin checks are
|
|
132
|
-
untouched, and **the device token's storage is unchanged** unless *lock the desktop
|
|
133
|
-
sign-ins* is on (localStorage, plus the sealed copy above). The page stays mounted behind an opaque card. Stated
|
|
134
|
-
residuals: anyone who has the phone's PIN passes it (the PIN fallback, or by
|
|
135
|
-
enrolling a finger); the recents thumbnail, and the frame the WebView shows on
|
|
136
|
-
wake before the page's JS runs, still show the last screen. The lock fails
|
|
137
|
-
closed: a cancelled, failed, locked-out or unrecognised refusal never opens the
|
|
138
|
-
app, at any repeat count. It opens and switches itself off (saying so) only when
|
|
139
|
-
the phone's own credential had to change the key, the app's saved check is
|
|
140
|
-
unreadable, or a fresh status read confirms no sensor or nothing to check with.
|
|
141
|
-
So on an old APK with a dead sensor that still reports hardware, or on a plain
|
|
142
|
-
HTTP route (where the old APK's key use is refused), the way out is a newer APK,
|
|
143
|
-
another route (the tailnet or HTTPS), or clearing the app's data.
|
|
144
|
-
- **LAN identity.** `<state dir>/lan-identity.json` (0600) holds a `hostId`
|
|
145
|
-
and a secret. `GET /api/lan/identity` hands them out to loopback and
|
|
146
|
-
Tailscale callers ONLY — refused (403) for every other peer kind before the
|
|
147
|
-
token gate, so a LAN token never unlocks it. `POST /api/lan/verify {nonce}`
|
|
148
|
-
answers anyone (pre-auth, rate-limited per remote IP) with
|
|
149
|
-
`HMAC-SHA256(secret, hostId + "\n" + nonce)`, and the phone sends a token
|
|
150
|
-
over LAN only after that MAC checks out. **Stated residual:** this proves
|
|
151
|
-
the thing answering at a LAN address holds the secret; it cannot resist an
|
|
152
|
-
**active relay** on the person's own Wi-Fi that forwards the probe to the
|
|
153
|
-
real desktop and then reads the plain-HTTP traffic (the token included).
|
|
154
|
-
With the token that relay can drive the desktop. It could also answer the app
|
|
155
|
-
with a forged page. So the Android app asks of every route whether it is
|
|
156
|
-
*protected* (`isPlainRoute` in shared, mirrored natively by `ShellRoute.java`),
|
|
157
|
-
and on one that is not it refuses every fingerprint use, every over-the-air
|
|
158
|
-
update and every app download or install, and warns in the address form, the
|
|
159
|
-
health sheet, the strip ("LAN, not protected") and a toast.
|
|
160
|
-
**The protected-route rule.** A route is protected only if it is exactly one
|
|
161
|
-
of: loopback; a Tailscale address or MagicDNS name over `http`, or `https` to a
|
|
162
|
-
`*.ts.net` name, unless a newer app reports that the phone has no tailnet
|
|
163
|
-
(then a tailnet name proves nothing: with Tailscale off, `100.x` goes to the
|
|
164
|
-
Wi-Fi gateway); the desktop's own TLS address on a newer app that holds its
|
|
165
|
-
pin; or an `https` address the person typed themselves. Everything else is
|
|
166
|
-
plain, **whatever its scheme**: a relay with a public certificate for its own
|
|
167
|
-
name passes `https` validation. A reply from a desktop that has not proved
|
|
168
|
-
itself (anything not sealed by the pairing code, or not received over a
|
|
169
|
-
protected route) never sets a saved desktop's address, key, pin or identity,
|
|
170
|
-
so a relay cannot walk a row onto itself. **Stated residuals:** a row saved
|
|
171
|
-
by an older app may already hold an address someone planted (a non-`ts.net`
|
|
172
|
-
`https` one reads as plain and the app opens an empty field to retype it; a
|
|
173
|
-
planted `ts.net` or `100.x` one cannot be told apart); and a person who taps
|
|
174
|
-
"pair" on a pairing link someone else made pairs with whoever made it (the
|
|
175
|
-
confirm says so). A browser on plain HTTP has no such defence, because the page
|
|
176
|
-
itself is what arrives unprotected, and the token is still exposed to the
|
|
177
|
-
relay. An older app keeps the older answer (tailnet and HTTPS only).
|
|
178
|
-
Plain HTTP on a LAN has no defence against a man in the middle; if that
|
|
179
|
-
network is not trusted, do not enable `WATERPLANE_ALLOW_PUBLIC`.
|
|
180
|
-
- **LAN TLS and the pin** (`packages/host/src/lantls.ts`). Only where
|
|
181
|
-
`WATERPLANE_ALLOW_PUBLIC=1` already binds LAN addresses, the host also
|
|
182
|
-
listens on `WATERPLANE_TLS_PORT` (default port + 1) on those same addresses
|
|
183
|
-
with the same handler and the same gate, so a caller there is still peer kind
|
|
184
|
-
`lan` and needs its token. Off with `WATERPLANE_TLS=0`; never on loopback or
|
|
185
|
-
the tailnet; nothing without the opt-in. The key is P-256 in
|
|
186
|
-
`<state dir>/lan-tls.json` (0600), kept across restarts; the certificate is
|
|
187
|
-
self-signed and re-issued on the same key when the addresses change, so the
|
|
188
|
-
phone's pin (SHA-256 of the key) survives. The app stores the pin per desktop
|
|
189
|
-
and enforces it in the WebView, the background link and the pull service; a
|
|
190
|
-
desktop with a pin is then never reached over plain HTTP on a LAN address.
|
|
191
|
-
A self-signed port does not serve the web app (it answers a notice page): a
|
|
192
|
-
browser click-through would look protected and is not. With
|
|
193
|
-
`WATERPLANE_TLS_CERT` + `WATERPLANE_TLS_KEY` (a person's own leaf from a CA;
|
|
194
|
-
a self-signed one is read as self-signed) it serves the app, and the pin
|
|
195
|
-
follows that leaf. **What the pin does not defend:** the
|
|
196
|
-
WebView's handler runs only when validation fails, so a certificate valid
|
|
197
|
-
under a CA the phone trusts would skip the pin inside the WebView (pinned
|
|
198
|
-
addresses are private IPs and `.local` names, which public CAs do not issue,
|
|
199
|
-
and the app trusts no user-added CA); a first install of the APK over the
|
|
200
|
-
network (Android accepts any signature then); code already running in the
|
|
201
|
-
page; a browser; and dropping packets. The pin is a property of a newer APK:
|
|
202
|
-
a shipped one learns it and ignores it, and stays on plain LAN with the
|
|
203
|
-
warnings. The way out of a pinned desktop whose TLS port is off is a
|
|
204
|
-
confirmed "use plain HTTP for this desktop" on its row; nothing does it
|
|
205
|
-
by itself. The spike behind this (measured on two emulator WebViews only)
|
|
206
|
-
is in `docs/notes/protocol-and-security.md`. **None of this has been tried on
|
|
207
|
-
a real phone yet.**
|
|
208
|
-
- **Signed updates** (`packages/host/src/updatekey.ts`, `webbundle.ts`,
|
|
209
|
-
`packages/web/src/updateTrust.ts`, `ShellBundleVerify.java`). Each desktop
|
|
210
|
-
signs its web bundle's manifest with its own P-256 key
|
|
211
|
-
(`<state dir>/update-key.json`, 0600), not a project key: people build their
|
|
212
|
-
own bundles, and a private key shipped to every desktop is no key. The
|
|
213
|
-
manifest carries `signed` (hostId, build id, `gen`, the file list with hashes)
|
|
214
|
-
and `sig`. A phone learns the desktop's public key from `welcome` only when
|
|
215
|
-
the delivering socket's address was protected (above), so a relay cannot
|
|
216
|
-
hand it one. Once a phone holds a key, an update that is unsigned, badly
|
|
217
|
-
signed, for another desktop or older than the last it took (`gen`, which
|
|
218
|
-
rises when the served build id changes, so a real revert still works) is
|
|
219
|
-
refused and nothing is written. The check runs in the page (works on a
|
|
220
|
-
shipped APK) and again natively on a newer one, which also asks in its own
|
|
221
|
-
dialog the first time it sees a desktop's key, a changed key, or a
|
|
222
|
-
restored desktop; page code cannot tap it. A new key (a wiped state dir) or a
|
|
223
|
-
lower `gen` (a restored one) waits for that answer. **What it does not do:** a
|
|
224
|
-
signature proves the bundle came from the desktop you paired, never that
|
|
225
|
-
its code is good. **A hostile desktop can still ship anything**, and a
|
|
226
|
-
paired host stays fully trusted (last section). A passive (kids') phone opens
|
|
227
|
-
no socket, so it never learns a key and takes updates as before, over a
|
|
228
|
-
route that is protected or it would take none. A shipped APK without the
|
|
229
|
-
native check has the page's check only, so page code that is already
|
|
230
|
-
compromised can still persist a bundle there. A desktop that cannot read or
|
|
231
|
-
write its key file serves the bundle unsigned (logged once): a phone that
|
|
232
|
-
holds a key then refuses it and says to update or repair the desktop.
|
|
233
|
-
- **Pairing with no tailnet** (`pairpake.ts`, `shared/pake.ts`). A phone
|
|
234
|
-
pairs by CPace over ristretto255 keyed by the 8-character code, on the plain
|
|
235
|
-
port: the phone proves it knows the code first, the host answers with a
|
|
236
|
-
sealed reply (token, `hostId`, TLS pin, update key, LAN identity), and the
|
|
237
|
-
seal authenticates the host. A relay gets one online guess per attempt; an
|
|
238
|
-
impostor host answering `start` gets at most five, one per share it sends,
|
|
239
|
-
which is all a phone will answer. A real host offers the five newest open
|
|
240
|
-
codes, each confirm checked against all of them in constant time, so with
|
|
241
|
-
more than five open an older code never pairs this way: its phone is told
|
|
242
|
-
the desktop does not know that code and to show a new QR, which then works,
|
|
243
|
-
and the attempt counts as a miss against the five offered. A session lives
|
|
244
|
-
60 s and is deleted on its first `finish`.
|
|
245
|
-
10 wrong confirms close a code *for this pairing only*: it still redeems over
|
|
246
|
-
the tailnet, so a device on the Wi-Fi can stop an encrypted pairing (as
|
|
247
|
-
dropping packets does) but cannot burn a code meant for the tailnet. The
|
|
248
|
-
desktop refuses with no password set. **A code never crosses a plain socket
|
|
249
|
-
and lives:** on one that is not loopback, the tailnet, `tailscale serve` or
|
|
250
|
-
TLS, `/pair/<code>` and `/pair/<code>/apk` burn the code and show a page with
|
|
251
|
-
no link and no APK; a LAN-only desktop's screen shows the deep-link QR, which
|
|
252
|
-
goes camera to app. A code already sent plain is dead on that phone. The
|
|
253
|
-
link also carries a 128-bit commitment to the key and pin, which the seal
|
|
254
|
-
must match. **Stated residuals:** a person who accepts the question that
|
|
255
|
-
appears for an older desktop (it cannot tell an old desktop from someone
|
|
256
|
-
blocking encrypted pairing) spends that code and token in the clear, as
|
|
257
|
-
before; a relay can fill the miss count and stop pairing; whether every
|
|
258
|
-
camera app opens the waterplane link is unverified, and the typed code runs the
|
|
259
|
-
same protocol; the first APK install over the network is unprotected.
|
|
260
|
-
`waterplane pair` prints the update key's fingerprint beside the code.
|
|
261
|
-
- **Read ticket** (`auth.ts`, `POST /api/auth/read-ticket`). An image tag or a
|
|
262
|
-
download link cannot carry a bearer, so a phone with no tailnet could not
|
|
263
|
-
load them. A signed device token buys a one-hour ticket (HMAC of device id,
|
|
264
|
-
scope and expiry; the key lives in memory, so a restart voids every
|
|
265
|
-
ticket) that is accepted as `?wt=` **only** on the two opaque read routes
|
|
266
|
-
(`OPAQUE_READ_ROUTES`), **only** over the TLS socket, GET/HEAD only, only
|
|
267
|
-
while the device row exists, and held to the row's scope (a passive phone's
|
|
268
|
-
ticket gets what a passive token gets). Access logs, shares and copies
|
|
269
|
-
strip `wt`. A ticket in a URL is a credential for those two read routes for
|
|
270
|
-
up to an hour: do not paste such a URL.
|
|
271
|
-
- **A phone with Tailscale off.** It reaches the desktop only on the Wi-Fi.
|
|
272
|
-
A shipped APK stays on plain LAN with name-based trust as before, except
|
|
273
|
-
that an update key it already holds still refuses a forged bundle. A newer
|
|
274
|
-
APK pairs by code, holds the pin and goes over TLS. A phone that never
|
|
275
|
-
learned a pin cannot tell a relay from the desktop on the first
|
|
276
|
-
plain contact; pairing by code is the fix. The password-change case: tokens
|
|
277
|
-
rotate as before, so a LAN-only phone must pair again, and its gate says so
|
|
278
|
-
rather than asking for a password that cannot work there.
|
|
279
|
-
On a shell that enforces pins, a page that knows Tailscale is off sends no
|
|
280
|
-
device token to a tailnet address, pinned or not, since only the Wi-Fi can
|
|
281
|
-
answer it. Until the shell's first network reading it holds the token back on
|
|
282
|
-
those addresses and learns no pin, for at most 8 seconds; a shell silent past
|
|
283
|
-
that falls to trust by name. The background link is not covered on an unpinned
|
|
284
|
-
row: it sends its token to the address it holds, as before.
|
|
285
|
-
- **Pairing codes** (`packages/host/src/pair.ts`). `POST /api/pair/mint`
|
|
286
|
-
sits behind the normal gate; the 8-character code lives 10 minutes, is kept
|
|
287
|
-
in memory only as its sha256 (a restart invalidates every outstanding one),
|
|
288
|
-
at most 5 are live, and it is spent by its first successful
|
|
289
|
-
`POST /api/pair/redeem` — the one pre-auth route that can mint a device
|
|
290
|
-
token, rate-limited per remote IP (5, then one per 10 s). Redeem mints a
|
|
291
|
-
token only while a password is set; with none, a LAN redeemer is refused
|
|
292
|
-
before the code is spent (deny-until-set). The LAN identity secret goes only
|
|
293
|
-
to a loopback/Tailscale redeemer. `GET /pair/<code>` (landing) and
|
|
294
|
-
`/pair/<code>/apk` are outside `/api/` and the token gate so a brand-new
|
|
295
|
-
phone can load them, and reveal nothing about the desktop until the code
|
|
296
|
-
checks out; they carry `no-store`, `X-Frame-Options: DENY`, a
|
|
297
|
-
`default-src 'none'` CSP and `no-referrer`. They are NOT in
|
|
298
|
-
`OPAQUE_READ_ROUTES`.
|
|
299
|
-
- **Passive devices** (the kids' phone). Pairing can mark a device
|
|
300
|
-
"passive": its ledger row stores `scope: "passive"` (rows without it are
|
|
301
|
-
full scope). The host holds such a token to a **default-deny allowlist**
|
|
302
|
-
(`passive.ts`): `GET /api/health`, `/api/webbundle[/file]`, `/api/theme`,
|
|
303
|
-
`/api/passive/state`, and `GET /api/repos/<id>/artifact` only for an APK
|
|
304
|
-
that is in the auto-pull queue at that moment — every other `/api/` route
|
|
305
|
-
(403) and the WebSocket (closed 4403) are refused, and `/api/auth/token`
|
|
306
|
-
never mints it a full token. A passive pairing needs a password and
|
|
307
|
-
switches "gate Tailscale" on; while any passive row exists the gate cannot
|
|
308
|
-
be switched off, so the phone has no tokenless path on Tailscale or the
|
|
309
|
-
LAN. A request that reaches loopback through `tailscale serve` (it carries
|
|
310
|
-
a proxy header) is classed as a Tailscale peer by the gate, so the gate
|
|
311
|
-
applies to the https address too. **Unlock:** a PIN (4–12 digits,
|
|
312
|
-
scrypt-hashed in `<state dir>/passive-pin.json`, 0600) set only from a
|
|
313
|
-
trusted caller that is not passive (`POST /api/auth/pin`);
|
|
314
|
-
`POST /api/auth/unlock` checks it, rate-limited per device (5, then one per
|
|
315
|
-
minute), and lifts that one token to full scope for 30 minutes, held in the
|
|
316
|
-
host's memory only — expiry, "Lock now" (`POST /api/auth/lock`), a PIN
|
|
317
|
-
change or a host restart end it, and its sockets are closed. **Auth
|
|
318
|
-
material is the one part of full scope an unlock never grants**, because
|
|
319
|
-
each piece would outlive the 30 minutes: an unlocked passive device still
|
|
320
|
-
cannot mint a pairing code (`POST /api/pair/mint` — a redeemed code is a
|
|
321
|
-
permanent full token) or a device token, revoke a device, pair or unpair a
|
|
322
|
-
peer (`/api/peers` writes), read the LAN identity secret, or set the
|
|
323
|
-
password, the Tailscale gate or the PIN. Three places say no: `passive.ts`'s
|
|
324
|
-
`passiveNeverAllows` for the pairing, revoke, peer and identity routes
|
|
325
|
-
(checked in `server/http.ts` right after the allowlist), the `/api/auth/token`
|
|
326
|
-
and `/api/auth/pin` handlers' own scope checks, and the WebSocket
|
|
327
|
-
`auth.set`/`auth.clear`/`auth.gateTailscale` ops (`routes/system.ts`, by the
|
|
328
|
-
socket's device row). A passive caller that knows the PASSWORD itself can
|
|
329
|
-
still log in like any browser — the PIN model never covered that.
|
|
330
|
-
- Password management (`auth.set` / `auth.clear` / `gateTailscale`) is only
|
|
331
|
-
accepted from an already-trusted connection (loopback or Tailscale). A LAN
|
|
332
|
-
caller — even one who knows the current password — can never set or change
|
|
333
|
-
it, only authenticate with it.
|
|
334
|
-
- `POST /api/hook` (the sink the spawned `claude` process posts lifecycle
|
|
335
|
-
events to), `POST /api/plugin/<id>/hook` and `POST /api/shell/open` answer
|
|
336
|
-
only a process on this machine, regardless of any other setting: the socket
|
|
337
|
-
peer must be loopback AND the request must carry no reverse-proxy header
|
|
338
|
-
(`X-Forwarded-*`, `Forwarded`, `X-Real-IP`, `Via`, `Tailscale-User-*` —
|
|
339
|
-
one list, `net.ts`'s `localProcessRefusal`). Loopback alone is not enough:
|
|
340
|
-
behind `tailscale serve` every tailnet request arrives from 127.0.0.1.
|
|
341
|
-
A loopback request whose `Host` names anything but this machine's loopback is
|
|
342
|
-
treated as proxied; a reverse proxy that forwards neither a proxy header nor
|
|
343
|
-
the client `Host` is treated as local — front the daemon only with one that
|
|
344
|
-
sets `X-Forwarded-For`.
|
|
345
|
-
- A shell session (a plain login shell in a repo, `backends/shell.ts`) is the
|
|
346
|
-
same `session.create` op behind the same gates as a `claude` session and
|
|
347
|
-
widens nothing. Its `$BROWSER` shim posts to `POST /api/shell/open` (local
|
|
348
|
-
process only, above) and stores nothing for an unknown session; a URL with
|
|
349
|
-
userinfo in its authority (`https://github.com@evil.tld/`) is never
|
|
350
|
-
published. Only a person's tap opens one: a job (the intent tick, the
|
|
351
|
-
backlog, a fan-out, an agent's post) and a peer desktop's job are refused.
|
|
352
|
-
The shell does not inherit the daemon's API keys and key files
|
|
353
|
-
(`*_API_KEY`, `*_KEY_FILE`, `*_KEYCHAIN` are unset in the pane), and its
|
|
354
|
-
commands are never written to the session's prompt history.
|
|
355
|
-
- **Live desktop** (`packages/extensions/desktop`, macOS, and Windows with the host in WSL; experimental). Watching is a
|
|
356
|
-
stream on the same device-token socket and widens no gate. **Control is
|
|
357
|
-
arbitrary input on the desktop** (clicks and keys reach every app, password
|
|
358
|
-
managers and a logged-in terminal included), so it carries the same trust as
|
|
359
|
-
the PTY and is a separate setting, **off by default**. It is gated on the
|
|
360
|
-
host, not only on the phone: with it off, `stream.input` is dropped before it
|
|
361
|
-
reaches the helper, and with it on a Mac still needs the macOS Accessibility
|
|
362
|
-
grant. Windows asks no app for a grant, so there the host's switch is the only
|
|
363
|
-
gate. A peer token never reaches a stream (`/api/peer/*` only), and no job,
|
|
364
|
-
schedule or peer desktop opens a desktop stream. The helper `wp-desktop` runs as the desktop user and capture runs only while a
|
|
365
|
-
view is open. On a Mac it is signed with a stable identifier and holds only the
|
|
366
|
-
Screen Recording and Accessibility grants a person gave it there (it asks macOS
|
|
367
|
-
only when a person taps Fix on a setup row), and macOS shows its own recording
|
|
368
|
-
indicator the whole time. Windows shows no such indicator.
|
|
369
|
-
- **Browser origins** (`packages/host/src/origin.ts`). Every `/api/*` request
|
|
370
|
-
and WebSocket upgrade is checked on `Origin` and `Host`; only `http`/`https`
|
|
371
|
-
origins naming this machine on a port it serves pass, plus exactly one
|
|
372
|
-
non-web origin: `capacitor://localhost`, the iOS shell's page (WKWebView
|
|
373
|
-
cannot serve an app from `http`, so it is the twin of the Android shell's
|
|
374
|
-
`http://localhost`). Any other scheme is refused, and the CORS header only
|
|
375
|
-
ever reflects an origin that passed — never `*`. `*.ts.net` is accepted by
|
|
376
|
-
suffix only while on a tailnet whose MagicDNS name could not be read, only
|
|
377
|
-
for requests arriving through `tailscale serve`, and only when the Origin
|
|
378
|
-
names the same host the request was sent to; the name is re-read
|
|
379
|
-
until known.
|
|
380
|
-
- `/api/agent/intents`, `/api/agent/backlog`, `/api/agent/followup` and `/api/agent/exposure[/triage]` (an agent in a driven repo
|
|
381
|
-
posting work for later, `agentintents.ts`; marking a crash / exposure finding, `agentexposure.ts`: a read, and a mark that needs a live session, never a clear) answers only a loopback caller that is neither a browser
|
|
382
|
-
(any `Origin` or `Sec-Fetch-Site` header is refused, same-origin included)
|
|
383
|
-
nor proxied (`tailscale serve` forwards to 127.0.0.1, so the same
|
|
384
|
-
reverse-proxy headers as the hook sinks above are refused). It adds no new power:
|
|
385
|
-
a posted row goes through the same containment gate and the same store as
|
|
386
|
-
the phone's `intent.post`, cannot bring a budget waiver, and waits for the
|
|
387
|
-
person's auto-fire switch and budget like any scheduled item; a backlog add
|
|
388
|
-
never starts at all until the person fires it.
|
|
389
|
-
- **Consent is the phone's, checked on the host** (`confirmgate.ts`). The
|
|
390
|
-
checklist fixes that act on the person's behalf (`CONFIRM_GATED_FIXES`:
|
|
391
|
-
accepting Google's Android SDK licence and downloading the toolchain, making
|
|
392
|
-
a signing key, writing a repo's Claude settings) and the first-app job
|
|
393
|
-
(`POST /api/onboard`, and `/api/onboard/resume` when it adds consent) need
|
|
394
|
-
BOTH a browser `Origin` that passes the origin gate (an Origin-less request,
|
|
395
|
-
allowed everywhere else, is refused here) AND a one-shot token, 15 minutes,
|
|
396
|
-
minted by the GET that draws the confirm (the compliance report, the onboard
|
|
397
|
-
plan, the stopped job) and bound to that worktree + fix id, that path + the
|
|
398
|
-
consent ids the screen listed, or that job. A gated fix never runs inside a
|
|
399
|
-
"Fix all" list. Honest scope: a process running as you can run `sdkmanager`
|
|
400
|
-
itself, so this does not stop a determined local program; it stops the
|
|
401
|
-
spawned `claude` agent, a plugin or a script from accepting a licence with
|
|
402
|
-
one undocumented POST, and every refusal is logged.
|
|
403
|
-
- **Ask rules** (`packages/host/src/askrules.ts`). A desktop-wide list of
|
|
404
|
-
permission rules (`Bash(git push * main)`, `npm publish*`, ...) is passed to
|
|
405
|
-
every spawned `claude` as `permissions.ask` in the `--settings` flag, which
|
|
406
|
-
outranks anything the agent writes into the repo's settings and holds under
|
|
407
|
-
bypass-permissions; the resulting box is answerable only by a person's tap.
|
|
408
|
-
Changing the list (`PUT /api/askrules`) needs a browser `Origin` and a
|
|
409
|
-
one-shot confirm minted by the GET for the list's current revision, so a
|
|
410
|
-
`curl` with a forged header cannot delete a rule and a stale phone cannot
|
|
411
|
-
overwrite a newer list. It reaches `claude` sessions started on this
|
|
412
|
-
desktop only: a peer desktop's jobs run under that peer's own list, and the
|
|
413
|
-
host's own headless calls (review, assess) take no rule. The state file is
|
|
414
|
-
read at boot, so a same-user edit of it takes effect at the next restart.
|
|
415
|
-
Honest scope: it stops the accident, not a wrapper script the rule cannot
|
|
416
|
-
see or an agent talking you into Yes.
|
|
417
|
-
- **Toolchain downloads** land in a fresh `mkdtemp` directory (0700) under the
|
|
418
|
-
per-user data dir, are created with `O_EXCL`, and are hashed and unpacked
|
|
419
|
-
from there, never from a shared temp dir another local account could plant
|
|
420
|
-
a file or symlink in.
|
|
421
|
-
- **Files the host rewrites inside a repo** — `.gitignore` (the secret writer,
|
|
422
|
-
the checklist's gitignore fix, the signing-key link), `CLAUDE.md` and
|
|
423
|
-
`.claude/settings.json` (checklist fixes) — are written through an in-repo
|
|
424
|
-
symlink but never through one that leaves the worktree: a committed
|
|
425
|
-
`.gitignore -> ~/.gitconfig` is refused in words (`writeTextAtomicKeepMode`'s
|
|
426
|
-
`within`), the same realpath rule the file routes apply to reads. The repo
|
|
427
|
-
walks (tree search, artifact discovery, directory entry counts) neither
|
|
428
|
-
descend into nor list the contents of a link whose target is outside the
|
|
429
|
-
worktree (the link itself is shown as a link, nothing more).
|
|
430
|
-
- **A repo's own repo-health engine** (`tools/repohealth.mjs`, run by the host
|
|
431
|
-
after one approval — `repohealthapproval.ts`) is pinned by a hash of the
|
|
432
|
-
entry, its siblings and every module it imports with a plain relative path,
|
|
433
|
-
static or dynamic. An engine whose loads the hash cannot see is never
|
|
434
|
-
approvable and the phone is told why: an `import()`/`require()` argument that
|
|
435
|
-
is not one plain string, `eval`, `Function()`, `vm`, a worker, loader hooks, a
|
|
436
|
-
`#` import-map entry, a module from a URL or an absolute path, an import
|
|
437
|
-
outside the repo, a dependency committed under any `node_modules` or reached
|
|
438
|
-
by the repo's own package name, or a tree too big to read whole. Honest scope:
|
|
439
|
-
a comment- and string-aware text scan, not a parser — it closes the plain
|
|
440
|
-
shapes; an engine that spawns `node <file>` or reaches code through an alias
|
|
441
|
-
the scan does not know still runs what the approval never saw.
|
|
442
|
-
- **Plugins** (`packages/host/src/plugins/`, `docs/notes/plugins.md`) are code
|
|
443
|
-
the machine's owner put in their own config dir (`~/.config/waterplane/plugins/`
|
|
444
|
-
or `plugin-paths.json`). They run in the daemon's process with its rights —
|
|
445
|
-
the same trust as the daemon, crossing no boundary it has not. The phone
|
|
446
|
-
never runs plugin code: it draws what a plugin declares. A plugin answers a
|
|
447
|
-
choice box only through the core's answer gate (`answergate.ts`: the
|
|
448
|
-
manifest's `answers`, a per-session switch default OFF that only the phone
|
|
449
|
-
flips, the same box it was told about, never a trust/tool/plan box or one
|
|
450
|
-
naming something destructive or secret), reads files only through the
|
|
451
|
-
containment guard, and — when loaded from outside this repo — reads only
|
|
452
|
-
the env names its manifest declares (the in-repo extensions under
|
|
453
|
-
`packages/extensions/` are part of the host and read its own knobs directly).
|
|
454
|
-
Nothing on the network can install, load or reload one.
|
|
455
|
-
- **Peer tokens (paired desktops).** Two waterplane hosts can be paired so one
|
|
456
|
-
delegates a job to the other (`<state dir>/peers.json`, mode 0600;
|
|
457
|
-
outbound rows keep the token, inbound rows only its sha256). A peer token is a
|
|
458
|
-
scoped credential: it unlocks `/api/peer/*` and nothing else, and a device
|
|
459
|
-
token never unlocks those routes. Managing pairings (`/api/peers`) is allowed
|
|
460
|
-
on the trusted path only (loopback and Tailscale, never LAN), the same rule as
|
|
461
|
-
the password. `/api/peer/*` is exempt from the device-token gate in
|
|
462
|
-
`server/http.ts` because `peers/routes.ts` applies a stricter check: trusted
|
|
463
|
-
path kind, then the peer bearer token, then the peer API version header; an
|
|
464
|
-
unknown token gets "not paired" and the refusal is logged with the remote
|
|
465
|
-
address. Changing the password does NOT rotate peer tokens (a separate ledger;
|
|
466
|
-
unpair to revoke). Peer addresses are tailnet or loopback only (100.64/10,
|
|
467
|
-
`*.ts.net`, 127.0.0.1), never `*.local`. Honest scope: a tailnet client that
|
|
468
|
-
sends no `Origin` can already drive a host over its WebSocket, so pairing gives
|
|
469
|
-
a peer no power a tailnet process lacks. It makes delegation named (the
|
|
470
|
-
"from peer" mark can be trusted) and refusable, not a new privilege class. A
|
|
471
|
-
delegated job runs as an ordinary backlog row on the peer, held by that
|
|
472
|
-
owner's auto-fire switch and budget, in a detached worktree
|
|
473
|
-
(`<clone>/.claude/worktrees/peer-<from>-<key>`), and refuses to restart or install
|
|
474
|
-
the peer's running daemon (the marker is checked by `scripts/restart.sh` and
|
|
475
|
-
`deploy/install-service.sh`). `GET /api/peers` lists the peers from loopback or
|
|
476
|
-
Tailscale only and reports this daemon's own tailnet address, never a LAN one.
|
|
477
|
-
- **The installer** (`curl -fsSL https://github.com/waterplane-dev/waterplane/releases/latest/download/install.sh | sh`,
|
|
478
|
-
generated from `deploy/install.sh.tmpl`) is code you run as yourself, so
|
|
479
|
-
read it first if you prefer: download it, open it, then `sh install.sh`.
|
|
480
|
-
It edits no shell rc file and runs at most one `sudo` line — the Linux
|
|
481
|
-
package manager installing `tmux` — and only after asking on the terminal;
|
|
482
|
-
everything else is unprivileged. It verifies the Node runtime
|
|
483
|
-
against nodejs.org's `SHASUMS256.txt` and the release archive against the
|
|
484
|
-
`.sha256` published beside it, and installs nothing on a mismatch. Those
|
|
485
|
-
checksums come over the same HTTPS channel as the files, so on their own
|
|
486
|
-
they catch only corruption. **Who published the release** is checked too:
|
|
487
|
-
the `.sha256` carries an Ed25519 signature (`.sha256.sig`) made with the
|
|
488
|
-
project's offline release key, and the installer verifies it — with the
|
|
489
|
-
Node it has just checked against nodejs.org — against the public key
|
|
490
|
-
written into the script itself (`deploy/release-pubkey.pem`). A missing or
|
|
491
|
-
wrong signature installs nothing. That defeats a compromised release page,
|
|
492
|
-
GitHub account or mirror that swaps the archive and its checksum together.
|
|
493
|
-
It does not defeat a compromised signing key, nor an `install.sh` that was
|
|
494
|
-
itself swapped (the key travels inside it — read it, or compare it with the
|
|
495
|
-
one in the repository). Rotating the key means a new `install.sh`: copies
|
|
496
|
-
already downloaded trust only the old key. `WATERPLANE_INSECURE_SKIP_SIG=1`
|
|
497
|
-
skips the check, loudly, for a release you built yourself. **Honest scope,
|
|
498
|
-
freshness:** the signature proves who published a release, not that it is
|
|
499
|
-
the newest — whoever can write the release assets can keep offering an
|
|
500
|
-
OLDER signed release as "latest" (a freeze), though never one below the
|
|
501
|
-
version already installed, which `waterplane update` refuses. A signed
|
|
502
|
-
expiry would close that and needs re-signing on a schedule; until then a
|
|
503
|
-
"current" that stays put for long is worth a glance at the releases page.
|
|
504
|
-
`waterplane update` holds a release to the same rule with the same key,
|
|
505
|
-
embedded in the host (`host/cli/releasesig.ts`, test-pinned to
|
|
506
|
-
`deploy/release-pubkey.pem`): it verifies `release.json.sig` before it
|
|
507
|
-
reads `release.json` (which decides the version it fetches) and
|
|
508
|
-
`.sha256.sig` before it trusts the `.sha256`, and a missing or wrong
|
|
509
|
-
signature refuses the update — nothing is installed and the current version
|
|
510
|
-
is untouched. The same `WATERPLANE_INSECURE_SKIP_SIG=1` skips it, with a
|
|
511
|
-
warning on every run; the doctor's self-update row believes a newer release
|
|
512
|
-
only after the same check. The
|
|
513
|
-
login service it installs (`waterplane service install`) bakes in only PATH,
|
|
514
|
-
HOME, its own state dir (and the root and Claude config dir when the shell
|
|
515
|
-
named them) and the variables passed with `--env`, never the installing
|
|
516
|
-
shell's environment, and refuses `WATERPLANE_ALLOW_PUBLIC` outright: a public bind
|
|
517
|
-
stays an explicit, visible opt-in in `.env`.
|
|
518
|
-
- **`node-pty` is pinned to a pre-release (`1.2.0-beta.15`) on purpose.** The
|
|
519
|
-
stable 1.1.0 leaks two file descriptors into the daemon on every macOS spawn
|
|
520
|
-
(a spare `/dev/ptmx` and the child's slave), which exhausted the machine's
|
|
521
|
-
pseudo-terminals; the 1.2.0 betas fix it. The pin is exact, the published
|
|
522
|
-
package's `npm-shrinkwrap.json` holds it, and `ptyleak.test.mjs` guards the fix.
|
|
523
|
-
Scanners that flag the pre-release or node-pty's install script (a native
|
|
524
|
-
build fallback; prebuilt binaries ship in the package) are seeing this choice.
|
|
525
|
-
It moves to `1.2.0` once that is released stable.
|
|
26
|
+
- Triage (in scope or not, and how serious): usually within a week.
|
|
27
|
+
- Fix: depends on severity and availability. Days for something serious and clearly in scope,
|
|
28
|
+
longer for lower severity. You will be kept posted rather than left in silence.
|
|
29
|
+
|
|
30
|
+
If you hear nothing after a reasonable wait, a follow-up comment on the advisory is fine.
|
|
31
|
+
|
|
32
|
+
### How a report is handled
|
|
33
|
+
|
|
34
|
+
1. The report stays in the private advisory while it is confirmed and triaged.
|
|
35
|
+
2. The fix is made and tested, and goes out in a release.
|
|
36
|
+
3. The advisory is published, and the release's `CHANGELOG.md` entry names the fix under
|
|
37
|
+
**Security:**, with the CVE or GHSA id when there is one.
|
|
38
|
+
4. Reporters are credited by name in the advisory and the changelog, unless they ask to stay
|
|
39
|
+
anonymous.
|
|
40
|
+
|
|
41
|
+
The [assurance case](docs/notes/assurance-case.md) argues why these properties hold. To check a
|
|
42
|
+
download yourself, see [Verify a download by hand](docs/install.md#verify-a-download-by-hand).
|
|
43
|
+
|
|
44
|
+
## The boundary, in plain words
|
|
45
|
+
|
|
46
|
+
The daemon spawns the real `claude` CLI (and, for the build feature, shell commands you configured) in a
|
|
47
|
+
PTY, a pseudo-terminal. That process can read, write and run anything the account running it can. **The
|
|
48
|
+
security boundary is who can reach the daemon at all. It is not a sandbox around what a connected,
|
|
49
|
+
authorized caller can do once inside.**
|
|
50
|
+
|
|
51
|
+
- By default the host binds only **loopback** and the machine's **Tailscale IP**. Reaching it normally
|
|
52
|
+
means being on the same tailnet, so Tailscale's device authentication is the real gate.
|
|
53
|
+
- On Windows the host runs inside WSL2 with mirrored networking. Its loopback is shared with Windows, so
|
|
54
|
+
"loopback" there means any process on the Windows machine, the same local-machine boundary as on a
|
|
55
|
+
Mac. The `Origin`/`Host` checks still apply to browsers on it.
|
|
56
|
+
- Never bind a public interface. `WATERPLANE_ALLOW_PUBLIC=1` adds LAN interfaces behind a password gate
|
|
57
|
+
(below) and is an explicit opt-in. Plain HTTP on a LAN has no defence against a man in the middle: if
|
|
58
|
+
that network is not trusted, do not enable it.
|
|
526
59
|
|
|
527
60
|
### In scope
|
|
528
61
|
|
|
529
|
-
Bugs that let someone who should **not** be able to reach or drive the daemon
|
|
530
|
-
do so anyway, e.g.:
|
|
62
|
+
Bugs that let someone who should **not** be able to reach or drive the daemon do so anyway:
|
|
531
63
|
|
|
532
|
-
- Auth bypass: reaching a gated (LAN or `gateTailscale`) daemon without a
|
|
533
|
-
|
|
534
|
-
- Cross-origin or DNS-rebinding attacks that let a malicious web page drive
|
|
535
|
-
|
|
536
|
-
- Path traversal in the file
|
|
64
|
+
- Auth bypass: reaching a gated (LAN or `gateTailscale`) daemon without a valid password or token, or
|
|
65
|
+
forging, replaying or extending a token.
|
|
66
|
+
- Cross-origin or DNS-rebinding attacks that let a malicious web page drive the API or WebSocket from a
|
|
67
|
+
browser already on the trusted network.
|
|
68
|
+
- Path traversal in the file and git endpoints (`packages/host/src/files.ts`,
|
|
537
69
|
`packages/host/src/git.ts`) that reaches outside the intended repo root.
|
|
538
|
-
- Injection
|
|
539
|
-
|
|
540
|
-
- Anything that lets a LAN-classified caller manage auth (set
|
|
541
|
-
|
|
542
|
-
- Secrets (VAPID keys,
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
### Out of scope
|
|
546
|
-
|
|
547
|
-
- **Anything an authorized, already-connected caller can do.**
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
70
|
+
- Injection (shell, argument or otherwise) reachable from network input rather than from the operator's
|
|
71
|
+
own configured commands.
|
|
72
|
+
- Anything that lets a LAN-classified caller manage auth (set or clear the password, toggle
|
|
73
|
+
`gateTailscale`). That is meant to be impossible.
|
|
74
|
+
- Secrets (VAPID keys, the password hash, tokens, `<state dir>/*.json`) leaking to a caller who should
|
|
75
|
+
not read them.
|
|
76
|
+
|
|
77
|
+
### Out of scope
|
|
78
|
+
|
|
79
|
+
- **Anything an authorized, already-connected caller can do.** A trusted device can drive `claude` and
|
|
80
|
+
run configured build commands, and both execute arbitrary code by design. Such a caller can read and
|
|
81
|
+
write files in scanned repos, run git including `push`, run saved build commands
|
|
82
|
+
(`packages/host/src/builds.ts`) and, through `claude`, read the screen, use the clipboard or run
|
|
83
|
+
anything the desktop user could. None of that is a vulnerability.
|
|
84
|
+
- **Exposing the daemon to an untrusted network** with `WATERPLANE_ALLOW_PUBLIC=1` or by binding
|
|
85
|
+
`WATERPLANE_HOST` to a public interface. These are explicit opt-ins with a loud warning in the README;
|
|
86
|
+
a stranger reaching the daemon after that is expected behavior.
|
|
87
|
+
- Denial of service from a caller who is already authorized (spamming sessions, filling disk with
|
|
88
|
+
uploads). File it as a regular issue or PR if you have a fix.
|
|
89
|
+
- Vulnerabilities in the `claude` CLI, Tailscale or other third-party software this project invokes but
|
|
90
|
+
does not ship. Report those upstream.
|
|
91
|
+
- Anything that needs physical access to an already-unlocked machine running the daemon.
|
|
92
|
+
- **A paired host is fully trusted by the Android shell.** The app's UI updates over the air from the
|
|
93
|
+
host it is paired with (`packages/web/src/webUpdate.ts`). Bundles are signed with a per-desktop key the
|
|
94
|
+
phone pins over a protected route. That stops a relay on the network, never the desktop itself: a host
|
|
95
|
+
you pair with can ship JS that runs with the shell's native plugins. Pair only with desktops you
|
|
96
|
+
control.
|
|
97
|
+
|
|
98
|
+
If you are unsure whether something is in scope, report it privately anyway. A false positive costs a
|
|
99
|
+
few minutes; a missed real bug costs far more.
|
|
100
|
+
|
|
101
|
+
## How access is controlled
|
|
102
|
+
|
|
103
|
+
### Network and password gate
|
|
104
|
+
|
|
105
|
+
- `WATERPLANE_ALLOW_PUBLIC=1` also binds LAN interfaces. Connections classified `lan` (`peerKind` in
|
|
106
|
+
`packages/host/src/net.ts`) must then authenticate with a password (`packages/host/src/auth.ts`),
|
|
107
|
+
scrypt-hashed in `<state dir>/auth.json`. With no password set, LAN callers are refused outright
|
|
108
|
+
("deny until set"). The optional `gateTailscale` toggle extends the requirement to Tailscale peers.
|
|
109
|
+
Loopback is always trusted and can never be gated.
|
|
110
|
+
- An unreadable `auth.json` is moved aside and treated as "no password", still deny-until-set for LAN.
|
|
111
|
+
If its bytes may have held `gateTailscale`, Tailscale stays gated until the password is set again
|
|
112
|
+
from loopback. A corrupt file never opens the gate.
|
|
113
|
+
- Password management (`auth.set`, `auth.clear`, `gateTailscale`) is accepted only from an
|
|
114
|
+
already-trusted connection (loopback or Tailscale). A LAN caller, even one who knows the password,
|
|
115
|
+
can authenticate with it but never set or change it.
|
|
116
|
+
- **Funnel is never trusted.** A loopback request carrying `Tailscale-Funnel-Request` (added by
|
|
117
|
+
`tailscale funnel`, from the public internet) is classed `public` (`requestPeerKind` in `net.ts`,
|
|
118
|
+
used by the token gate, the host-management routes and `/api/peer*`). It always needs a token and
|
|
119
|
+
can never manage auth or mint pairing codes.
|
|
120
|
+
- Behind `tailscale serve` every socket is loopback and carries a proxy header. The gate classes such
|
|
121
|
+
a request as a Tailscale peer, so it applies to the https address too, and the rate limiter
|
|
122
|
+
(`ratelimit.ts`) keys on the client address serve writes into `X-Forwarded-For` (read only when the
|
|
123
|
+
socket is loopback and a proxy header is present). `POST /api/auth/login` is rate-limited per
|
|
124
|
+
address (429 with retry-after) before the password hash is checked.
|
|
125
|
+
|
|
126
|
+
### Device tokens
|
|
127
|
+
|
|
128
|
+
Every token belongs to one row of the paired-device ledger, `<state dir>/devices.json` (mode 0600).
|
|
129
|
+
A token is 32 random bytes, base64url, returned once and stored **only as its sha256**, compared in
|
|
130
|
+
constant time. Tokens survive a host restart. No token is valid while no password is set.
|
|
131
|
+
|
|
132
|
+
- Revoking a row (`POST /api/devices/revoke`, authenticated) kills its token at once and closes that
|
|
133
|
+
device's sockets: close frame first, teardown after one second whatever the peer does, and a frame
|
|
134
|
+
arriving meanwhile is dropped (`socketAuthorized`, asked for every frame). Setting, changing or
|
|
135
|
+
clearing the password drops every row.
|
|
136
|
+
- A browser gets a token from `POST /api/auth/login` as an httpOnly cookie. The Android shell, whose
|
|
137
|
+
cross-site cookie is withheld, mints one with `POST /api/auth/token`, **only from a caller that is
|
|
138
|
+
already trusted**: loopback, or a Tailscale peer holding a valid token when that path is gated;
|
|
139
|
+
never LAN or Funnel, even with a token, and never while no password is set. It sends the token as
|
|
140
|
+
`Authorization: Bearer <tok>` on HTTP and as the WebSocket subprotocol `waterplane-tok.<tok>` on the
|
|
141
|
+
upgrade. The host echoes only the base protocol, never the token.
|
|
142
|
+
- The page keeps its tokens in the app's private localStorage. The APK's background link keeps a
|
|
143
|
+
second copy of each gated desktop's token so it can reconnect with the app swiped away: AES-256-GCM
|
|
144
|
+
sealed under a non-exportable Android Keystore key (`ShellLinkTokens`), bound to that desktop's id
|
|
145
|
+
and address, sent only to its saved tailnet address, and deleted when the desktop is removed, the
|
|
146
|
+
token is refused or the link is switched off. **Residual:** the key needs no user authentication,
|
|
147
|
+
because nobody is holding the phone when the service reconnects. It protects against a copied file,
|
|
148
|
+
not against code running as this app. With *lock the desktop sign-ins* on (see The Android app) the
|
|
149
|
+
link keeps no copy at rest.
|
|
150
|
+
|
|
151
|
+
### Pairing codes
|
|
152
|
+
|
|
153
|
+
(`packages/host/src/pair.ts`.) `POST /api/pair/mint` sits behind the normal gate. The 8-character code
|
|
154
|
+
lives 10 minutes and is kept in memory only as its sha256, so a restart invalidates every outstanding
|
|
155
|
+
one. At most 5 are live. The first successful `POST /api/pair/redeem` spends it. That is the one
|
|
156
|
+
pre-auth route that can mint a device token, and it is rate-limited per remote IP (5, then one per
|
|
157
|
+
10 s). Redeem mints a token only while a password is set; with none, a LAN redeemer is refused before
|
|
158
|
+
the code is spent. The LAN identity secret goes only to a loopback or Tailscale redeemer.
|
|
159
|
+
|
|
160
|
+
`GET /pair/<code>` (landing) and `/pair/<code>/apk` sit outside `/api/` and the token gate so a new
|
|
161
|
+
phone can load them. They reveal nothing about the desktop until the code checks out, carry
|
|
162
|
+
`no-store`, `X-Frame-Options: DENY`, a `default-src 'none'` CSP and `no-referrer`, and are not in
|
|
163
|
+
`OPAQUE_READ_ROUTES`.
|
|
164
|
+
|
|
165
|
+
### Pairing with no tailnet
|
|
166
|
+
|
|
167
|
+
(`pairpake.ts`, `shared/pake.ts`.) A phone pairs by CPace over ristretto255, keyed by the 8-character
|
|
168
|
+
code, on the plain port. The phone proves it knows the code first; the host answers with a sealed
|
|
169
|
+
reply (token, `hostId`, TLS pin, update key, LAN identity), and the seal authenticates the host.
|
|
170
|
+
|
|
171
|
+
- A relay gets one online guess per attempt. An impostor host answering `start` gets at most five, one
|
|
172
|
+
per share it sends, which is all a phone will answer.
|
|
173
|
+
- A real host offers the five newest open codes and checks each confirm against all of them in
|
|
174
|
+
constant time. With more than five open, an older code never pairs this way: its phone is told the
|
|
175
|
+
desktop does not know that code and to show a new QR, which then works. The attempt counts as a miss
|
|
176
|
+
against the five offered.
|
|
177
|
+
- A session lives 60 s and is deleted on its first `finish`. Ten wrong confirms close a code *for this
|
|
178
|
+
pairing only*; it still redeems over the tailnet. So a device on the Wi-Fi can stop an encrypted
|
|
179
|
+
pairing (as dropping packets does) but cannot burn a code meant for the tailnet.
|
|
180
|
+
- The desktop refuses with no password set.
|
|
181
|
+
- **A code never crosses a plain socket and lives.** On a socket that is not loopback, the tailnet,
|
|
182
|
+
`tailscale serve` or TLS, `/pair/<code>` and `/pair/<code>/apk` burn the code and show a page with
|
|
183
|
+
no link and no APK. A LAN-only desktop's screen shows the deep-link QR, which goes camera to app. A
|
|
184
|
+
code already sent in the clear is dead on that phone.
|
|
185
|
+
- The link also carries a 128-bit commitment to the key and pin, which the seal must match.
|
|
186
|
+
`waterplane pair` prints the update key's fingerprint beside the code.
|
|
187
|
+
|
|
188
|
+
**Residuals:** a person who accepts the question shown for an older desktop spends that code and token
|
|
189
|
+
in the clear (the app cannot tell an old desktop from someone blocking encrypted pairing). A relay can
|
|
190
|
+
fill the miss count and stop pairing. Whether every camera app opens the waterplane link is
|
|
191
|
+
unverified; the typed code runs the same protocol. The first APK install over the network is
|
|
192
|
+
unprotected.
|
|
193
|
+
|
|
194
|
+
**A phone with Tailscale off** reaches the desktop only on the Wi-Fi. An APK built before code pairing
|
|
195
|
+
stays on plain LAN with trust by name, except that an update key it already holds still refuses a
|
|
196
|
+
forged bundle. A current APK pairs by code, holds the pin and goes over TLS. A phone that never learned
|
|
197
|
+
a pin cannot tell a relay from the desktop on the first plain contact; pairing by code is the fix.
|
|
198
|
+
After a password change tokens rotate, so a LAN-only phone must pair again, and its gate says so rather
|
|
199
|
+
than asking for a password that cannot work there. On a shell that enforces pins, a page that knows
|
|
200
|
+
Tailscale is off sends no device token to a tailnet address, pinned or not, since only the Wi-Fi can
|
|
201
|
+
answer it. Until the shell's first network reading it holds the token back on those addresses and
|
|
202
|
+
learns no pin, for at most 8 seconds; a shell silent past that falls back to trust by name. The
|
|
203
|
+
background link is not covered on an unpinned row: it sends its token to the address it holds.
|
|
204
|
+
|
|
205
|
+
### Passive devices (a kids' phone)
|
|
206
|
+
|
|
207
|
+
Pairing can mark a device "passive": its ledger row stores `scope: "passive"` (rows without it are full
|
|
208
|
+
scope). The host holds such a token to a **default-deny allowlist** (`passive.ts`): `GET /api/health`,
|
|
209
|
+
`/api/webbundle[/file]`, `/api/theme`, `/api/passive/state`, and `GET /api/repos/<id>/artifact` only
|
|
210
|
+
for an APK in the auto-pull queue at that moment. Every other `/api/` route is refused (403), the
|
|
211
|
+
WebSocket is closed (4403), and `/api/auth/token` never mints it a full token. A passive pairing needs
|
|
212
|
+
a password and switches "gate Tailscale" on; while any passive row exists the gate cannot be switched
|
|
213
|
+
off, so the phone has no tokenless path on Tailscale or the LAN.
|
|
214
|
+
|
|
215
|
+
**Unlock.** A PIN (4 to 12 digits, scrypt-hashed in `<state dir>/passive-pin.json`, 0600) is set only
|
|
216
|
+
from a trusted caller that is not passive (`POST /api/auth/pin`). `POST /api/auth/unlock` checks it,
|
|
217
|
+
rate-limited per device (5, then one per minute), and lifts that one token to full scope for 30
|
|
218
|
+
minutes, held in host memory only. Expiry, "Lock now" (`POST /api/auth/lock`), a PIN change or a host
|
|
219
|
+
restart end it, and its sockets are closed.
|
|
220
|
+
|
|
221
|
+
**Auth material is the one part of full scope an unlock never grants**, because each piece would
|
|
222
|
+
outlive the 30 minutes. An unlocked passive device cannot mint a pairing code (`POST /api/pair/mint`; a
|
|
223
|
+
redeemed code is a permanent full token) or a device token, revoke a device, pair or unpair a peer
|
|
224
|
+
(`/api/peers` writes), read the LAN identity secret, or set the password, the Tailscale gate or the
|
|
225
|
+
PIN. Three places enforce it: `passiveNeverAllows` in `passive.ts` (checked in `server/http.ts` right
|
|
226
|
+
after the allowlist), the `/api/auth/token` and `/api/auth/pin` handlers' own scope checks, and the
|
|
227
|
+
WebSocket `auth.set`, `auth.clear` and `auth.gateTailscale` ops (`routes/system.ts`, by the socket's
|
|
228
|
+
device row). A passive caller who knows the PASSWORD can still log in like any browser; the PIN model
|
|
229
|
+
does not cover that.
|
|
230
|
+
|
|
231
|
+
### Browser origins
|
|
232
|
+
|
|
233
|
+
Network identity does not cover browsers: a WebSocket ignores the same-origin policy
|
|
234
|
+
(`packages/host/src/origin.ts`).
|
|
235
|
+
|
|
236
|
+
- Every `/api/*` request and WebSocket upgrade is checked on `Origin` and `Host` (against DNS
|
|
237
|
+
rebinding), with `Sec-Fetch-Site` as the backstop for `Origin`-less cross-origin GETs. A missing
|
|
238
|
+
`Origin` is allowed (curl, e2e, the hook sink).
|
|
239
|
+
- Only `http`/`https` origins naming this machine on a port it serves pass, plus one non-web origin,
|
|
240
|
+
`capacitor://localhost`, the iOS shell's page (WKWebView cannot serve an app from `http`). Any other
|
|
241
|
+
scheme is refused. The CORS header only ever reflects an origin that passed, never `*`.
|
|
242
|
+
- `*.ts.net` is accepted by suffix only while on a tailnet whose MagicDNS name could not be read, only
|
|
243
|
+
for requests arriving through `tailscale serve`, and only when the Origin names the same host the
|
|
244
|
+
request was sent to. The name is re-read until known. `*.local` is never a wildcard (only the
|
|
245
|
+
machine's own mDNS name).
|
|
246
|
+
- Side-effecting endpoints must not be GET (`/api/host/screenshot` is POST for this). Exactly two
|
|
247
|
+
read-only routes are exempt (`OPAQUE_READ_ROUTES`: artifact and inbox file, GET/HEAD only), because
|
|
248
|
+
the APK loads them as `<img src>` or `<a download>`.
|
|
249
|
+
- Path guards in `files.ts`, `git.ts` and `scaffold.ts` are asserted by e2e. `scaffold.inRoot()`
|
|
250
|
+
compares realpath-resolved paths (`/var` resolves to `/private/var`).
|
|
251
|
+
|
|
252
|
+
### Local-process-only routes
|
|
253
|
+
|
|
254
|
+
`POST /api/hook` (the sink the spawned `claude` posts lifecycle events to), `POST /api/plugin/<id>/hook`
|
|
255
|
+
and `POST /api/shell/open` answer only a process on this machine, whatever the other settings. The
|
|
256
|
+
socket peer must be loopback AND the request must carry no reverse-proxy header (`X-Forwarded-*`,
|
|
257
|
+
`Forwarded`, `X-Real-IP`, `Via`, `Tailscale-User-*`; one list, `localProcessRefusal` in `net.ts`).
|
|
258
|
+
Loopback alone is not enough, because behind `tailscale serve` every tailnet request arrives from
|
|
259
|
+
127.0.0.1. A loopback request whose `Host` names anything but this machine's loopback is treated as
|
|
260
|
+
proxied. A reverse proxy that forwards neither a proxy header nor the client `Host` is treated as
|
|
261
|
+
local, so front the daemon only with one that sets `X-Forwarded-For`.
|
|
262
|
+
|
|
263
|
+
`/api/agent/intents`, `/api/agent/backlog`, `/api/agent/followup` and `/api/agent/exposure[/triage]`
|
|
264
|
+
are for an agent in a driven repo (posting work for later, `agentintents.ts`; reading or marking a
|
|
265
|
+
crash or exposure finding, `agentexposure.ts`, where a mark needs a live session and never clears).
|
|
266
|
+
They answer only a loopback caller that is neither a browser (any `Origin` or `Sec-Fetch-Site` header
|
|
267
|
+
is refused, same-origin included) nor proxied (same headers as above). They add no power: a posted row
|
|
268
|
+
goes through the same containment gate and store as the phone's `intent.post`, cannot bring a budget
|
|
269
|
+
waiver, and waits for the person's auto-fire switch and budget like any scheduled item. A backlog add
|
|
270
|
+
never starts until the person fires it.
|
|
271
|
+
|
|
272
|
+
## The Android app
|
|
273
|
+
|
|
274
|
+
### Sealed sign-ins (optional, off by default)
|
|
275
|
+
|
|
276
|
+
Settings > Connection > *lock the desktop sign-ins* puts this phone's device tokens in one blob under a
|
|
277
|
+
Keystore key, opened on each use with the fingerprint (or the phone's PIN, on a build that supports
|
|
278
|
+
it), and removes the plaintext. Nothing on the host changes. **It guards against a copy of the app's
|
|
279
|
+
storage (a backup, a pulled file), not against a hand holding the unlocked phone** (that is the app
|
|
280
|
+
lock); the process holds the tokens in plaintext memory while running. A desktop with no password has
|
|
281
|
+
no token, so there is nothing to lock for it.
|
|
282
|
+
|
|
283
|
+
The background link depends on the APK. A current app keeps each token in memory only: alerts continue
|
|
284
|
+
after a swipe-away until the phone restarts or Android stops the app, then it shows "locked, open the
|
|
285
|
+
app". An app built before this keeps no token for the link, so alerts from gated desktops wait until
|
|
286
|
+
the app is opened.
|
|
287
|
+
|
|
288
|
+
**Residuals:**
|
|
289
|
+
|
|
290
|
+
- A token the desktop revoked stays in the blob until the next rewrap.
|
|
291
|
+
- A stray bare token that belongs to no saved desktop can stay plaintext (the row says so).
|
|
292
|
+
- Passive (kids') tokens are not sealed.
|
|
293
|
+
- On a build without PIN support the start prompt is fingerprint only, and on a plain-HTTP route it
|
|
294
|
+
refuses the key, so its card stays until the route is the tailnet or HTTPS. The row refuses to lock
|
|
295
|
+
in that state, and the host setup form refuses to save a plain address while that app has both locks
|
|
296
|
+
on.
|
|
297
|
+
- The recents thumbnail still shows the last screen.
|
|
298
|
+
- A lost key (screen lock removed, or a new fingerprint on a fingerprint-only key) loses the sign-ins,
|
|
299
|
+
so a tailnet-gated desktop must be paired again. A missing sensor never counts as a lost key. A lost
|
|
300
|
+
key's blob is set aside on the phone (`tokenSealDead`), not deleted; nothing restores it, and the
|
|
301
|
+
next lock's new key makes it unreadable.
|
|
302
|
+
- A key that is dead but not worded as such by an OEM stays a refusal. The way out is *forget the
|
|
303
|
+
locked sign-ins* (confirmed, behind the app lock), which discards the blob by hand and gives a
|
|
304
|
+
stranger nothing.
|
|
305
|
+
- "Nothing connects before the unlock" is true of the socket only. Tokenless HTTP, such as the
|
|
306
|
+
app-update check, runs first and carries no token.
|
|
307
|
+
|
|
308
|
+
### App lock (optional, off by default)
|
|
309
|
+
|
|
310
|
+
Settings > Connection > *lock this app* requires the phone's fingerprint (or PIN, on a build that
|
|
311
|
+
supports it) before the app shows its screen. **It is a hold-the-phone defence at UI level and nothing
|
|
312
|
+
more.** No host route, token, gate or wire message changes, and the device token's storage is
|
|
313
|
+
unchanged unless *lock the desktop sign-ins* is on. The page stays mounted behind an opaque card.
|
|
314
|
+
|
|
315
|
+
**Residuals:** anyone who has the phone's PIN passes it (the PIN fallback, or by enrolling a finger).
|
|
316
|
+
The recents thumbnail, and the frame the WebView shows on wake before the page's JS runs, still show
|
|
317
|
+
the last screen.
|
|
318
|
+
|
|
319
|
+
The lock fails closed: a cancelled, failed, locked-out or unrecognised refusal never opens the app, at
|
|
320
|
+
any repeat count. It opens and switches itself off (saying so) only when the phone's own credential had
|
|
321
|
+
to change the key, the app's saved check is unreadable, or a fresh status read confirms no sensor or
|
|
322
|
+
nothing to check with. So on an old APK with a dead sensor that still reports hardware, or on a
|
|
323
|
+
plain-HTTP route (where that APK's key use is refused), the way out is a newer APK, another route (the
|
|
324
|
+
tailnet or HTTPS), or clearing the app's data.
|
|
325
|
+
|
|
326
|
+
## Over the LAN
|
|
327
|
+
|
|
328
|
+
### LAN identity and protected routes
|
|
329
|
+
|
|
330
|
+
`<state dir>/lan-identity.json` (0600) holds a `hostId` and a secret. `GET /api/lan/identity` hands them
|
|
331
|
+
out to loopback and Tailscale callers ONLY; every other peer kind is refused (403) before the token
|
|
332
|
+
gate, so a LAN token never unlocks it. `POST /api/lan/verify {nonce}` answers anyone (pre-auth,
|
|
333
|
+
rate-limited per remote IP) with `HMAC-SHA256(secret, hostId + "\n" + nonce)`, and the phone sends a
|
|
334
|
+
token over LAN only after that MAC checks out.
|
|
335
|
+
|
|
336
|
+
**Residual:** the MAC proves that whatever answers at a LAN address holds the secret. It cannot resist
|
|
337
|
+
an **active relay** on the person's own Wi-Fi that forwards the probe to the real desktop and then reads
|
|
338
|
+
the plain-HTTP traffic, token included. With the token that relay can drive the desktop, and it could
|
|
339
|
+
answer the app with a forged page.
|
|
340
|
+
|
|
341
|
+
So the Android app asks of every route whether it is *protected* (`isPlainRoute` in shared, mirrored
|
|
342
|
+
natively by `ShellRoute.java`). On a route that is not, it refuses every fingerprint use, every
|
|
343
|
+
over-the-air update and every app download or install, and warns in the address form, the health sheet,
|
|
344
|
+
the strip ("LAN, not protected") and a toast.
|
|
345
|
+
|
|
346
|
+
**The protected-route rule.** A route is protected only if it is exactly one of:
|
|
347
|
+
|
|
348
|
+
- loopback;
|
|
349
|
+
- a Tailscale address or MagicDNS name over `http`, or `https` to a `*.ts.net` name, unless a newer app
|
|
350
|
+
reports that the phone has no tailnet (then a tailnet name proves nothing: with Tailscale off, `100.x`
|
|
351
|
+
goes to the Wi-Fi gateway);
|
|
352
|
+
- the desktop's own TLS address, on an app that holds its pin;
|
|
353
|
+
- an `https` address the person typed themselves.
|
|
354
|
+
|
|
355
|
+
Everything else is plain, **whatever its scheme**: a relay with a public certificate for its own name
|
|
356
|
+
passes `https` validation. A reply from a desktop that has not proved itself (not sealed by the pairing
|
|
357
|
+
code, or not received over a protected route) never sets a saved desktop's address, key, pin or
|
|
358
|
+
identity, so a relay cannot walk a row onto itself. An app built before this rule trusts only tailnet
|
|
359
|
+
and HTTPS.
|
|
360
|
+
|
|
361
|
+
**Residuals:** a row saved by an older app may already hold an address someone planted (a non-`ts.net`
|
|
362
|
+
`https` one reads as plain and the app opens an empty field to retype it; a planted `ts.net` or `100.x`
|
|
363
|
+
one cannot be told apart). A person who taps "pair" on a pairing link someone else made pairs with
|
|
364
|
+
whoever made it (the confirm says so). A browser on plain HTTP has no such defence, because the page
|
|
365
|
+
itself arrives unprotected, and the token is still exposed to the relay.
|
|
366
|
+
|
|
367
|
+
### LAN TLS and the pin
|
|
368
|
+
|
|
369
|
+
(`packages/host/src/lantls.ts`.) Only where `WATERPLANE_ALLOW_PUBLIC=1` already binds LAN addresses, the
|
|
370
|
+
host also listens on `WATERPLANE_TLS_PORT` (default port + 1) on those same addresses, with the same
|
|
371
|
+
handler and gate, so a caller there is still peer kind `lan` and needs its token. Off with
|
|
372
|
+
`WATERPLANE_TLS=0`; never on loopback or the tailnet; nothing without the opt-in.
|
|
373
|
+
|
|
374
|
+
The key is P-256 in `<state dir>/lan-tls.json` (0600), kept across restarts. The certificate is
|
|
375
|
+
self-signed and re-issued on the same key when the addresses change, so the phone's pin (SHA-256 of the
|
|
376
|
+
key) survives. The app stores the pin per desktop and enforces it in the WebView, the background link
|
|
377
|
+
and the pull service; a desktop with a pin is then never reached over plain HTTP on a LAN address. A
|
|
378
|
+
self-signed port does not serve the web app (it answers a notice page), because a browser click-through
|
|
379
|
+
would look protected and is not. With `WATERPLANE_TLS_CERT` + `WATERPLANE_TLS_KEY` (a person's own leaf
|
|
380
|
+
from a CA; a self-signed one is read as self-signed) it serves the app, and the pin follows that leaf.
|
|
381
|
+
|
|
382
|
+
**What the pin does not defend:**
|
|
383
|
+
|
|
384
|
+
- The WebView's handler runs only when validation fails, so a certificate valid under a CA the phone
|
|
385
|
+
trusts would skip the pin inside the WebView. Pinned addresses are private IPs and `.local` names,
|
|
386
|
+
which public CAs do not issue, and the app trusts no user-added CA.
|
|
387
|
+
- A first install of the APK over the network (Android accepts any signature then).
|
|
388
|
+
- Code already running in the page, a browser, and dropping packets.
|
|
389
|
+
|
|
390
|
+
The pin is a property of current APK builds; an older APK ignores it and stays on plain LAN with the
|
|
391
|
+
warnings. The way out of a pinned desktop whose TLS port is off is a confirmed "use plain HTTP for this
|
|
392
|
+
desktop" on its row; nothing does it by itself. The measurements behind this, taken on two emulator
|
|
393
|
+
WebViews only, are in `docs/notes/protocol-and-security.md`. **None of this has been tried on a real
|
|
394
|
+
phone.**
|
|
395
|
+
|
|
396
|
+
### Signed updates
|
|
397
|
+
|
|
398
|
+
(`packages/host/src/updatekey.ts`, `webbundle.ts`, `packages/web/src/updateTrust.ts`,
|
|
399
|
+
`ShellBundleVerify.java`.) Each desktop signs its web bundle's manifest with its own P-256 key
|
|
400
|
+
(`<state dir>/update-key.json`, 0600), not a project key: people build their own bundles, and a private
|
|
401
|
+
key shipped to every desktop is no key. The manifest carries `signed` (hostId, build id, `gen`, the file
|
|
402
|
+
list with hashes) and `sig`.
|
|
403
|
+
|
|
404
|
+
A phone learns the desktop's public key from `welcome` only when the delivering socket's address was
|
|
405
|
+
protected, so a relay cannot hand it one. Once a phone holds a key, an update is refused and nothing is
|
|
406
|
+
written if it is unsigned, badly signed, for another desktop, or older than the last one taken (`gen`,
|
|
407
|
+
which rises when the served build id changes, so a real revert still works). The check runs in the page
|
|
408
|
+
(which works on any shipped APK) and again natively on an APK that has it. The native check also asks in
|
|
409
|
+
its own dialog the first time it sees a desktop's key, a changed key, or a restored desktop; page code
|
|
410
|
+
cannot tap it. A new key (a wiped state dir) or a lower `gen` (a restored one) waits for that answer.
|
|
411
|
+
|
|
412
|
+
**What it does not do.** A signature proves the bundle came from the desktop you paired, never that its
|
|
413
|
+
code is good. **A hostile desktop can still ship anything**, and a paired host stays fully trusted (see
|
|
414
|
+
Out of scope). An APK without the native check has the page's check only, so page code that is already
|
|
415
|
+
compromised can still persist a bundle there. A passive (kids') phone opens no socket, so it never
|
|
416
|
+
learns a key and takes updates unchecked, over a protected route or not at all. A desktop that cannot
|
|
417
|
+
read or write its key file serves the bundle unsigned (logged once); a phone that holds a key then
|
|
418
|
+
refuses it and says to update or repair the desktop.
|
|
419
|
+
|
|
420
|
+
### Read ticket
|
|
421
|
+
|
|
422
|
+
(`auth.ts`, `POST /api/auth/read-ticket`.) An image tag or a download link cannot carry a bearer header,
|
|
423
|
+
so a phone with no tailnet could not load them. A signed device token buys a one-hour ticket (HMAC of
|
|
424
|
+
device id, scope and expiry; the key lives in memory, so a restart voids every ticket). It is accepted
|
|
425
|
+
as `?wt=` **only** on the two opaque read routes (`OPAQUE_READ_ROUTES`), **only** over the TLS socket,
|
|
426
|
+
GET/HEAD only, only while the device row exists, and held to the row's scope (a passive phone's ticket
|
|
427
|
+
gets what a passive token gets). Access logs, shares and copies strip `wt`. A ticket in a URL is a
|
|
428
|
+
credential for those two read routes for up to an hour: do not paste such a URL.
|
|
429
|
+
|
|
430
|
+
## What a connected host does on your machine
|
|
431
|
+
|
|
432
|
+
### Shell sessions
|
|
433
|
+
|
|
434
|
+
A shell session (a plain login shell in a repo, `backends/shell.ts`) is the same `session.create` op
|
|
435
|
+
behind the same gates as a `claude` session and widens nothing. Its `$BROWSER` shim posts to `POST
|
|
436
|
+
/api/shell/open` (local process only, above) and stores nothing for an unknown session. A URL with
|
|
437
|
+
userinfo in its authority (`https://github.com@evil.tld/`) is never published. Only a person's tap
|
|
438
|
+
opens one: a job (the intent tick, the backlog, a fan-out, an agent's post) or a peer desktop's job is
|
|
439
|
+
refused. The shell does not inherit the daemon's API keys and key files (`*_API_KEY`, `*_KEY_FILE`,
|
|
440
|
+
`*_KEYCHAIN` are unset in the pane), and its commands are never written to the session's prompt
|
|
441
|
+
history.
|
|
442
|
+
|
|
443
|
+
### Live desktop
|
|
444
|
+
|
|
445
|
+
(`packages/extensions/desktop`; macOS, and Windows with the host in WSL; experimental.) Watching is a
|
|
446
|
+
stream on the same device-token socket and widens no gate. **Control is arbitrary input on the
|
|
447
|
+
desktop** (clicks and keys reach every app, password managers and a logged-in terminal included), so
|
|
448
|
+
it carries the same trust as the PTY and is a separate setting, **off by default**. It is gated on the
|
|
449
|
+
host, not only on the phone: with it off, `stream.input` is dropped before it reaches the helper; with
|
|
450
|
+
it on, a Mac still needs the macOS Accessibility grant. Windows asks no app for a grant, so there the
|
|
451
|
+
host's switch is the only gate. A peer token never reaches a stream (`/api/peer/*` only), and no job,
|
|
452
|
+
schedule or peer desktop opens one.
|
|
453
|
+
|
|
454
|
+
The helper `wp-desktop` runs as the desktop user, and capture runs only while a view is open. On a Mac
|
|
455
|
+
it is signed with a stable identifier and holds only the Screen Recording and Accessibility grants a
|
|
456
|
+
person gave it there (it asks macOS only when a person taps Fix on a setup row), and macOS shows its
|
|
457
|
+
own recording indicator throughout. Windows shows no such indicator.
|
|
458
|
+
|
|
459
|
+
### Consent is the phone's, checked on the host
|
|
460
|
+
|
|
461
|
+
(`confirmgate.ts`.) Checklist fixes that act on the person's behalf (`CONFIRM_GATED_FIXES`: accepting
|
|
462
|
+
Google's Android SDK licence and downloading the toolchain, making a signing key, writing a repo's
|
|
463
|
+
Claude settings) and the first-app job (`POST /api/onboard`, and `/api/onboard/resume` when it adds
|
|
464
|
+
consent) need BOTH a browser `Origin` that passes the origin gate (an Origin-less request, allowed
|
|
465
|
+
elsewhere, is refused here) AND a one-shot token valid 15 minutes. The GET that draws the confirm
|
|
466
|
+
mints it, bound to that worktree and fix id, that path and the consent ids the screen listed, or that
|
|
467
|
+
job. A gated fix never runs inside a "Fix all" list. **Honest scope:** a process running as you can
|
|
468
|
+
run `sdkmanager` itself, so this does not stop a determined local program. It stops the spawned
|
|
469
|
+
`claude` agent, a plugin or a script from accepting a licence with one undocumented POST. Every
|
|
470
|
+
refusal is logged.
|
|
471
|
+
|
|
472
|
+
### Ask rules
|
|
473
|
+
|
|
474
|
+
(`packages/host/src/askrules.ts`.) A desktop-wide list of permission rules (`Bash(git push * main)`,
|
|
475
|
+
`npm publish*`, and so on) is passed to every spawned `claude` as `permissions.ask` in the `--settings`
|
|
476
|
+
flag. That outranks anything the agent writes into the repo's settings and holds under
|
|
477
|
+
bypass-permissions; the resulting box is answerable only by a person's tap. Changing the list (`PUT
|
|
478
|
+
/api/askrules`) needs a browser `Origin` and a one-shot confirm minted by the GET for the list's current
|
|
479
|
+
revision, so a `curl` with a forged header cannot delete a rule and a stale phone cannot overwrite a
|
|
480
|
+
newer list. The list reaches `claude` sessions started on this desktop only: a peer desktop's jobs run
|
|
481
|
+
under that peer's own list, and the host's own headless calls (review, assess) take no rule. The state
|
|
482
|
+
file is read at boot, so a same-user edit takes effect at the next restart. **Honest scope:** it stops
|
|
483
|
+
the accident, not a wrapper script the rule cannot see or an agent talking you into Yes.
|
|
484
|
+
|
|
485
|
+
### Files the host writes
|
|
486
|
+
|
|
487
|
+
- **Toolchain downloads** land in a fresh `mkdtemp` directory (0700) under the per-user data dir, are
|
|
488
|
+
created with `O_EXCL`, and are hashed and unpacked from there, never from a shared temp dir where
|
|
489
|
+
another local account could plant a file or symlink.
|
|
490
|
+
- **Files the host rewrites inside a repo** (`.gitignore` via the secret writer, the checklist's
|
|
491
|
+
gitignore fix and the signing-key link; `CLAUDE.md` and `.claude/settings.json` via checklist fixes)
|
|
492
|
+
are written through an in-repo symlink but never through one that leaves the worktree. A committed
|
|
493
|
+
`.gitignore -> ~/.gitconfig` is refused in words (`writeTextAtomicKeepMode`'s `within`), the same
|
|
494
|
+
realpath rule the file routes apply to reads. The repo walks (tree search, artifact discovery,
|
|
495
|
+
directory entry counts) neither descend into nor list the contents of a link whose target is outside
|
|
496
|
+
the worktree; the link itself is shown as a link, nothing more.
|
|
497
|
+
|
|
498
|
+
### A repo's own repo-health engine
|
|
499
|
+
|
|
500
|
+
(`tools/repohealth.mjs`, run by the host after one approval, `repohealthapproval.ts`.) The engine is
|
|
501
|
+
pinned by a hash of the entry, its siblings and every module it imports with a plain relative path,
|
|
502
|
+
static or dynamic. An engine whose loads the hash cannot see is never approvable, and the phone is told
|
|
503
|
+
why: an `import()` or `require()` argument that is not one plain string; `eval`, `Function()`, `vm`, a
|
|
504
|
+
worker or loader hooks; a `#` import-map entry, or a module from a URL or an absolute path; an import
|
|
505
|
+
outside the repo, or a dependency committed under any `node_modules` or reached by the repo's own
|
|
506
|
+
package name; a tree too big to read whole. **Honest scope:** this is a comment- and string-aware text
|
|
507
|
+
scan, not a parser. It closes the plain shapes. An engine that spawns `node <file>` or reaches code
|
|
508
|
+
through an alias the scan does not know still runs what the approval never saw.
|
|
509
|
+
|
|
510
|
+
### Plugins
|
|
511
|
+
|
|
512
|
+
(`packages/host/src/plugins/`, `docs/notes/plugins.md`.) Plugins are code the machine's owner put in
|
|
513
|
+
their own config dir (`~/.config/waterplane/plugins/` or `plugin-paths.json`). They run in the daemon's
|
|
514
|
+
process with its rights: the same trust as the daemon, crossing no boundary it has not. The phone never
|
|
515
|
+
runs plugin code; it draws what a plugin declares. A plugin answers a choice box only through the
|
|
516
|
+
core's answer gate (`answergate.ts`: the manifest's `answers`, a per-session switch that is OFF by
|
|
517
|
+
default and that only the phone flips, the same box it was told about, never a trust, tool or plan box
|
|
518
|
+
or one naming something destructive or secret). It reads files only through the containment guard, and
|
|
519
|
+
when loaded from outside this repo reads only the env names its manifest declares (the in-repo
|
|
520
|
+
extensions under `packages/extensions/` are part of the host and read its own knobs directly). Nothing
|
|
521
|
+
on the network can install, load or reload a plugin.
|
|
522
|
+
|
|
523
|
+
### Peer tokens (paired desktops)
|
|
524
|
+
|
|
525
|
+
Two waterplane hosts can be paired so one delegates a job to the other (`<state dir>/peers.json`, mode
|
|
526
|
+
0600; outbound rows keep the token, inbound rows only its sha256). A peer token is a scoped credential:
|
|
527
|
+
it unlocks `/api/peer/*` and nothing else, and a device token never unlocks those routes. Managing
|
|
528
|
+
pairings (`/api/peers`) is allowed on the trusted path only (loopback and Tailscale, never LAN), the
|
|
529
|
+
same rule as the password.
|
|
530
|
+
|
|
531
|
+
`/api/peer/*` is exempt from the device-token gate in `server/http.ts` because `peers/routes.ts`
|
|
532
|
+
applies a stricter check: trusted path kind, then the peer bearer token, then the peer API version
|
|
533
|
+
header. An unknown token gets "not paired" and the refusal is logged with the remote address. Changing
|
|
534
|
+
the password does NOT rotate peer tokens (a separate ledger; unpair to revoke). Peer addresses are
|
|
535
|
+
tailnet or loopback only (100.64/10, `*.ts.net`, 127.0.0.1), never `*.local`.
|
|
536
|
+
|
|
537
|
+
**Honest scope:** a tailnet client that sends no `Origin` can already drive a host over its WebSocket,
|
|
538
|
+
so pairing gives a peer no power a tailnet process lacks. It makes delegation named (the "from peer"
|
|
539
|
+
mark can be trusted) and refusable, not a new privilege class. A delegated job runs as an ordinary
|
|
540
|
+
backlog row on the peer, held by that owner's auto-fire switch and budget, in a detached worktree
|
|
541
|
+
(`<clone>/.claude/worktrees/peer-<from>-<key>`). It refuses to restart or install the peer's running
|
|
542
|
+
daemon (the marker is checked by `scripts/restart.sh` and `deploy/install-service.sh`). `GET
|
|
543
|
+
/api/peers` lists the peers from loopback or Tailscale only and reports this daemon's own tailnet
|
|
544
|
+
address, never a LAN one.
|
|
545
|
+
|
|
546
|
+
## Installing and updating
|
|
547
|
+
|
|
548
|
+
### The installer
|
|
549
|
+
|
|
550
|
+
`curl -fsSL https://github.com/waterplane-dev/waterplane/releases/latest/download/install.sh | sh`
|
|
551
|
+
(generated from `deploy/install.sh.tmpl`) is code you run as yourself, so read it first if you
|
|
552
|
+
prefer: download it, open it, then `sh install.sh`. It edits no shell rc file and runs at most one
|
|
553
|
+
`sudo` line, the Linux package manager installing `tmux`, and only after asking on the terminal.
|
|
554
|
+
Everything else is unprivileged.
|
|
555
|
+
|
|
556
|
+
- **Checksums.** It verifies the Node runtime against nodejs.org's `SHASUMS256.txt` and the release
|
|
557
|
+
archive against the `.sha256` published beside it, and installs nothing on a mismatch. Those
|
|
558
|
+
checksums arrive over the same HTTPS channel as the files, so on their own they catch only
|
|
559
|
+
corruption.
|
|
560
|
+
- **Signature.** The `.sha256` carries an Ed25519 signature (`.sha256.sig`) made with the project's
|
|
561
|
+
offline release key. The installer verifies it, using the Node it just checked against nodejs.org,
|
|
562
|
+
against the public key written into the script itself (`deploy/release-pubkey.pem`). A missing or
|
|
563
|
+
wrong signature installs nothing. That defeats a compromised release page, GitHub account or mirror
|
|
564
|
+
that swaps the archive and its checksum together. It does not defeat a compromised signing key,
|
|
565
|
+
nor an `install.sh` that was itself swapped (the key travels inside it, so read it or compare it
|
|
566
|
+
with the one in the repository). Rotating the key means a new `install.sh`; copies already
|
|
567
|
+
downloaded trust only the old key. `WATERPLANE_INSECURE_SKIP_SIG=1` skips the check, loudly, for a
|
|
568
|
+
release you built yourself.
|
|
569
|
+
- **Freshness.** The signature proves who published a release, not that it is the newest. Whoever
|
|
570
|
+
can write the release assets can keep offering an OLDER signed release as "latest" (a freeze),
|
|
571
|
+
though never one below the version already installed, which `waterplane update` refuses. A signed
|
|
572
|
+
expiry would close that and needs re-signing on a schedule; until then, a "current" that stays put
|
|
573
|
+
for long is worth a glance at the releases page.
|
|
574
|
+
- **`waterplane update`** holds a release to the same rule with the same key, embedded in the host
|
|
575
|
+
(`host/cli/releasesig.ts`, test-pinned to `deploy/release-pubkey.pem`). It verifies
|
|
576
|
+
`release.json.sig` before it reads `release.json` (which decides the version it fetches) and
|
|
577
|
+
`.sha256.sig` before it trusts the `.sha256`. A missing or wrong signature refuses the update:
|
|
578
|
+
nothing is installed and the current version is untouched. The same
|
|
579
|
+
`WATERPLANE_INSECURE_SKIP_SIG=1` skips it, with a warning on every run. The doctor's self-update
|
|
580
|
+
row believes a newer release only after the same check.
|
|
581
|
+
- **The login service** (`waterplane service install`) bakes in only PATH, HOME, its own state dir
|
|
582
|
+
(and the root and Claude config dir when the shell named them) and the variables passed with
|
|
583
|
+
`--env`, never the installing shell's environment. It refuses `WATERPLANE_ALLOW_PUBLIC` outright:
|
|
584
|
+
a public bind stays an explicit, visible opt-in in `.env`.
|
|
585
|
+
|
|
586
|
+
### `node-pty` is pinned to a pre-release on purpose
|
|
587
|
+
|
|
588
|
+
The stable 1.1.0 leaks two file descriptors into the daemon on every macOS spawn (a spare
|
|
589
|
+
`/dev/ptmx` and the child's slave), which exhausted the machine's pseudo-terminals. The 1.2.0 betas
|
|
590
|
+
fix it, so the dependency is pinned exactly to `1.2.0-beta.15`. The published package's
|
|
591
|
+
`npm-shrinkwrap.json` holds the pin, and `ptyleak.test.mjs` guards the fix. Scanners that flag the
|
|
592
|
+
pre-release, or node-pty's install script (a native build fallback; prebuilt binaries ship in the
|
|
593
|
+
package), are seeing this choice. The pin moves to `1.2.0` once that is released stable.
|