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
- 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-flapconnectedfrom being mis-read as acceptance (a false-positive save) or an unrelatedunauthorizedfrom being mis-attributed. secretOverridecleared on successful hello (not left sticky), sogetSecret()stays the single source of truth after verification; it still survives mid-verify network retries.- Daemon restored on every interactive exit path. A guard around the whole
loginbody queues a plaintrapos_cloud_reconnecton 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 ownconnectedevent safely ignorable. - Add a
cloud.urlguard as the first thing inlogin(login has none today — onlystatusdoes,cloud.lua:67-71), so an unset URL fails fast withset cloud.url firstinstead of a 12s timeout. - Success message is
logged inonly (drop thecloud.password setline); the existing test is updated. - 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. - Scope kept tight: daemon-not-running and
displacedboth 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 localssecretOverride = nilandloginToken = nil. - Use the override in
connect()(libcloud.lua:244): changesecret = getSecret();tosecret = secretOverride or getSecret();. Login rejects empty candidates, sooris safe. Do not clear the override here — it must survivewebsocket_failure → scheduleReconnect → connectretries during the verify window. - Emit the verdict events with the token. In
onHello:- success branch (after
state = 'ready', aroundlibcloud.lua:290):queueEvent('trapos_cloud_connected', traposId, loginToken);then clear bothsecretOverride = nil; loginToken = nil;. - rejection branch (around
libcloud.lua:291-296, after settingstate = 'unauthorized'):queueEvent('trapos_cloud_unauthorized', traposId, lastError, loginToken);.
- success branch (after
- 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 tonil— 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.urlguard first (mirrorstatus):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 usingos.pullEventRawso 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 underpcall; on failure, queue the plain reconnect, then re-error(err, 0). This covers ctrl+t at the re-prompt (outsideverifyPassword) and any unexpected error. -
Interactive flow (
opts.password == nil): loop —readPassword(); blank ⇒ printpassword cannot be emptyand re-prompt; elseverifyPassword(candidate):ok⇒settings.set/save, printlogged in, return.unauthorized⇒ queue a plain reconnect (restore the link while the user types), printinvalid password, try again (ctrl+t to cancel), loop.timeout⇒ queue a plain reconnect, printcloud login failed: could not reach gateway, return.
-
Non-interactive flow (
opts.passwordset): blank ⇒error('cloud login failed: password cannot be empty', 0). ElseverifyPassword:ok⇒ save + printlogged in, return.- else queue a plain reconnect, then
error('cloud login failed: invalid password', 0)(unauthorized) orerror('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): assertonHello(false, …)queuestrapos_cloud_unauthorizedcarrying(traposId, lastError, token); that atrapos_cloud_reconnectcarrying a candidate makes the nextconnect()send that secret in the hello (vs.getSecret()when absent); that the successtrapos_cloud_connectedcarries the token and that a successful hello clears the override (a subsequent network reconnect falls back togetSecret()). Existing assertions (connected[2] == '7:base', etc.) are unaffected by the added token arg.packages/trapos-cloud/tests/cloud-program.lua: the fakeos(lines ~64-68) stubs onlyqueueEvent; extend it to drive events — stubos.pullEventRaw/startTimer/cancelTimer. The fakequeueEventalready records queued events, so it captures the token from thetrapos_cloud_reconnectevent; the scriptedpullEventRawechoes that captured token into the fedconnected/unauthorizedevent (no test-only seam inlogin). Update existing login tests (they assert the immediatecloud.password set+ reconnect, and thecloud.password setline) 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, printslogged in),cloud.urlunset (printsset cloud.url first, no prompt/save).
Verification
- Lua suite:
just test(orjust _craftos-test --pretty) — exercisestests/cloud.luaandtests/cloud-program.luaunder headless CraftOS-PC. - End-to-end:
just e2e(tools/trapos-servergateway + headless CraftOS), then with the daemon running and a server password configured:cloud login --forcewith 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.logshows a reconnect, not a lingeringunauthorized).cloud login <wrong>shoulderrorwith a non-zero exit and not changecloud.password.- A correct password should print
logged inand the daemon log should showconnected.
- Known limitations (by design): with the daemon not running, or when the computer is
displaced,cloud loginreportscould not reach gatewayafter ~12s (nothing saved) rather than a more specific message.