The console signs in the browser (VS-5b)
Status: built. The operator console has no login. A browser makes a key it cannot export, a vaam admin phone (or, as the break-glass, an operator on the command line) vouches for it, and the key signs every request the console makes. The console's OpenID, session and single-sign-on plumbing is deleted, with no compatibility mode, and since VS-4 (2026-10-27) so is the gateway's side of it: the OpenID Provider, /login, the Keycloak broker and the password store no longer exist (docs/architecture.md §4.3).
This is the console half of the signed transport (docs/architecture.md §4.0, docs/integrating.md). The gateway half is VS-5a (ConsoleKey, registerConsoleKey, pair-console-key, backends/crates/sms-api/src/console_keys.md) and VS-3 (the envelope that verifies it).
The rulings this implements
- "NEVER we should use access or refresh tokens. … We'll use ONLY keypair for signing." and "vpay and vsms too — keypairs everywhere." (2026-09-29).
- "Admin rights only via admin phone pairing" (2026-10-09).
- Q1 (2026-10-26): phone pairing only, so Keycloak single sign-on and password/TOTP are gone from the console. Q2: the break-glass is command-line key enrolment. Q5: the composer keeps its own Ed25519 service key, registered as an
AppClientkey. - R2: a paired console user is
admin, neverowner. R3: a console key lasts 30 days. R4: the console stays optional; the gateway never depends on it. - No dropdown menus and no dialogs in anything new here (vaam's rule, which binds this product's interfaces too): the console's confirmations are inline, its choices are buttons.
What runs where
flowchart LR
subgraph Browser
K[("IndexedDB<br/>non-extractable P-256 key")]
W["wasm sealer<br/>(vsms-sdk-wasm, --target web)"]
R["tRPC router + gateway data layer"]
end
subgraph "Console server (Next.js)"
P["/api/gateway/*<br/>pass-through of sealed bytes"]
C["/api/compose/*<br/>composer"]
end
G["sms-gateway<br/>(EnvelopeLayer, Required)"]
R -->|"GET /apps?limit=500"| W
W -->|"sign with"| K
W -->|"COSE_Sign1 bytes"| P
P -->|"same bytes, mTLS"| G
G -->|"sealed answer"| P
P --> W
W -->|"verified JSON"| R
R -->|"proof + message"| C
C -->|"proof, forwarded"| G
C -->|"service key, sealed"| GEverything left of the console server runs in the page. That is the change: the screens' data layer (@vsms/gateway) and the tRPC router (@vsms/api) used to run in the console's server, which held a credential and called the gateway on the operator's behalf. A server cannot sign with a key that lives in someone's browser, so the code that decides what to call moved to where the key is. The tRPC wire protocol is unchanged (batching, superjson, the error formatter that carries a validation error's per-field detail): @vsms/hooks serves it from tRPC's own fetch adapter on an in-process fetch (createLocalFetch), so no route handler stands between a screen and the data layer.
The console's server holds no credential for a person and no session. It carries sealed bytes (app/api/gateway) and spends one service key, the composer's (app/api/compose). middleware.ts, lib/{oidc,session,sso,sso-discovery}.ts, app/api/auth/*, app/login/* and app/api/trpc are deleted; ADMIN_BASE_URL, SMS_AUTH_ISSUER, SMS_CONSOLE_CLIENT_ID, SMS_CONSOLE_PRIVATE_KEY_PATH, SMS_CONSOLE_SCOPE, SMS_CONSOLE_OIDC_CLIENT_ID and SMS_CONSOLE_SESSION_SECRET are gone from the environment schema, turbo.jsonc, both .env.example files, the compose stacks and the Helm chart.
The browser key
lib/device-key.ts. crypto.subtle.generateKey({name: "ECDSA", namedCurve: "P-256"}, false, ["sign", "verify"]) makes the pair; extractable: false makes the private half unexportable in every format (a test asks for pkcs8 and jwk and is refused, on the key as generated and on the copy that comes back out of IndexedDB). The CryptoKey object itself is stored in IndexedDB by structured clone, which is the only way a non-extractable key is stored at all. No key material is ever a string, a cookie or a localStorage value.
The record beside the key remembers when the gateway first accepted it and when it expires, because the gateway cannot tell a key it never knew from one it has finished with (both are its unsigned 401). Only the browser can say "this console was paired", which is what lets it ask to pair again instead of waiting.
The pairing QR
lib/pairing.ts. vaam's admin-pairing format (backend/src/procedures/admin_pairing.rs), so one scanner reads both:
{"v":1,"k":"<65-byte uncompressed P-256 point, base64url>","t":1700000000,"l":"Chrome on macOS","s":"<r‖s, base64url>"}s is the key's ECDSA-P-256-SHA-256 signature over the UTF-8 bytes "vsms.console-pair.v1\n" + k + "\n" + t, exactly what registerConsoleKey verifies (console_keys::possession_message). A test verifies the QR the way the gateway does, with Node's crypto from the public point alone, and also shows that the signature does not stand for vaam's own vaam.admin-pair.v1 message, that a changed t or k fails, and that five minutes either side of t is the window.
Two things to know:
- The label is not signed. vsms's message has no slot for it (vaam's own has).
lis the browser's unauthenticated claim; the phone must show it as one. - The QR does not say which service it is for. A vsms console QR and a vaam admin-browser QR have the same shape and differ only in the tag under
s. Scanned by the wrong flow, the proof fails closed. VA-4 has to decide how its scanner routes (a distinct field, a distinct entry, or trying both); this side added no field because the format was to match vaam's exactly.
How pairing completes
sequenceDiagram
participant B as Console (browser)
participant S as Console server
participant G as sms-gateway
participant O as Operator
participant P as vaam admin phone
participant V as vaam
B->>B: make non-extractable key, store in IndexedDB
B->>B: draw QR (k, t, l, s); redraw every 4 min
loop every 2 s until 200
B->>S: sealed GET /sender_ids?fields=id&limit=1
S->>G: same bytes
G-->>B: unsigned 401 (key unknown): "not paired yet"
end
alt before VA-4: break-glass
O->>G: sms-gateway pair-console-key --user EMAIL --public-key k
else after VA-4: vaam relays
P->>B: scan the QR
P->>V: approve with biometrics
V->>G: registerConsoleKey(vaamUserId, k, s, t)
end
B->>S: sealed GET (next poll)
S->>G: same bytes
G-->>B: sealed 200
B->>G: sealed GET /console_keys?thumbprint=...
G-->>B: id, userId, pairedAt, expiresAt
B->>B: remember them; open the consoleThe wait is a sealed read the key just made, and the gateway's 200 is the only thing that ends it. A 401 is "not paired yet", never an error, and neither is a dropped connection (the page says it cannot reach the gateway and keeps going). The console works the same before and after VA-4: it cannot tell a command-line enrolment from a phone's, and does not need to.
Before VA-4 the page tells the operator the command and which field of the QR is the key. Pairing through the command line attaches the key to an existing user (provision-user makes one); it creates no user and changes no role, so it cannot mint an owner, but it can attach to one, which is the owner's way in.
Sealing
sdks/rust/vsms-sdk-wasm, the crate the Node SDK already seals through, is built a second time with wasm-bindgen --target web (bash sdks/node/vsms-sdk-node/scripts/build-wasm.sh --web, the admin package's build:sealer) into the gitignored public/sealer/, served by the console's own origin and imported by URL at run time, never bundled. The same pinned wasm-bindgen as the Node build; the script checks it against sdks/rust/Cargo.lock.
Sealer.fromEsp256Callback(publicKey, sign, current, next) gets a callback that calls crypto.subtle.sign; the key never enters the module, which checks that the signature verifies under the public key before using it and normalises WebCrypto's raw r‖s to the low-s form the gateway requires. The two server keys are pinned as configuration (SMS_ENVELOPE_SERVER_KEY_CURRENT and _NEXT, public values from sms-gateway server-keys), read by the root layout per request and handed to the browser, so one image promotes through environments.
Two additions to the crate, both needed by a console that speaks REST with optimistic locking:
routeOf(method, path)returns the schema's route template and decoded parameters for a concrete path (the matching the test responder already did, promoted and tested). The data layer writes ordinary requests (PATCH /providers/p1); the sealer is handed the template and parameters, rebuilds the target from the very parameters it bound, and the sealingfetchdrops the request unless that target equals the path it was asked for. A request cannot be signed form1and sent tom2.sealCall(…, ifMatch). A write under@versionsendsIf-Match, and the gateway rebuilds the binding from the header it receives, so the client has to have bound that value. Without this everyPATCHandDELETEof the console would fail verification. The Rust tests seal aPATCHwith anIf-Matchand show that a changed value, and anIf-Matchnobody signed, are both refused by cratestack's own server envelope.
What goes over the wire
lib/sealing.test.ts runs the real data layer against a real sealed verifier and asserts, for a read, a PATCH under If-Match, a DELETE, a POST and a procedure: the request is a POST to /api/gateway/<path> whose body is a COSE message (CBOR tag 18); the only headers are the ones a seal depends on plus x-vsms-method; there is no Authorization and no cookie; credentials is omit. A sealed GET carries its message as the body, which fetch refuses, so the browser posts the bytes and names the real method; the pass-through replays them under it, with node:http rather than fetch for the same reason.
The pass-through (@vsms/gateway/bff, app/api/gateway/[...path]/route.ts) is a pipe whose rules are pure functions with tests: a POST of a COSE message, one of four methods, an allow-list of request headers (no credential on it), no route the gateway leaves unsealed (/dlr, /healthz, /readyz), no dot segment, 1 MiB in and 8 MiB out, no redirect followed, no Set-Cookie or Location back. A sealed 204 still has a body and a response cannot be a 204 and have one, so it travels as a 200 with x-vsms-status; the seal binds the status, so a header that lied about it would make the answer fail to open.
What comes back is believed only if it opens
openResponse verifies the answer under one of the two pinned keys and against this very request. Refused, by test: plain JSON whatever its status (becomes a 503 of the console's own, and what the body said never reaches a screen); a body dressed as sealed that is not; a real answer to a different request; an answer sealed under a key that is not pinned. The gateway's unsigned 401 is read as "not paired", reported once, and never retried: a retry is a new request with a new nonce, and a key the gateway does not know is not one a second attempt can fix. The ETag a write sends back is rebuilt from the signed body's version, not copied from a header the seal does not cover.
The composer
previewMessage and sendMessage are procedures only an app may call (backends/crates/sms-api/src/procedures.rs, caller_client_id), so a paired person's key cannot. The composer (app/api/compose/[operation], @vsms/gateway/bff composer.ts) sends through a service key of the console's own: an Ed25519 key registered as an AppClient key, read from SMS_COMPOSER_KEY_FILE, signing through the Node SDK, which believes only answers that verify. Q5.
A standing key must not be spent on whoever reaches the route, and there is no session to ask. A sealed read answered 200 is no proof of who signed it: the gateway answers 200 to every signer its envelope accepts, an AppClient's Ed25519 key of any app as well as a paired person's, and SenderId is readable by an app and a user alike. So the proof is a call to a procedure made for it, authorizeComposition (schemas/vsms.cstack, backends/crates/sms-api/src/composition.rs):
sequenceDiagram
participant B as Browser (paired key)
participant C as Console server
participant G as Gateway
participant S as sms-gateway SDK (service key)
B->>B: SHA-256 of the exact text of the input
B->>C: proof = sealed authorizeComposition(operation, hash) + the input text
C->>C: SHA-256 of the text it received
C->>G: the proof's bytes, as sealed
G->>G: user + role (owner, admin, operator), then sms:send
G-->>C: sealed { userId, role, operation, inputSha256 }, or a sealed 403
C->>C: opens under the pinned keys, against those bytes
C->>C: names a person, this operation, this hash?
C->>S: sendMessage / previewMessageThe gateway refuses (a sealed 403) anything that is not a signed-in person holding owner, admin or operator (the roles sendMessage itself admits for a human) and the sms:send permission, so an app client, an auditor or a role that lost the permission cannot spend the key. The console's server opens the answer with the Node SDK's AnswerOpener (the wasm module's Opener, cratestack's own envelope: the two pinned server keys, against the bytes it forwarded and the status it received), so a 200 from something that is not the gateway, or an answer meant for another request, is a 502 and the key stays unused. Only an opened answer that names a person, the route's operation and the hash of the text received lets the service key sign.
By test (frontends/apps/admin/lib/composer.test.ts, with the real gateway's rule held by backends/crates/sms-api/tests/authorize_composition_live_postgres.rs): an app client's key, an auditor, a support user, a developer, a role without sms:send, a stranger's key, a replayed proof (the gateway refuses a nonce it has seen), a proof of some other call (the route is bound into the signature), a proof made for another message or for preview presented to send, an answer that is not the gateway's and a malformed body each leave the service key unused.
The proof is bound to the message and used once. What it still does not show is that what the operator typed was wise: it shows a paired operator who may send is making this call, with these bytes.
R4 holds: the gateway never calls the console, and the service key is useful without it (any deployment can send with the SDK and the same register-client flow).
The key's life
stateDiagram-v2
[*] --> none: first visit
none --> pending: key made, QR shown
pending --> paired: gateway answers 200
paired --> ended: expired (30 days) or revoked
paired --> clock: refused or expired while the clock is wrong
clock --> paired: the clock is corrected
clock --> none: the person chose "pair again"
paired --> none: unpaired here
paired --> none: revoked here
ended --> none: wiped
none --> pending: re-pair, a fresh keylib/key-lifecycle.ts joins IndexedDB, the gateway and the clock: none (no key), pending (a key the gateway has not accepted), paired, ended (accepted once, refused now, or past its 30 days on the clock alone, on a clock that is right), clock (a refusal or a local expiry while this computer's clock is off by more than the warning threshold, so it may be the clock's doing and not the key's; the key is left alone), unreachable (the gateway could not be reached, which must never read as "not paired" and send a person with a good key to make a new one).
- Show it.
/console("This console", under Admin) shows who the key speaks as, its RFC 9679 thumbprint (lib/thumbprint.ts, SHA-256 of the key's CBOR map, not of the raw point), when it was paired and when it expires, with a warning in the last seven days. The account block shows "Paired, 12 days left" everywhere. A console that is not paired shows the pairing page. - Re-pair after expiry. A key is never extended. When it ends (on a clock that is right) the page says so and
/pairwipes it and makes a fresh one. A key held by a wrong clock is never wiped automatically: the gate and/pairsay the clock is off and offer "Pair this console again", and only that action deletes the key. - Revoke calls
revokeConsoleKeyon the user's own key, then forgets it: the gateway stops accepting it everywhere. Unpair only wipes the IndexedDB key; the gateway's row stays until it expires or is revoked elsewhere, which the confirmation says. Both ask inline first. - Clock. Every sealed request carries its signing time and the gateway refuses one more than five minutes off its own clock. A laptop with a wrong clock looks "unpaired" (the same unsigned 401), so the console measures its clock against the server's
Dateheader (lib/clock.ts), the pairing page says so past two minutes, and the key's status reads a refusal on such a clock asclock, notended.
What a stolen browser profile gets
The private key cannot be copied out of the profile as bytes, but a script running on a page of this console can ask it to sign while the page is open. So the exposure is: use of the key by code that runs in the page, for the key's remaining life, as an admin. It is bounded by the 30 days, by role (never owner: an owner can only be enrolled from the command line, and a linked owner is refused by registerConsoleKey), by revocation from any other paired console, and by the fact that the gateway re-checks the key, the user's active flag and the role on every request. What this change does not add is a per-request Content-Security-Policy (script-src 'self' 'wasm-unsafe-eval' with a nonce), the control that keeps injected script out of the page. The static hardening headers are set (next.config.ts); the policy is the follow-up, and the wasm and the public/sealer/ layout were chosen so it can say script-src 'self' without unsafe-eval beyond WebAssembly.
Configuration
| Variable | Where | What |
|---|---|---|
SMS_API_URL | server | the gateway's origin, no path; with https:, all three certificate paths |
SMS_API_CLIENT_CERT_PATH | server | mTLS client certificate from the console's process to the gateway |
SMS_API_CLIENT_KEY_PATH | server | its key |
SMS_API_CA_PATH | server | the CA that signed the gateway's certificate |
SMS_ENVELOPE_SERVER_KEY_CURRENT | server | the gateway's current sealing public key (sms-gateway server-keys) |
SMS_ENVELOPE_SERVER_KEY_NEXT | server | the next one; must differ |
SMS_COMPOSER_KEY_FILE | server | Ed25519 PKCS#8 PEM of the composer's service key (optional) |
MESSAGE_STREAM_POLL_MS | server | the Messages screen's poll window; the other tuning values are unchanged |
The server keys reach the browser as props from the root layout, which opts out of static rendering (connection()), so a build never bakes one environment's keys into an image promoted to another.
Provisioning the composer's key
docs/runbooks/deployment.adoc, "The composer's key", is the procedure. In short: generate an Ed25519 key (openssl genpkey -algorithm ed25519), register its public half with sms-gateway register-client --app <slug> --client-name console-composer --scope sms:send --scope sms:read --public-key <base64url>, and mount the PEM at SMS_COMPOSER_KEY_FILE. The private half never reaches vsms. The console's scope picker deliberately does not offer console:pair (the gateway refuses it everywhere but the command line, backends/crates/sms-api/src/pair_scope.md).
How it was verified
- Rust (
sdks/rust/vsms-sdk-wasm): 49 native tests and 2 doctests against cratestack's own server envelope, includingrouteOffor every route of the schema (and its literal-before-parameter order), theIf-Matchbinding and the opener of an answer to bytes it did not seal;clippy -D warningsfor the host and forwasm32-unknown-unknown, with and withoutresponder. - Console (
frontends/apps/admin,pnpm test, which first builds.sealer-test/, the browser module with the server half compiled in): the key is non-extractable and stays so through IndexedDB (fake-indexeddb); the QR verifies under its tag; the thumbprint is RFC 9679's; every outbound call is sealed and credential-free; an unsealed, forged, mismatched or unpinned answer is refused; the composer is gated by the proof (the gateway's named person, this operation, this message); the status machine, the pairing flow and the clock check. - Gateway package (
@vsms/gateway): the pass-through's rules, the upstream transport against a real local HTTP server (aGETwith a body, a redirect not followed, the size cap, the timeout), and the sealingfetch's own logic. - A real
sms-gateway serve(2026-10-27; no GitHub job does this, and nothing in the repository drives a browser, so the run is recorded here rather than kept as a test). A scratch Postgres 16 cluster,sms-migrate,sms-gateway bootstrap,create-app --slug acme, two generated envelope keys withserve, and two registered Ed25519 clients: the composer's (register-client --client-name console-composer) and a stand-in for vaam's pairing client (--client-name vaam-pairing --scope console:pair). The console was the production build (next build, standalone,public/sealer/frombuild-wasm.sh --web) driven by Playwright's Chromium. 19 of 19 steps passed:- the QR drawn in the page, decoded from its rendered SVG, was relayed by the stand-in as
registerConsoleKey(publicKey = k,possessionProof = s,issuedAt = t): the real backend accepted the possession proof and made anadminuser, and the console opened by itself; /consoleshowed the thumbprint the Rust gateway stored (console_keys.thumbprint), solib/thumbprint.tsandcratestack::coseagree; the row's expiry ispaired_at + 30 days; the Apps screen showed the App the gateway sealed;- the composer's preview went browser, proof, console, real gateway (proof verified), SDK with the service key, real
previewMessage,200(this run used the first design's proof, a sealed read; the proof isauthorizeCompositionnow, held byauthorize_composition_live_postgres.rsand the console's composer tests, and was not driven through a browser again);sendwas refused by the gateway's own rule (sender id "ACME" has no approved provider registration), relayed with its422andVALIDATION_ERRORintact, and a proof used twice was refused the second time. A delivered send needs the registration steps ofjust demo, which this run did not do; - Revoke called the real
revokeConsoleKey(revoked_atset) and sent the console back to/pairwith a fresh key;pair-console-key --user owner@vsms.localthen paired the same browser as the owner; - through the console's pass-through to the real gateway, with a WebCrypto non-extractable key from Node: a sealed
GET /apps, aPATCHwithIf-Match(accepted by the realEnvelopeLayer: the bound header is the one it rebuilds), the samePATCHstale (a sealed412that opens), aPOSTand aDELETEunderIf-Match. The realDELETEanswered a sealed200, so thex-vsms-statuscarrier for a bodiless status has not yet been exercised by a real answer; it is covered by the tests and costs nothing; - no request carried an
Authorizationor aCookieat any hop, and the gateway answered a request nobody signed with its coarse401.
- the QR drawn in the page, decoded from its rendered SVG, was relayed by the stand-in as
- A browser run against a stand-in gateway (cratestack's real server envelope behind
node:http) preceded that and found two defects no unit test could (the status reader's probe notifying itself in a loop, and the page navigating before the shared status said "paired"); it also covered a key ended on the gateway elsewhere (the console went to/pairand said why), a forged unsealed answer for/apps(never shown), and the key in IndexedDB being non-extractable in a real Chromium. - Not run:
just all-checks'slintandtest(the Rust workspace underbackends/is unchanged and a full build did not fit the machine; the wasm crate's own tests and clippy,cargo denyfor its manifest and everycargo xtaskguard in that recipe did run), a Docker build of the admin image,helm template, the compose stacks, and a real phone (VA-4 does not exist yet).