164 lines
10 KiB
Markdown
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.
|