125 lines
6.9 KiB
Markdown
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`.
|