Skip to content

feat(device-client): native client with sign-up, sign-in, re-login - #126

Merged
kinqsradio merged 3 commits into
mainfrom
feat/device-client
Nov 11, 2026
Merged

kinqsradio merged 3 commits into
mainfrom
feat/device-client

Conversation

@kinqsradio

@kinqsradio kinqsradio commented Nov 11, 2026 •

Copy link
Copy Markdown
Member

Summary

First of three PRs for orbyn-device-client, the native async client the Engineering app uses to be a device of an Orbyn account. Design note: docs/device-client.md. MIT OR Apache-2.0, no dependency on any app, builds on orbyn-auth-client, orbyn-crypto and orbyn-contracts.

  • Traits the host supplies: DeviceApi (one call; HttpApi is the real one), KeyStore (the OS keychain in the app; MemoryStore for tests) and Clock. DeviceClient<A, S, C> holds them.
  • KeyStore holds only the device keys, the account and device ids with the email, the session token and a vault key (32 random bytes made on the device; the host seals its own snapshot under it). The zone-key cache lives in the host's sealed snapshot, not here. Nothing secret is in a Debug print or an error.
  • Sign-up: OPAQUE on the device, the proof of work solved inside the client (random start, blocking thread, two rounds at most), a PendingSignUp that holds the keys in memory only, and the emailed code with a resend. No request body holds the password or an encoding of it.
  • Sign-in: a new device is pending until approved; a device the store holds signs a trusted start; the second factor is a challenge and a code or recovery code; a wrong password fails on the device and sends nothing more.
  • reauthenticate(password) answers ReauthenticationRequired (engine#120) with the ordinary login as a trusted start from the same device key: no new route, no approval, a recent session; then the caller retries. sign_out(forget) and sign_out_all().
  • Typed errors: ReauthenticationRequired, InvalidCode, RateLimited { retry_after }, DeletionScheduled, AccountGone (a 401 plus POST /v1/accounts/status), SessionExpired, TooLarge, and more; none carries a secret or a URL.
  • Transport: reqwest with rustls and ring only (no OpenSSL, no aws-lc), redirects off, https only except http to a loopback IP literal, 30 s timeout, 32 MiB body cap. is_loopback_literal is public for the app's LocalOnly gate.
  • scripts/local.sh gets invite-code (prints only a fresh invite code) and latest-code <email> (prints only the newest email code), documented in docs/local.md.
  • Review round 1 (02f1075):
    • AccountGone only when POST /v1/accounts/status answers 200 with a deletion time (the app destroys key material on it). A 401 with the account alive, or a status call that fails or answers non-200, is SessionExpired; tests for each, and nothing is wiped.
    • Request and Response print their method, path or status and the body's length only, never the body; their bodies are wiped on drop. A test checks Debug and {:#?} hold none of the body bytes.
    • The second-factor code is written straight into a wiped buffer, the session token is wiped when the answer is dropped, and the response body is capped while reading (also with no Content-Length; with_body_limit lowers it).
    • A re-created account gets a new vault key (it never opens the old snapshot); verify_sign_up re-checks the store before the code is sent, so a sign-in finished in between is not overwritten and the code is not spent; latest-code matches the address at the end of the message.
  • Next PRs: sync (push and pull per zone, notices, status), then approval and the device app label (engine#73).

Closes #

Testing

  • CI covers the rest.
  • tests/client.rs: the client against an in-memory engine that runs the real OPAQUE server side and checks the real signatures (14 tests: invite, proof of work, code, trusted start, second factor, re-login after 11 minutes, account gone against a session ended elsewhere, rate limit, redirects not followed, Retry-After, Debug hygiene, vault key, body limit).
  • tests/local_stack.rs (ignored by default): against the real engine on scripts/local.sh up: invite, sign-up with the code from the worker log, a second pending device, a wrong password, the re-login and sign-out everywhere. Passes.
  • 21 mutations: trusted start missing or signed over the wrong request, proof not sent, re-login with new keys, code not normalised, AccountGone on any status answer or when the status call fails, Retry-After, the reauthentication mapping, forget, the busy-store checks at sign-up, verify and sign-in, vault key kept across accounts, redirects followed, Debug printing the body (response and request), no running body limit. All killed except one equivalent: the early Content-Length check only saves reading, since the running limit stops the same response.
  • Gate: fmt, clippy, structure, cargo test --workspace.
  • Not tested: a real OS keychain (the app's), sync and approval (the next PRs).

Co-authors

none

AI usage

  • Written by hand, with no AI
  • Harness and version: Claude Code 2.1.317
  • Models: claude-sonnet-5-5 (implementation, tests, docs)
  • Effort: medium
  • Human role: agreed the trait and scope with the app owner; reviews the PR at the exact head
  • Sources: none

Checklist

  • Works on Windows, macOS and Linux
  • docs/architecture.md with any rule change; generated wire types with any contract change
  • No copied source, secrets, credentials or personal data; no AI attribution in commits, title or branch

@kinqsradio kinqsradio changed the title feat(device-client): the native client crate with sign-up, sign-in and re-login feat(device-client): native client with sign-up, sign-in, re-login Nov 11, 2026
@kinqsradio
kinqsradio merged commit 5f280c1 into main Nov 11, 2026
17 checks passed
@kinqsradio
kinqsradio deleted the feat/device-client branch November 11, 2026 10:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant