461 lines
18 KiB
Markdown
461 lines
18 KiB
Markdown
# Plan: verify the password in `cloud login` (grilled by GPT)
|
|
|
|
## Context
|
|
|
|
Today `cloud login` (`packages/trapos-cloud/programs/cloud.lua:89-113`) does not verify 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 gateway rejects a wrong secret. On rejection, the daemon's
|
|
`onHello` sets terminal `unauthorized` state without a user-visible event, so the user can type a bad
|
|
password and still see success-looking output.
|
|
|
|
The desired behavior is:
|
|
|
|
- Interactive login (`cloud login`, no password arg) verifies the candidate password before saving it.
|
|
- Interactive wrong passwords re-prompt; ctrl+t cancels and restores the daemon to persisted settings.
|
|
- Non-interactive login (`cloud login <password>`) verifies before saving, and wrong passwords fail with
|
|
`error(msg, 0)` for a non-zero exit.
|
|
- A wrong, aborted, timed-out, or unsaved candidate never overwrites a previously valid saved password.
|
|
|
|
The daemon owns the only websocket and shares the OS event queue with programs. `cloud login` should not
|
|
open its own websocket. Instead, it asks the daemon to reconnect with a candidate secret and waits for the
|
|
candidate hello verdict.
|
|
|
|
## Confirmed Decisions
|
|
|
|
- Verify before commit: save `cloud.password` only after the gateway accepts the candidate.
|
|
- Use `error(msg, 0)` for non-interactive failures and storage failures.
|
|
- Use a per-attempt correlation token so login ignores unrelated `trapos_cloud_connected` and
|
|
`trapos_cloud_unauthorized` events.
|
|
- Use a local `/apis/eventloop` instance inside `cloud login` for the wait, not a hand-written
|
|
`os.pullEventRaw()` loop.
|
|
- Keep `trapos_cloud_connected` as the success event, with token as an optional trailing argument.
|
|
- Emit `trapos_cloud_unauthorized` for every rejected hello, not only login verification attempts.
|
|
- Keep candidate override active until an explicit plain reconnect clears it, so transient websocket
|
|
retries during the verify window still use the candidate.
|
|
- After successful verification and save, queue a plain reconnect with reason `login-saved` so the daemon
|
|
clears candidate state and resumes reading persisted settings.
|
|
- After failed, aborted, timed-out, unauthorized, or save-failed verification, queue a plain reconnect
|
|
with reason `login-restore`.
|
|
- Use reconnect reason `login-verify` for candidate verification.
|
|
- Do not log verification tokens; log only the reconnect reason.
|
|
- Do not trim, lowercase, normalize, or otherwise transform passwords. Reject only the exact empty
|
|
string `''`.
|
|
- Add a `cloud.url` guard to `cloud login` and fail fast with `set cloud.url first` when verification
|
|
cannot be started.
|
|
- Keep the existing already-set guard unless `--force` is passed.
|
|
- Keep `lastError()` returning a string code; structured unauthorized details are only for the event.
|
|
- Do not add a concurrent-login lock.
|
|
- Treat daemon absence and unresolved displaced state as timeout/`could not reach gateway`.
|
|
- Bump `trapos-cloud` from `0.4.2` to `0.4.3` in both package metadata locations.
|
|
|
|
## Non-Goals
|
|
|
|
- Do not add a standalone Lua test harness.
|
|
- Do not add daemon liveness/status APIs for login.
|
|
- Do not open a program-owned websocket for verification.
|
|
- Do not trim or normalize passwords.
|
|
- Do not add a concurrent-login lock.
|
|
- Do not change `lastError()` to return structured errors.
|
|
- Do not broaden this into general cloud daemon state-management UX.
|
|
|
|
## Event Contract
|
|
|
|
Verification reconnect:
|
|
|
|
```lua
|
|
os.queueEvent('trapos_cloud_reconnect', 'login-verify', candidateSecret, token);
|
|
```
|
|
|
|
Successful verified hello:
|
|
|
|
```lua
|
|
queueEvent('trapos_cloud_connected', traposId, token);
|
|
```
|
|
|
|
Unauthorized hello:
|
|
|
|
```lua
|
|
queueEvent('trapos_cloud_unauthorized', traposId, err, token);
|
|
```
|
|
|
|
Restore after failed/aborted verification:
|
|
|
|
```lua
|
|
os.queueEvent('trapos_cloud_reconnect', 'login-restore');
|
|
```
|
|
|
|
Cleanup after successful save:
|
|
|
|
```lua
|
|
os.queueEvent('trapos_cloud_reconnect', 'login-saved');
|
|
```
|
|
|
|
Rules:
|
|
|
|
- `candidateSecret == nil` or `candidateSecret == ''` means plain reconnect: clear candidate and token.
|
|
- Candidate absence wins; a token without a candidate is ignored by clearing both candidate and token.
|
|
- `cloud login` rejects empty candidates before queueing verification.
|
|
- Ordinary daemon reconnects may emit tokenless `trapos_cloud_connected` or `trapos_cloud_unauthorized`.
|
|
- Login only treats events with the exact current token as authoritative.
|
|
- Login drops unrelated or tokenless verdict events it consumes; it does not re-queue them.
|
|
|
|
## Daemon Changes
|
|
|
|
File: `packages/trapos-cloud/apis/libcloud.lua`
|
|
|
|
### Candidate And Token State
|
|
|
|
Inside `api.startSession(opts)`, add candidate state:
|
|
|
|
```lua
|
|
local secretOverride = nil;
|
|
local verifyToken = nil;
|
|
local activeSecret = nil;
|
|
local activeVerifyToken = nil;
|
|
```
|
|
|
|
In `connect()`, capture the candidate state for that physical connection attempt:
|
|
|
|
```lua
|
|
activeSecret = secretOverride or getSecret();
|
|
activeVerifyToken = verifyToken;
|
|
```
|
|
|
|
Then send hello with `activeSecret`, not the mutable outer `secret` value.
|
|
|
|
Reason: reconnect events can supersede earlier attempts. The secret/token used for a hello result must be
|
|
the secret/token captured for the active attempt, not whatever mutable candidate state happens to contain
|
|
when `onHello` runs.
|
|
|
|
### Reconnect Handler
|
|
|
|
Change the `trapos_cloud_reconnect` handler to accept candidate and token:
|
|
|
|
```lua
|
|
el.register('trapos_cloud_reconnect', function(reason, candidateSecret, token)
|
|
if type(candidateSecret) == 'string' and candidateSecret ~= '' then
|
|
secretOverride = candidateSecret;
|
|
verifyToken = token;
|
|
else
|
|
secretOverride = nil;
|
|
verifyToken = nil;
|
|
end
|
|
reconnectNow(reason);
|
|
end);
|
|
```
|
|
|
|
This makes `login-restore`, `login-saved`, and any manual/plain reconnect clear login verification state.
|
|
|
|
### Hello Verdict Events
|
|
|
|
In `onHello`, keep `lastError()` as a string code, but emit observable events.
|
|
|
|
On success:
|
|
|
|
```lua
|
|
queueEvent('trapos_cloud_connected', traposId, activeVerifyToken);
|
|
```
|
|
|
|
On rejection, normalize the gateway error table for the event:
|
|
|
|
```lua
|
|
local errBody = err;
|
|
if type(errBody) ~= 'table' then
|
|
errBody = { code = 'unauthorized', message = 'unauthorized' };
|
|
end
|
|
lastError = errBody.code or 'unauthorized';
|
|
queueEvent('trapos_cloud_unauthorized', traposId, errBody, activeVerifyToken);
|
|
```
|
|
|
|
Do not log the token. Existing reconnect logs should become more useful through reasons such as
|
|
`login-verify`, `login-restore`, and `login-saved`.
|
|
|
|
### Stale Socket Investigation
|
|
|
|
Before finalizing stale-message behavior, probe or test whether the runtime's `websocket_message` event
|
|
includes a websocket handle in addition to `(url, content)`.
|
|
|
|
If a handle is available, only accept messages for the current `activeWs` attempt. If the runtime only
|
|
provides `(url, content)`, document the limitation and rely on the existing URL/state guards plus
|
|
per-attempt token capture.
|
|
|
|
## Program Changes
|
|
|
|
File: `packages/trapos-cloud/programs/cloud.lua`
|
|
|
|
### Setup
|
|
|
|
Add:
|
|
|
|
```lua
|
|
local createEventLoop = require('/apis/eventloop');
|
|
local VERIFY_TIMEOUT = 12; -- daemon helloTimeout is 10s
|
|
```
|
|
|
|
Instantiate the cloud API once:
|
|
|
|
```lua
|
|
local cloudApi = createCloud();
|
|
```
|
|
|
|
Use `cloudApi.request(...)` for `status` and `cloudApi.uuid()` for login verification tokens.
|
|
|
|
### Guard Order
|
|
|
|
In `login`, keep parse behavior and the existing force semantics. Use this order:
|
|
|
|
```lua
|
|
local opts, parseError = parseLoginArgs(rawArgs);
|
|
if parseError then
|
|
print(parseError);
|
|
printUsage();
|
|
return;
|
|
end
|
|
|
|
if not opts.force and not isBlank(settings.get(PASSWORD_SETTING)) then
|
|
print('cloud.password is already set; use cloud login --force to replace it');
|
|
return;
|
|
end
|
|
|
|
if isBlank(settings.get(URL_SETTING)) then
|
|
print('set cloud.url first');
|
|
return;
|
|
end
|
|
```
|
|
|
|
Reason: replacement intent is validated before requiring gateway configuration. If replacement is allowed
|
|
or no password exists, verification requires `cloud.url` and fails fast when absent.
|
|
|
|
### Verification Helper
|
|
|
|
Use a local eventloop per candidate attempt:
|
|
|
|
```lua
|
|
local function verifyPassword(candidate)
|
|
local token = cloudApi.uuid();
|
|
local result = nil;
|
|
local el = createEventLoop();
|
|
|
|
local function finish(value)
|
|
if result ~= nil then return; end
|
|
result = value;
|
|
el.stopLoop();
|
|
end
|
|
|
|
el.register('trapos_cloud_connected', function(_traposId, eventToken)
|
|
if eventToken == token then finish('ok'); end
|
|
end);
|
|
|
|
el.register('trapos_cloud_unauthorized', function(_traposId, _err, eventToken)
|
|
if eventToken == token then finish('unauthorized'); end
|
|
end);
|
|
|
|
el.register('terminate', function()
|
|
result = 'terminate';
|
|
end);
|
|
|
|
el.setTimeout(function()
|
|
finish('timeout');
|
|
end, VERIFY_TIMEOUT);
|
|
|
|
os.queueEvent(RECONNECT_EVENT, 'login-verify', candidate, token);
|
|
el.runLoop();
|
|
|
|
return result;
|
|
end
|
|
```
|
|
|
|
Important details:
|
|
|
|
- Create a new eventloop for each retry attempt.
|
|
- Use default `el.runLoop()`, not `runLoop(true)`, so `terminate` stops the loop.
|
|
- The terminate handler only records `result = 'terminate'`; command-level flow restores and throws.
|
|
- `finish()` is idempotent because `stopLoop()` queues an internal stop event rather than interrupting
|
|
the current dispatch immediately.
|
|
- Rely on `eventloop.runLoop()` cleanup for local timers and handlers.
|
|
|
|
### Save Helper
|
|
|
|
Add a helper or inline flow that saves only after verified `ok`, and rolls back live settings if saving
|
|
fails:
|
|
|
|
```lua
|
|
local oldPassword = settings.get(PASSWORD_SETTING);
|
|
local hadPassword = oldPassword ~= nil;
|
|
|
|
settings.set(PASSWORD_SETTING, password);
|
|
if not settings.save() then
|
|
if hadPassword then
|
|
settings.set(PASSWORD_SETTING, oldPassword);
|
|
else
|
|
settings.unset(PASSWORD_SETTING);
|
|
end
|
|
os.queueEvent(RECONNECT_EVENT, 'login-restore');
|
|
error('cloud login failed: could not save settings', 0);
|
|
end
|
|
|
|
os.queueEvent(RECONNECT_EVENT, 'login-saved');
|
|
print('logged in; cloud.password set');
|
|
```
|
|
|
|
Reason: the daemon reads live `settings.get(PASSWORD_SETTING)`, not just persisted files. If save fails,
|
|
the in-memory setting must be restored before `login-restore` reconnects.
|
|
|
|
### Interactive Flow
|
|
|
|
Interactive is exactly `opts.password == nil`; do not add TTY probing.
|
|
|
|
Loop behavior:
|
|
|
|
- Prompt with existing masked `readPassword()`.
|
|
- If input is exactly `''`, print a password-empty message and re-prompt.
|
|
- Do not trim or normalize the input.
|
|
- Run `verifyPassword(candidate)`.
|
|
- On `ok`, save, queue `login-saved`, print `logged in; cloud.password set`, and return.
|
|
- On `unauthorized`, queue `login-restore`, print `invalid password, try again (ctrl+t to cancel)`,
|
|
and immediately re-prompt without waiting for restore completion.
|
|
- On `timeout`, queue `login-restore`, print `cloud login failed: could not reach gateway`, and return.
|
|
- On `terminate`, queue `login-restore`, then `error('Terminated', 0)`.
|
|
|
|
### Non-Interactive Flow
|
|
|
|
Non-interactive is exactly `opts.password ~= nil`.
|
|
|
|
Behavior:
|
|
|
|
- If password is exactly `''`, `error('cloud login failed: password cannot be empty', 0)`.
|
|
- Do not trim or normalize the argument.
|
|
- Run `verifyPassword(password)`.
|
|
- On `ok`, save, queue `login-saved`, print `logged in; cloud.password set`, and return.
|
|
- On `unauthorized`, queue `login-restore`, then `error('cloud login failed: invalid password', 0)`.
|
|
- On `timeout`, queue `login-restore`, then `error('cloud login failed: could not reach gateway', 0)`.
|
|
- On `terminate`, queue `login-restore`, then `error('Terminated', 0)`.
|
|
|
|
Do not print before non-interactive `error(...)`; the error text is the output.
|
|
|
|
## Tests
|
|
|
|
### Daemon/API Tests
|
|
|
|
File: `packages/trapos-cloud/tests/cloud.lua`
|
|
|
|
Add or update tests to cover:
|
|
|
|
- Unauthorized hello queues `trapos_cloud_unauthorized` with `(traposId, normalizedErrTable, token)`.
|
|
- `lastError()` remains the string code, for example `'unauthorized'`.
|
|
- Candidate reconnect sends the candidate secret in the next hello.
|
|
- Plain reconnect clears candidate and token and falls back to `getSecret()`.
|
|
- Successful hello queues `trapos_cloud_connected` with `(traposId, token)`.
|
|
- Token without candidate is ignored/cleared by reconnect handler.
|
|
- Candidate override survives websocket failure/retry until a plain reconnect clears it.
|
|
- Existing network drop, displacement, send, request, serve, and respond tests remain valid.
|
|
|
|
If websocket-message identity is available in the runtime, add a stale-socket test proving old socket
|
|
messages cannot drive the current attempt. If identity is not available, document the limitation in test
|
|
comments or plan follow-up notes.
|
|
|
|
### Program Tests
|
|
|
|
File: `packages/trapos-cloud/tests/cloud-program.lua`
|
|
|
|
Update the harness:
|
|
|
|
- Intercept `require('/apis/eventloop')` and provide a fake eventloop with `register`, `setTimeout`,
|
|
`runLoop`, and `stopLoop`.
|
|
- Script verification events through `opts.events` or equivalent.
|
|
- Let fake `runLoop()` consume scripted events and dispatch registered handlers until stopped.
|
|
- Capture the token from queued `trapos_cloud_reconnect` verification events and use it in scripted
|
|
matching `trapos_cloud_connected` / `trapos_cloud_unauthorized` events.
|
|
- Support non-matching/tokenless verdict events so tests can prove login ignores them.
|
|
- Extend fake settings with `unset()`.
|
|
- Add save-failure injection, for example `saveOk = false`.
|
|
- Change `runCloud()` to return `ok` and `err` instead of rethrowing expected program errors.
|
|
- Update successful tests to assert `ctx.ok == true`.
|
|
|
|
Update existing login tests because old behavior saved immediately and printed `cloud.password set`.
|
|
|
|
Add/cover these scenarios:
|
|
|
|
- `cloud login` with existing password and no `--force` refuses replacement before checking `cloud.url`.
|
|
- `cloud login` with missing `cloud.url` prints `set cloud.url first`, does not prompt, does not save,
|
|
and does not reconnect.
|
|
- Interactive accepted password saves only after matching `trapos_cloud_connected` token, queues
|
|
`login-verify` then `login-saved`, and prints `logged in; cloud.password set`.
|
|
- Non-interactive accepted password does not prompt, saves after matching connected token, queues
|
|
`login-verify` then `login-saved`, and succeeds.
|
|
- Interactive wrong-then-right flow queues `login-restore` after unauthorized, re-prompts immediately,
|
|
saves only the accepted second password, and never saves the rejected one.
|
|
- Interactive blank password re-prompts and does not queue verification for the blank value.
|
|
- Non-interactive blank password errors with `cloud login failed: password cannot be empty`, does not
|
|
queue restore, and does not save.
|
|
- Interactive timeout queues `login-restore`, prints `cloud login failed: could not reach gateway`, and
|
|
does not save.
|
|
- Non-interactive unauthorized queues `login-restore`, errors with `cloud login failed: invalid password`,
|
|
and does not save.
|
|
- Non-interactive timeout queues `login-restore`, errors with `cloud login failed: could not reach gateway`,
|
|
and does not save.
|
|
- Interactive terminate during verification queues `login-restore`, errors with `Terminated`, and leaves
|
|
settings unchanged.
|
|
- Matching-token events complete verification; non-matching and tokenless verdict events are ignored until
|
|
matching result or timeout.
|
|
- Save failure after verified success with an old password restores the old in-memory password, queues
|
|
`login-restore`, errors with `cloud login failed: could not save settings`, and does not queue
|
|
`login-saved`.
|
|
- Save failure after verified success with no old password calls `settings.unset(PASSWORD_SETTING)`, queues
|
|
`login-restore`, errors with `cloud login failed: could not save settings`, and does not queue
|
|
`login-saved`.
|
|
|
|
## Package Version
|
|
|
|
Update:
|
|
|
|
- `packages/trapos-cloud/ccpm.json`: `0.4.2` -> `0.4.3`
|
|
- `packages/index.json`: `trapos-cloud` `0.4.2` -> `0.4.3`
|
|
|
|
Reason: this changes runtime behavior of `trapos-cloud` APIs and programs, and `cloud --version` reports
|
|
the package version through `libversion`.
|
|
|
|
## Implementation Order
|
|
|
|
1. Update `packages/trapos-cloud/apis/libcloud.lua` with candidate/token reconnect contract and events.
|
|
2. Update `packages/trapos-cloud/tests/cloud.lua` for daemon/API behavior.
|
|
3. Update `packages/trapos-cloud/programs/cloud.lua` with local eventloop verification and save rollback.
|
|
4. Update `packages/trapos-cloud/tests/cloud-program.lua` harness and CLI tests.
|
|
5. Update package versions in `packages/trapos-cloud/ccpm.json` and `packages/index.json`.
|
|
6. Run verification and fix issues.
|
|
|
|
## Verification
|
|
|
|
Because this change edits Lua and this Markdown plan, run:
|
|
|
|
```sh
|
|
just check
|
|
just test
|
|
```
|
|
|
|
`just check` covers Lua lint and offline Markdown link validation. `just test` runs the CraftOS-PC test
|
|
suite, including `tests/cloud.lua` and `tests/cloud-program.lua`.
|
|
|
|
Optional end-to-end verification against the real gateway:
|
|
|
|
```sh
|
|
just e2e
|
|
```
|
|
|
|
Manual behavior to confirm with the daemon running and server password configured:
|
|
|
|
- `cloud login --force` with a wrong password re-prompts and does not save.
|
|
- Ctrl+t during interactive verification restores daemon state and exits with `Terminated`.
|
|
- `cloud login <wrong>` errors non-zero with `cloud login failed: invalid password` and does not save.
|
|
- A correct password prints `logged in; cloud.password set` and `/logs/cloud.log` shows reconnect reasons
|
|
around `login-verify` and `login-saved`, without logging verification tokens.
|
|
|
|
## Known Limitations
|
|
|
|
- If the daemon is not running, login times out with `cloud login failed: could not reach gateway`.
|
|
- If the computer is displaced by another registration and cannot recover during verification, login times
|
|
out with `cloud login failed: could not reach gateway`.
|
|
- Unless the runtime exposes websocket identity in message events, stale websocket messages can only be
|
|
mitigated by URL/state guards and per-attempt token capture, not by direct socket-handle comparison.
|