cc-libs/.plans/archived/cloud-login-verify-password-plan-grilled-by-claude.md

164 lines
10 KiB
Markdown

# Plan: verify the password in `cloud login`
## Context
Today `cloud login` (`packages/trapos-cloud/programs/cloud.lua:89-113`) **never verifies** the
password. It writes `cloud.password` to settings, queues `trapos_cloud_reconnect`, prints
`cloud.password set`, and returns. Verification only happens later and asynchronously inside the
daemon: `startSession` sends the secret in the `hello` handshake and the server
(`tools/trapos-server/src/auth.ts` `resolveAccount`) rejects a wrong secret. On rejection the
daemon's `onHello` (`libcloud.lua:278-297`) silently sets the terminal `unauthorized` state — no
event — so the user types a bad password and still sees "cloud.password set".
We want `cloud login` to confirm the gateway **accepts** the password before reporting success, and
to never overwrite a known-good saved password with an unverified one:
- **Interactive** (no password arg → prompted): on a wrong password, re-prompt and retry; cancel
with ctrl+t.
- **Non-interactive** (password passed as an arg): on a wrong password, fail with `error(msg, 0)`
(non-zero exit).
The daemon owns the only websocket and shares the OS event queue with programs (`libcloud.lua:6-8`).
`cloud login` drives verification by asking the daemon to reconnect with a *candidate* secret and
waiting for the handshake verdict; nothing is persisted until the gateway accepts it. The same
cooperative-coroutine pattern `api.request` (`libcloud.lua:416`) already uses applies here: login
yields in `pullEventRaw`, which lets the daemon coroutine advance.
## Confirmed design decisions
1. **Correlation token.** Login generates a token, passes it as a 3rd arg on `trapos_cloud_reconnect`;
the daemon echoes it on the verdict events; login **ignores any verdict whose token doesn't
match**. This prevents a coincidental boot/network-flap `connected` from being mis-read as
acceptance (a false-positive save) or an unrelated `unauthorized` from being mis-attributed.
2. **`secretOverride` cleared on successful hello** (not left sticky), so `getSecret()` stays the
single source of truth after verification; it still survives mid-verify network retries.
3. **Daemon restored on every interactive exit path.** A guard around the whole `login` body queues a
plain `trapos_cloud_reconnect` on any exit (terminate, error, failure), **plus** a restore between
rounds so the link stays up while the user types the next attempt. The token from (1) makes the
restore's own `connected` event safely ignorable.
4. **Add a `cloud.url` guard** as the first thing in `login` (login has none today — only `status`
does, `cloud.lua:67-71`), so an unset URL fails fast with `set cloud.url first` instead of a 12s
timeout.
5. **Success message is `logged in` only** (drop the `cloud.password set` line); the existing test is
updated.
6. **Verify timeout is a snappy single-attempt ~12s**, hardcoded with a comment noting the coupling
to the daemon `helloTimeout` (10s). Slower/retry paths surface as "could not reach gateway" with
nothing saved.
7. **Scope kept tight:** daemon-not-running and `displaced` both surface as the ~12s "could not reach
gateway" timeout (nothing saved). No liveness flag, no pre-verify probe, no displaced-specific
handling — noted as known limitations.
## Changes
### 1. `packages/trapos-cloud/apis/libcloud.lua` — observable, candidate- and token-based verification
- **Candidate secret + login token state.** In `startSession`, add locals `secretOverride = nil` and
`loginToken = nil`.
- **Use the override in `connect()`** (`libcloud.lua:244`): change `secret = getSecret();` to
`secret = secretOverride or getSecret();`. Login rejects empty candidates, so `or` is safe. Do
**not** clear the override here — it must survive `websocket_failure → scheduleReconnect → connect`
retries during the verify window.
- **Emit the verdict events with the token.** In `onHello`:
- success branch (after `state = 'ready'`, around `libcloud.lua:290`):
`queueEvent('trapos_cloud_connected', traposId, loginToken);` then **clear both**
`secretOverride = nil; loginToken = nil;`.
- rejection branch (around `libcloud.lua:291-296`, after setting `state = 'unauthorized'`):
`queueEvent('trapos_cloud_unauthorized', traposId, lastError, loginToken);`.
- **Carry candidate + token on reconnect** (`libcloud.lua:402`):
`el.register('trapos_cloud_reconnect', function(reason, candidateSecret, token)
secretOverride = candidateSecret; loginToken = token; reconnectNow(reason); end)`. The handler
**always assigns both from its args**, so a plain reconnect (no args) clears both to `nil`
exactly how login restores the daemon to the saved password.
Note the verdict-event shapes (login matches the token per-branch): `trapos_cloud_connected` =
`(name, traposId, token)`; `trapos_cloud_unauthorized` = `(name, traposId, lastError, token)`.
### 2. `packages/trapos-cloud/programs/cloud.lua` — verify in the `login` command
Replace the body of `if command == 'login'` (`cloud.lua:89-113`). Keep `parseLoginArgs`,
`readPassword`, and the `--force`/already-set guard. Additions:
- **`cloud.url` guard first** (mirror `status`): `if isBlank(settings.get(URL_SETTING)) then
print('set cloud.url first'); return; end` — before the already-set guard and any prompt.
- **Constant:** `local VERIFY_TIMEOUT = 12; -- one hello attempt; daemon helloTimeout is 10s`.
- **`verifyPassword(candidate)` helper** — drives one verification round using `os.pullEventRaw` so
ctrl+t is caught, generates a fresh token, and **only acts on a verdict whose token matches**:
```lua
local function verifyPassword(candidate)
local token = createCloud().uuid(); -- correlation token for this round
os.queueEvent(RECONNECT_EVENT, 'login', candidate, token);
local timer = os.startTimer(VERIFY_TIMEOUT);
while true do
local e = table.pack(os.pullEventRaw());
local name = e[1];
if name == 'terminate' then
error('Terminated', 0); -- guard (below) restores the daemon
elseif name == 'trapos_cloud_connected' and e[3] == token then
os.cancelTimer(timer); return 'ok';
elseif name == 'trapos_cloud_unauthorized' and e[4] == token then
os.cancelTimer(timer); return 'unauthorized';
elseif name == 'timer' and e[2] == timer then
return 'timeout';
end
-- any other event (incl. mismatched-token verdicts from a restore) is ignored
end
end
```
- **Restore guard.** Wrap the verification/command flow so that **any** exit queues a plain
`os.queueEvent(RECONNECT_EVENT)` to restore the daemon to its saved secret before propagating —
e.g. run the flow under `pcall`; on failure, queue the plain reconnect, then re-`error(err, 0)`.
This covers ctrl+t at the re-prompt (outside `verifyPassword`) and any unexpected error.
- **Interactive flow** (`opts.password == nil`): loop — `readPassword()`; blank ⇒ print
`password cannot be empty` and re-prompt; else `verifyPassword(candidate)`:
- `ok``settings.set/save`, print `logged in`, return.
- `unauthorized` ⇒ queue a plain reconnect (restore the link while the user types), print
`invalid password, try again (ctrl+t to cancel)`, loop.
- `timeout` ⇒ queue a plain reconnect, print `cloud login failed: could not reach gateway`, return.
- **Non-interactive flow** (`opts.password` set): blank ⇒
`error('cloud login failed: password cannot be empty', 0)`. Else `verifyPassword`:
- `ok` ⇒ save + print `logged in`, return.
- else queue a plain reconnect, then `error('cloud login failed: invalid password', 0)`
(unauthorized) or `error('cloud login failed: could not reach gateway', 0)` (timeout).
Password is saved **only** on `ok`, so a wrong/aborted attempt never clobbers an existing valid
`cloud.password`.
### 3. Tests
- **`packages/trapos-cloud/tests/cloud.lua`** (daemon/api): assert `onHello(false, …)` queues
`trapos_cloud_unauthorized` carrying `(traposId, lastError, token)`; that a
`trapos_cloud_reconnect` carrying a candidate makes the next `connect()` send that secret in the
hello (vs. `getSecret()` when absent); that the success `trapos_cloud_connected` carries the token
and that a successful hello clears the override (a subsequent network reconnect falls back to
`getSecret()`). Existing assertions (`connected[2] == '7:base'`, etc.) are unaffected by the added
token arg.
- **`packages/trapos-cloud/tests/cloud-program.lua`**: the fake `os` (lines ~64-68) stubs only
`queueEvent`; extend it to drive events — stub `os.pullEventRaw`/`startTimer`/`cancelTimer`. The
fake `queueEvent` already records queued events, so it **captures the token** from the
`trapos_cloud_reconnect` event; the scripted `pullEventRaw` **echoes that captured token** into the
fed `connected`/`unauthorized` event (no test-only seam in `login`). Update existing login tests
(they assert the immediate `cloud.password set` + reconnect, and the `cloud.password set` line) and
add: interactive wrong-then-right retry (saved only after the accepted attempt; plain reconnect
queued between rounds), interactive timeout (not saved, plain reconnect queued), non-interactive
wrong password (raises, not saved), non-interactive accepted (saved, prints `logged in`),
`cloud.url` unset (prints `set cloud.url first`, no prompt/save).
## Verification
- Lua suite: `just test` (or `just _craftos-test --pretty`) — exercises `tests/cloud.lua` and
`tests/cloud-program.lua` under headless CraftOS-PC.
- End-to-end: `just e2e` (`tools/trapos-server` gateway + headless CraftOS), then with the daemon
running and a server password configured:
- `cloud login --force` with a wrong password should re-prompt (interactive), and ctrl+t at the
prompt should leave the daemon reconnected to the saved secret (check `/logs/cloud.log` shows a
reconnect, not a lingering `unauthorized`).
- `cloud login <wrong>` should `error` with a non-zero exit and not change `cloud.password`.
- A correct password should print `logged in` and the daemon log should show `connected`.
- **Known limitations** (by design): with the daemon not running, or when the computer is
`displaced`, `cloud login` reports `could not reach gateway` after ~12s (nothing saved) rather than
a more specific message.