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 settingstate = 'unauthorized', addqueueEvent('trapos_cloud_unauthorized', traposId, lastError);. Success already emitstrapos_cloud_connected(line 290) — leave it. - One-shot candidate secret. Add a local
secretOverride = nilinstartSession. Inconnect()(line 239) changesecret = getSecret();tosecret = secretOverride or getSecret();. The override is non-empty when present (login rejects empty), sooris safe. - Carry the candidate on reconnect. Change the
trapos_cloud_reconnecthandler (line 402) to accept a second arg:function(reason, candidateSecret) secretOverride = candidateSecret; reconnectNow(reason); end. A reconnect with no candidate clears the override (back togetSecret()), 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; elseverifyPassword.ok⇒settings.set/save, printlogged in; cloud.password set, return.unauthorized⇒ printinvalid password, try again (ctrl+t to cancel)and loop.timeout⇒ queue plaintrapos_cloud_reconnect, printcloud login failed: could not reach gateway, return. - Non-interactive (
opts.passwordset): blank ⇒error('cloud login failed: password cannot be empty', 0). ElseverifyPassword.ok⇒ save + print, return. Otherwise queue plain reconnect to restore the daemon, thenerror('cloud login failed: invalid password', 0)(unauthorized) orerror('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): assertonHello(false, ...)queuestrapos_cloud_unauthorized, and that atrapos_cloud_reconnectcarrying a candidate makes the nextconnect()send that secret in the hello (vs. falling back togetSecret()when the candidate is absent).packages/trapos-cloud/tests/cloud-program.lua: the harness's fakeos(lines ~64) stubs onlyqueueEvent; extend it to feed events — addopts.eventsconsumed by stubbedos.pullEventRaw/startTimer/cancelTimer. Update existing login tests (they currently assert the immediatecloud.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(orjust _craftos-test --prettyfor grouped output) — exercisestests/cloud.luaandtests/cloud-program.luaunder headless CraftOS-PC. - End-to-end against the real gateway:
just e2e(tools/trapos-servergateway + headless CraftOS), then manually: with the daemon running and a server password configured,cloud login --forcewith a wrong password should re-prompt (interactive) andcloud login <wrong>should error with a non-zero exit; a correct password should printlogged in; cloud.password setand the daemon log (/logs/cloud.log) should showconnected.