cc-libs/.plans/archived/cloud-login-verify-password-plan-initial.md

125 lines
6.9 KiB
Markdown

# Plan: verify the password in `cloud login`
## Context
Today `cloud login` (`packages/trapos-cloud/programs/cloud.lua:89`) **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` (`apis/libcloud.lua:278`) sets the terminal `unauthorized` state **silently**
no event, so nothing tells the user their password was wrong. The user types a bad password and
still sees "cloud.password set".
We want `cloud login` to confirm the password is accepted by the gateway before reporting success:
- **Interactive** (no password arg → prompted): on a wrong password, re-prompt and let the user
retry; cancel with ctrl+t.
- **Non-interactive** (password passed as an arg): on a wrong password, fail with an error and a
non-zero exit (`error(msg, 0)`).
Confirmed decisions: **verify before commit** (never overwrite a known-good saved password with an
unverified one) and **`error()` non-zero exit** for non-interactive failure.
## Design
The daemon owns the only websocket and shares the os event queue with programs. `cloud login` drives
verification by asking the daemon to reconnect with a *candidate* secret and waiting for the
handshake result. Nothing is persisted until the gateway accepts it.
```
login: candidate = arg or prompt
-> os.queueEvent('trapos_cloud_reconnect', 'login', candidate) # candidate, NOT saved
-> wait: trapos_cloud_connected | trapos_cloud_unauthorized | timeout
connected -> settings.set/save(candidate); print "logged in"; done
unauthorized -> interactive: re-prompt and loop
non-interactive: queue plain reconnect (restore daemon); error()
timeout -> queue plain reconnect (restore daemon); report "could not reach gateway"
```
Interactive/non-interactive is decided exactly as today: `opts.password == nil` ⇒ interactive
(prompted), else non-interactive. No TTY probing.
## Changes
### 1. `packages/trapos-cloud/apis/libcloud.lua` — make verification observable + candidate-based
- **Emit a hello-failure event.** In `onHello`'s rejection branch (around line 291-296), after
setting `state = 'unauthorized'`, add
`queueEvent('trapos_cloud_unauthorized', traposId, lastError);`. Success already emits
`trapos_cloud_connected` (line 290) — leave it.
- **One-shot candidate secret.** Add a local `secretOverride = nil` in `startSession`. In `connect()`
(line 239) change `secret = getSecret();` to `secret = secretOverride or getSecret();`. The
override is non-empty when present (login rejects empty), so `or` is safe.
- **Carry the candidate on reconnect.** Change the `trapos_cloud_reconnect` handler (line 402) to
accept a second arg: `function(reason, candidateSecret) secretOverride = candidateSecret; reconnectNow(reason); end`.
A reconnect with no candidate clears the override (back to `getSecret()`), which is exactly how
login restores the daemon to the previously-saved password after a failed/aborted attempt.
### 2. `packages/trapos-cloud/programs/cloud.lua` — verify in the `login` command
Replace the body of `if command == 'login'` (lines 89-113). Keep the existing `parseLoginArgs`,
`readPassword`, the `cloud.url` guard, and the `--force`/already-set guard. Add a helper that drives
one verification round, using **`os.pullEventRaw`** so ctrl+t is caught and the daemon is restored
before exiting:
```lua
local VERIFY_TIMEOUT = 12; -- comfortably over the daemon helloTimeout (10s)
local function verifyPassword(candidate)
os.queueEvent(RECONNECT_EVENT, 'login', candidate);
local timer = os.startTimer(VERIFY_TIMEOUT);
while true do
local e = table.pack(os.pullEventRaw());
local name = e[1];
if name == 'terminate' then
os.queueEvent(RECONNECT_EVENT); -- restore daemon to saved secret
error('Terminated', 0);
elseif name == 'trapos_cloud_connected' then
os.cancelTimer(timer); return 'ok';
elseif name == 'trapos_cloud_unauthorized' then
os.cancelTimer(timer); return 'unauthorized';
elseif name == 'timer' and e[2] == timer then
return 'timeout';
end
end
end
```
Command flow:
- Interactive (`opts.password == nil`): loop — `readPassword()`; reject blank and re-prompt; else
`verifyPassword`. `ok``settings.set/save`, print `logged in; cloud.password set`, return.
`unauthorized` ⇒ print `invalid password, try again (ctrl+t to cancel)` and loop. `timeout` ⇒ queue
plain `trapos_cloud_reconnect`, print `cloud login failed: could not reach gateway`, return.
- Non-interactive (`opts.password` set): blank ⇒ `error('cloud login failed: password cannot be empty', 0)`.
Else `verifyPassword`. `ok` ⇒ save + print, return. Otherwise queue plain reconnect to restore the
daemon, then `error('cloud login failed: invalid password', 0)` (unauthorized) or
`error('cloud login failed: could not reach gateway', 0)` (timeout).
Note: password is saved **only** on `ok`, so a wrong attempt never clobbers an existing valid
`cloud.password`. If `cloud.url` is unset the existing `set cloud.url first` guard applies; if the
daemon isn't running, verification simply times out into the "could not reach gateway" path (no
new global coupling needed).
### 3. Tests
- `packages/trapos-cloud/tests/cloud.lua` (daemon/api): assert `onHello(false, ...)` queues
`trapos_cloud_unauthorized`, and that a `trapos_cloud_reconnect` carrying a candidate makes the
next `connect()` send that secret in the hello (vs. falling back to `getSecret()` when the
candidate is absent).
- `packages/trapos-cloud/tests/cloud-program.lua`: the harness's fake `os` (lines ~64) stubs only
`queueEvent`; extend it to feed events — add `opts.events` consumed by stubbed
`os.pullEventRaw`/`startTimer`/`cancelTimer`. Update existing login tests (they currently assert
the immediate `cloud.password set` + reconnect) and add: interactive wrong-then-right retry
(password saved only after the accepted attempt), interactive timeout (not saved, plain reconnect
queued), non-interactive wrong password (raises, not saved), non-interactive accepted (saved).
## Verification
- Run the lua suite: `just test` (or `just _craftos-test --pretty` for grouped output) — exercises
`tests/cloud.lua` and `tests/cloud-program.lua` under headless CraftOS-PC.
- End-to-end against the real gateway: `just e2e` (`tools/trapos-server` gateway + headless CraftOS),
then manually: with the daemon running and a server password configured, `cloud login --force`
with a wrong password should re-prompt (interactive) and `cloud login <wrong>` should error with a
non-zero exit; a correct password should print `logged in; cloud.password set` and the daemon log
(`/logs/cloud.log`) should show `connected`.