@terminal3/seal-web
v0.1.2
Published
T3n sealed delivery in the browser - open items sealed to a device-bound key, and fill signed forms whose answers only their named recipients can read, with no runtime dependencies
Readme
T3n sealed delivery for the browser
@terminal3/seal-web is the customer's side of sealed delivery: data her assistant carries but cannot read, in both directions.
- Out, a sealed item. T3N encrypts something sensitive (card details, a transfer to confirm) to a key that exists only in her browser. The assistant holds a reference (
sealed:…); this package fetches the ciphertext through your app and opens it on her device. - In, a sealed request. T3N signs a form (what it asks, who may read the answers, where they may go) and makes a form key inside its enclave. This package verifies the signature against your pinned signing key, draws the form, and encrypts her answers to the form key. The assistant only ever learns the status.
The package has no runtime dependencies. It needs WebCrypto, IndexedDB, custom elements and WebAuthn, so it runs in current browsers on a secure origin.
Installation
pnpm add @terminal3/seal-webUsage
import * as Seal from "@terminal3/seal-web";
Seal.configure({
rpName: "Example Bank",
api: {
// Adds her app session; keep `init.headers`, which carries the JSON content type.
fetch: (url, init) => fetch(url, { ...init, headers: { ...init.headers, authorization: `Bearer ${token}` } }),
passkey: "/api/passkey",
nonce: "/api/seal/nonce",
bind: "/api/seal/bind",
item: "/api/seal/items/{ref}",
confirm: "/api/seal/items/{ref}/confirm",
describe: "/api/seal/requests/{ref}",
submit: "/api/seal/requests/{ref}",
signingKey: "/api/seal/signing-key",
},
});
await Seal.registerPasskey({ id: customerId, name: customerName }); // once, at sign-in
await Seal.startSession({ sid, nonce }); // after she allows the assistant<t3-safebox ref="sealed:…" label="Your card"></t3-safebox>
<t3-safebox-form ref="request:…"></t3-safebox-form>Importing the package defines both elements. Call Seal.reset() at sign-out and when she revokes the assistant.
configure
configure({ api, rpName, rpId?, shadow? }) throws a SealError with code invalid_config, and keeps the previous configuration, if api or rpName is missing, or any part is mistyped or unknown.
| Option | |
| --- | --- |
| api | One endpoint per call, below. |
| rpName | Your name, shown in the passkey prompt and on both elements. |
| rpId | The passkey relying party ID; the page's host name by default. |
| shadow | "closed" (default) hides the elements' shadow roots from element.shadowRoot and the page's selectors; "open" lets a test drive them. Neither mode is a boundary against a script running on the page, which can instrument the page before this package runs. |
Each endpoint is a URL or path. The four that act on one item or request carry {ref}, which is replaced by the URL-encoded reference without its sealed: or request: prefix. POST bodies are JSON. An error answer should be a JSON object with error (a code) and optionally message.
| Entry | Method | Takes | Answers |
| --- | --- | --- | --- |
| fetch | | (url, init) | A Response. Optional; fetch by default. |
| passkey | GET | | { credential_id } of her registered passkey, read before each passkey prompt; credential_id is null when she has none |
| passkey | POST | { nonce, registration }, below | |
| nonce | POST | { purpose: "register" } | { nonce }, single-use, for her signed-in session |
| nonce | POST | { purpose: "bind" } or { purpose: "get" } | { nonce, sid }, single-use for that purpose |
| bind | POST | { seal_jwk, nonce, assertion } | |
| item | POST | {}, or { nonce, assertion } after a fresh unlock | { jwe, label? }; error fresh_unlock_required asks for an unlock |
| confirm | POST | {} | |
| describe | GET | | { description }, the form's compact JWS; error already_submitted once answered |
| submit | POST | { jwe } | { status, fields_present? } |
| signingKey | GET | | { jwk }, the public key forms are signed with |
| sources | GET | | Optional map of further item endpoints answering { jwe, label? }, chosen by <t3-safebox source="name"> |
Your app server verifies what the browser sends and relays each call to T3N; the browser never talks to T3N directly.
Keys and passkeys
- Session key.
startSessionmakes a P-256 ECDH key pair with WebCrypto as non-extractable, so the private key never exists outside the browser's key store. It lives in IndexedDB for the session (10 minutes idle, 30 minutes at most). - Registration.
registerPasskeyruns a WebAuthn registration ceremony over aregisternonce, with user verification required. Its challenge is SHA-256 of the JCS of{ t: "t3n-seal/register", nonce }. It excludes her registered credential, so a device that holds it makes no second one. It posts{ nonce, registration }, whereregistrationis{ credential_id, clientDataJSON, attestationObject, authenticatorData?, publicKey?, publicKeyAlgorithm?, transports }: the binary members base64url,publicKeythe SPKI andpublicKeyAlgorithmits COSE algorithm where the browser reports them. Your IdP consumes the nonce, then checks thatclientDataJSONhastypewebauthn.create, that challenge and your origin, and that the authenticator data has your RP ID hash and the user-present and user-verified flags. It stores the credential ID and public key from the attestation's authenticator data;publicKeymust match them. Attestation isnone, so the ceremony proves freshness and the origin, not the authenticator: accept a registration only within a fresh sign-in. - Binding. Her passkey binds the session key's public half to her consent: an assertion over SHA-256 of the JCS (RFC 8785) of
{ t: "t3n-seal/bind", kid, sid, nonce }. Your IdP verifies it against the registered passkey and only then putsseal_jwkin the assistant's delegation token. - Fresh unlock. An item that asks for one (a card number) opens only after another assertion, over
{ t: "t3n-seal/get", ref, sid, nonce }.
Formats
- Compact JWE (RFC 7516),
algECDH-ES,encA256GCM, over P-256: one-round Concat KDF with emptyapu/apv, the base64url protected header as the AES-GCM additional data, and the recipient key's RFC 7638 thumbprint askid. Headers carryref,purposeand optionallyexp(Unix seconds). - A form's description is a compact JWS, ES256,
typt3n-seal-form+jws, verified over the exact bytes received against the key yoursigningKeyendpoint answers. That key'skidis<tid>.<generation>, and the description's header must name the samekid. Submitted answers carry the description's SHA-256 asfp. - Every public key received is checked to be on P-256. A
kidnamed by any key but the signing key must be its own thumbprint.
Functions
| Function | |
| --- | --- |
| registerPasskey({ id, name }) | Registers her passkey on this device, unless it already holds her registered credential; answers the credential ID. |
| startSession({ sid, nonce }) | Makes and binds a session key; answers its public JWK. |
| ensureSession() | Binds a session key with a fresh nonce if hers is gone. |
| hasSession(sid?), reset() | Whether a live session key is bound; delete it. |
| openItem(ref, source?) | Fetches and opens a sealed item: { content, meta }. |
| open(jwe, ref?) | Opens a JWE with the session key. |
| confirmItem(ref) | Confirms a sealed item. |
| describe(ref), submit(ref, description, answers) | Verifies a form's description; seals and submits her answers. |
| thumbprint(jwk) | RFC 7638 thumbprint of a P-256 public JWK. |
Every refusal is a SealError with a stable code and a message fit to show her. The format refusals are invalid_key, jwe_malformed, header_mismatch, jwe_not_authentic and expired; others include no_session, wrong_reference, not_signed and not_configured. A passkey prompt that is refused or fails gives passkey_cancelled (she dismissed it, or it timed out), passkey_unsupported, passkey_not_allowed or passkey_failed.
Elements
<t3-safebox ref="sealed:…" label="…">shows a sealed item once she opens it, for a minute.auto-openopens it on connection;source="name"fetches it fromapi.sources.name. An item with aconfirmbutton firesseal-confirmedafter theconfirmendpoint answers, orseal-confirm(withrun_id) for a confirmation your app runs itself.<t3-safebox-form ref="request:…">draws a sealed request from its verified description, and firesseal-submittedonce the answers are sent.- Both may be in the page before
configureruns: they wait indata-state="waiting"and start once it has. Both render again on each laterconfigure; their shadow root keeps the mode it was attached with. - Both reflect their state in
data-state, and take their accent colour from the CSS custom properties--seal-accentand--seal-accent-soft.
