cc-libs/.plans/cloud-login-verify-password-plan-grilled-by-claude.md
2026-06-17 22:58:13 +02:00

10 KiB

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:
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):

    • oksettings.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.