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

6.9 KiB

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:

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