# 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 ` 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.