@ciphera-net/tessera 0.2.2 → 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 +41 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/tessera.d.ts +43 -4
- package/dist/tessera.js +17 -6
- package/package.json +1 -1
- package/wasm/node/tessera_bg.wasm +0 -0
- package/wasm/web/tessera_bg.wasm +0 -0
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
Binary file
|
package/wasm/web/tessera_bg.wasm
CHANGED
|
Binary file
|