@ciphera-net/tessera 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -297,6 +297,45 @@ An envelope sealed under `"address"` cannot be opened under `"totp"` — wrong-c
297
297
 
298
298
  For wrong-key, wrong-context, and GCM tag failure, `open` throws a generic `Error` — there is no specific class that reveals which check failed (no decryption oracle).
299
299
 
300
+ ### Keeping the vault unlocked across page loads (0.3.0, additive)
301
+
302
+ `Session.vault` is a pair of **closures**, so a `Session` cannot be persisted —
303
+ functions are not structured-cloneable. `Session.vaultKey` exposes the VMK
304
+ itself, which is a `CryptoKey` and therefore *can* be stored (IndexedDB), and
305
+ `vaultOpsFor` rebuilds the same `seal`/`open` pair from it:
306
+
307
+ ```ts
308
+ import { vaultOpsFor, type VaultKey } from '@ciphera-net/tessera';
309
+
310
+ // after a ceremony — store the key, not the session
311
+ if (session.vaultKey.extractable) throw new Error('refusing to persist an extractable key');
312
+ await idbPut('vault-keys', userId, session.vaultKey);
313
+
314
+ // on a later page load — no ceremony, no password
315
+ const key = (await idbGet('vault-keys', userId)) as VaultKey | undefined;
316
+ const vault = key ? vaultOpsFor(key) : null;
317
+ const plaintext = await vault?.open('address', envelope);
318
+ ```
319
+
320
+ 🔴 **The key is always non-extractable** — every `VaultKey` is produced by
321
+ `importVaultKey`, which imports with `extractable: false` and usage
322
+ `['deriveKey']`. There are no key bytes in JS to read or copy: a holder gets
323
+ *use* of the key on that device, never a copy of it. Assert `extractable ===
324
+ false` before storing anyway, so a future regression fails loudly rather than
325
+ quietly persisting exportable key material.
326
+
327
+ ⚠️ **Whether to persist it is YOUR decision and YOUR threat model.** Storing the
328
+ key does not weaken zero-knowledge against the server — it still never sees the
329
+ key or the plaintext. It weakens it against **whoever holds the unlocked
330
+ device**, and it widens the window for a script that gets onto the page, from
331
+ "can wait for an unlock" to "can open the vault immediately". If you do store
332
+ it, the minimum discipline is: clear it on sign-out in the same call that clears
333
+ the session; key it by user id and clear it when that id changes; give it a
334
+ lifetime no longer than your refresh token's; IndexedDB only, never
335
+ `localStorage` (which cannot hold a `CryptoKey` at all, so anything that made it
336
+ fit would mean serialising key bytes); and tell the user which state they are
337
+ in, with a way to undo it.
338
+
300
339
  ### OPAQUE ceremony errors
301
340
 
302
341
  The register/login ceremonies raise two further classes. Both carry a fixed
@@ -411,6 +450,8 @@ AAD = [0x01] ‖ utf8(context)
411
450
 
412
451
  ### What the SDK cannot guarantee
413
452
 
453
+ **A persisted key removes the wait, not the ceiling.** Since 0.3.0 `Session.vaultKey` lets a host keep the vault unlocked across page loads. That does not hand a page any capability it lacked — a page holding a `Session` could already `seal` and `open` at will — but it removes the requirement that a *user action* happen first. See "Keeping the vault unlocked across page loads" for the trade and the minimum discipline.
454
+
414
455
  **Non-extractable is an API-layer guard, not process isolation.** A compromised page (XSS, malicious dependency, compromised browser extension) can still *use* the non-extractable key to seal/open arbitrary records. It cannot export the raw VMK bytes via `exportKey`, but it can call `session.vault.seal` and `session.vault.open` freely. Non-extractable does not defend against a compromised execution context.
415
456
 
416
457
  **Memory zeroing is best-effort.** The `export_key` and raw VMK transit WASM/JS linear memory and are zeroed after use, but the JavaScript runtime and the JIT compiler may copy values to internal buffers (AES round-key schedules, GC copying collectors) that are not reachable for zeroing. These copies are transient and never written to persistent storage or the network, but they cannot be guaranteed erased.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export { Tessera, type Session, type RecoverySession } from './tessera.js';
1
+ export { Tessera, vaultOpsFor, type Session, type RecoverySession, type VaultOps, } from './tessera.js';
2
+ export type { VaultKey } from './vault.js';
2
3
  export { init } from './wasm.js';
3
4
  export { blindIndexString } from './blindIndex.js';
4
5
  export { newRecoveryPhrase, recoveryPhrasePassword } from './recovery.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Public API of @ciphera-net/tessera.
2
- export { Tessera } from './tessera.js';
2
+ export { Tessera, vaultOpsFor, } from './tessera.js';
3
3
  export { init } from './wasm.js';
4
4
  export { blindIndexString } from './blindIndex.js';
5
5
  export { newRecoveryPhrase, recoveryPhrasePassword } from './recovery.js';
package/dist/tessera.d.ts CHANGED
@@ -3,11 +3,50 @@ import { type VaultKey } from './vault.js';
3
3
  import type { Transport } from './transport.js';
4
4
  export interface Session {
5
5
  sessionKeyB64: string | null;
6
- vault: {
7
- seal(context: string, plaintext: Uint8Array): Promise<Uint8Array>;
8
- open(context: string, envelope: Uint8Array): Promise<Uint8Array>;
9
- };
6
+ vault: VaultOps;
7
+ /**
8
+ * The VMK that backs `vault`, exposed so a host can PERSIST the unlocked state.
9
+ *
10
+ * This grants no capability the host did not already have: anything holding a
11
+ * Session can already call `vault.open()` and `vault.seal()`. What it adds is
12
+ * REACHABILITY — `vault` is a pair of closures, and functions are not
13
+ * structured-cloneable, so a Session cannot be put in IndexedDB. A `VaultKey`
14
+ * is a `CryptoKey` and can be, which is the whole difference between "unlocked
15
+ * for this page" and "unlocked on this device".
16
+ *
17
+ * 🔴 IT IS ALWAYS NON-EXTRACTABLE. Every VaultKey in this SDK is produced by
18
+ * `importVaultKey`, which imports with `extractable: false` and usage
19
+ * `['deriveKey']`. So there are no key bytes in JS to read, copy or exfiltrate
20
+ * — a holder gets USE of the key on that device, never a copy of it. A host
21
+ * that stores one should nonetheless assert `extractable === false` on the way
22
+ * in, so that a future SDK regression fails loudly instead of quietly
23
+ * persisting exportable key material.
24
+ *
25
+ * ⚠️ WHETHER TO PERSIST IT IS THE HOST'S DECISION, AND ITS THREAT MODEL.
26
+ * Storing it does not weaken zero-knowledge against the server: the server
27
+ * still never sees the key or the plaintext. It weakens it against whoever
28
+ * holds the unlocked device, and it widens the window for a script that gets
29
+ * onto the page — from "can wait for an unlock" to "can open the vault
30
+ * immediately". This SDK takes no position; it stops hiding the object.
31
+ *
32
+ * Rebuild the ops from a stored key with `vaultOpsFor(vaultKey)`.
33
+ */
34
+ vaultKey: VaultKey;
35
+ }
36
+ /** The seal/open pair a `VaultKey` backs. Named so a host can type a rebuilt pair. */
37
+ export interface VaultOps {
38
+ seal(context: string, plaintext: Uint8Array): Promise<Uint8Array>;
39
+ open(context: string, envelope: Uint8Array): Promise<Uint8Array>;
10
40
  }
41
+ /**
42
+ * Rebuild `Session['vault']` from a `VaultKey` — for a host that persisted the
43
+ * key and wants the same ops back without re-running a ceremony.
44
+ *
45
+ * This is exactly what every Session is built with; it is exported so that a
46
+ * restored key and a freshly unlocked one produce the SAME object shape, rather
47
+ * than each host re-implementing the pair and drifting on the context argument.
48
+ */
49
+ export declare function vaultOpsFor(vaultKey: VaultKey): VaultOps;
11
50
  export interface RecoverySession extends Session {
12
51
  /** Re-key auth to a new password. Preserves the vault (the SAME VMK is re-wrapped under the new
13
52
  * export_key — the vault content is never re-encrypted). Single-use: the recovery secret is zeroed
package/dist/tessera.js CHANGED
@@ -12,15 +12,26 @@ import { createRegistrationHandle } from './wasm.js';
12
12
  // VMK-wrap blobs are stored as standard base64 (they are opaque server storage, not OPAQUE wire blobs).
13
13
  const b64 = toBase64Std;
14
14
  const fromB64 = fromBase64Std;
15
- function sessionFor(vmk, sessionKeyB64) {
15
+ /**
16
+ * Rebuild `Session['vault']` from a `VaultKey` — for a host that persisted the
17
+ * key and wants the same ops back without re-running a ceremony.
18
+ *
19
+ * This is exactly what every Session is built with; it is exported so that a
20
+ * restored key and a freshly unlocked one produce the SAME object shape, rather
21
+ * than each host re-implementing the pair and drifting on the context argument.
22
+ */
23
+ export function vaultOpsFor(vaultKey) {
16
24
  return {
17
- sessionKeyB64,
18
- vault: {
19
- seal: (context, plaintext) => vaultSeal(vmk, context, plaintext),
20
- open: (context, envelope) => vaultOpen(vmk, context, envelope),
21
- },
25
+ seal: (context, plaintext) => vaultSeal(vaultKey, context, plaintext),
26
+ open: (context, envelope) => vaultOpen(vaultKey, context, envelope),
22
27
  };
23
28
  }
29
+ function sessionFor(vmk, sessionKeyB64) {
30
+ // ONE construction site for every Session this SDK returns, which is why
31
+ // adding `vaultKey` here covers register, login, recovery and the passkey
32
+ // unlock without touching any of them.
33
+ return { sessionKeyB64, vault: vaultOpsFor(vmk), vaultKey: vmk };
34
+ }
24
35
  export class Tessera {
25
36
  transport;
26
37
  constructor(transport) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphera-net/tessera",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
Binary file
Binary file