@ciphera-net/tessera 0.1.3

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.
@@ -0,0 +1,12 @@
1
+ export declare class UnsupportedVersionError extends Error {
2
+ constructor();
3
+ }
4
+ export declare class MalformedEnvelopeError extends Error {
5
+ constructor();
6
+ }
7
+ export declare class EmptyVaultKeyError extends Error {
8
+ constructor();
9
+ }
10
+ export declare class EmptyContextError extends Error {
11
+ constructor();
12
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,27 @@
1
+ // Error taxonomy mirroring tessera-go/vault.go. One GENERIC error for wrong-key / wrong-context /
2
+ // tamper / too-short (no decryption oracle — these MUST be indistinguishable), and a DISTINCT error
3
+ // for an unrecognized version byte (forward-compat; the version is not secret).
4
+ export class UnsupportedVersionError extends Error {
5
+ constructor() {
6
+ super('tessera: unsupported vault envelope version');
7
+ this.name = 'UnsupportedVersionError';
8
+ }
9
+ }
10
+ export class MalformedEnvelopeError extends Error {
11
+ constructor() {
12
+ super('tessera: malformed or unauthentic vault envelope');
13
+ this.name = 'MalformedEnvelopeError';
14
+ }
15
+ }
16
+ export class EmptyVaultKeyError extends Error {
17
+ constructor() {
18
+ super('tessera: empty vault key');
19
+ this.name = 'EmptyVaultKeyError';
20
+ }
21
+ }
22
+ export class EmptyContextError extends Error {
23
+ constructor() {
24
+ super('tessera: empty record context');
25
+ this.name = 'EmptyContextError';
26
+ }
27
+ }
@@ -0,0 +1,8 @@
1
+ export { Tessera, type Session, type RecoverySession } from './tessera.js';
2
+ export { init } from './wasm.js';
3
+ export { blindIndexString } from './blindIndex.js';
4
+ export { newRecoveryPhrase } from './recovery.js';
5
+ export { isPasskeySupported, evaluatePrf, type PrfProvider, type PrfOptions, type PrfCreateOptions, type PrfGetOptions, } from './passkey.js';
6
+ export type { Transport } from './transport.js';
7
+ export type { UnlockMethod } from './vmk.js';
8
+ export { UnsupportedVersionError, MalformedEnvelopeError, EmptyVaultKeyError, EmptyContextError, } from './errors.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // Public API of @ciphera-net/tessera.
2
+ export { Tessera } from './tessera.js';
3
+ export { init } from './wasm.js';
4
+ export { blindIndexString } from './blindIndex.js';
5
+ export { newRecoveryPhrase } from './recovery.js';
6
+ export { isPasskeySupported, evaluatePrf, } from './passkey.js';
7
+ export { UnsupportedVersionError, MalformedEnvelopeError, EmptyVaultKeyError, EmptyContextError, } from './errors.js';
@@ -0,0 +1,16 @@
1
+ import type { Transport } from './transport.js';
2
+ /** Drive OPAQUE registration. Returns the 64-byte export_key (CLIENT-ONLY). The server stores the
3
+ * password file (void). */
4
+ export declare function registerOpaque(t: Transport, credentialId: string, password: Uint8Array): Promise<{
5
+ exportKey: Uint8Array;
6
+ }>;
7
+ /** Drive OPAQUE login. Returns the export_key (CLIENT-ONLY) and the server's session key (base64). */
8
+ export declare function loginOpaque(t: Transport, credentialId: string, password: Uint8Array): Promise<{
9
+ exportKey: Uint8Array;
10
+ sessionKeyB64: string;
11
+ }>;
12
+ /** Re-run registration under an EXISTING credentialId to replace the password file (post-recovery
13
+ * reset). Returns the new export_key so the caller can re-wrap the (preserved) VMK under it. */
14
+ export declare function resetPasswordOpaque(t: Transport, credentialId: string, newPassword: Uint8Array): Promise<{
15
+ exportKey: Uint8Array;
16
+ }>;
package/dist/opaque.js ADDED
@@ -0,0 +1,69 @@
1
+ // OPAQUE register/login orchestration over a Transport. Pure relay logic: the crypto is in the WASM
2
+ // handles, the wire encoding is base64-STANDARD (NOT the blind-index base64url) end-to-end, and the
3
+ // transport carries blobs to the app's backend (→ tessera-go → sidecar). The 64-byte export_key is
4
+ // CLIENT-ONLY — the caller (tessera.ts) wraps the VMK under it immediately and then zeroes it. The
5
+ // WASM Finish handles are freed once their bytes are consumed (zeroizes the in-WASM key copies).
6
+ import { fromBase64Std, toBase64Std } from './encoding.js';
7
+ import { createRegistrationHandle, createLoginHandle } from './wasm.js';
8
+ /** Drive OPAQUE registration. Returns the 64-byte export_key (CLIENT-ONLY). The server stores the
9
+ * password file (void). */
10
+ export async function registerOpaque(t, credentialId, password) {
11
+ const reg = createRegistrationHandle(password);
12
+ try {
13
+ const { responseB64 } = await t.registerStart({ requestB64: toBase64Std(reg.request), credentialId });
14
+ const fin = reg.finish(password, fromBase64Std(responseB64));
15
+ try {
16
+ const uploadB64 = toBase64Std(fin.upload);
17
+ const exportKey = fin.exportKey; // getter returns a fresh JS copy; caller owns/zeroes it
18
+ await t.registerFinish({ credentialId, uploadB64 });
19
+ return { exportKey };
20
+ }
21
+ finally {
22
+ fin.free(); // zeroize the in-WASM export_key copy
23
+ }
24
+ }
25
+ finally {
26
+ reg.free();
27
+ }
28
+ }
29
+ /** Drive OPAQUE login. Returns the export_key (CLIENT-ONLY) and the server's session key (base64). */
30
+ export async function loginOpaque(t, credentialId, password) {
31
+ const lh = createLoginHandle(password);
32
+ try {
33
+ const { loginId, responseB64 } = await t.loginStart({ requestB64: toBase64Std(lh.request), credentialId });
34
+ const lf = lh.finish(password, fromBase64Std(responseB64));
35
+ try {
36
+ const finalizationB64 = toBase64Std(lf.finalization);
37
+ const exportKey = lf.exportKey;
38
+ const { sessionKeyB64 } = await t.loginFinish({ loginId, finalizationB64 });
39
+ return { exportKey, sessionKeyB64 };
40
+ }
41
+ finally {
42
+ lf.free(); // zeroize the in-WASM export_key + session_key copies
43
+ }
44
+ }
45
+ finally {
46
+ lh.free();
47
+ }
48
+ }
49
+ /** Re-run registration under an EXISTING credentialId to replace the password file (post-recovery
50
+ * reset). Returns the new export_key so the caller can re-wrap the (preserved) VMK under it. */
51
+ export async function resetPasswordOpaque(t, credentialId, newPassword) {
52
+ const reg = createRegistrationHandle(newPassword);
53
+ try {
54
+ const { responseB64 } = await t.registerStart({ requestB64: toBase64Std(reg.request), credentialId });
55
+ const fin = reg.finish(newPassword, fromBase64Std(responseB64));
56
+ try {
57
+ const uploadB64 = toBase64Std(fin.upload);
58
+ const exportKey = fin.exportKey;
59
+ await t.replacePasswordFile({ credentialId, uploadB64 });
60
+ return { exportKey };
61
+ }
62
+ finally {
63
+ fin.free();
64
+ }
65
+ }
66
+ finally {
67
+ reg.free();
68
+ }
69
+ }
@@ -0,0 +1,30 @@
1
+ /** Supplies the 32-byte WebAuthn-PRF output. The caller runs the ceremony (e.g. via `evaluatePrf`)
2
+ * with its own RP/challenge/credential context. Returns exactly the bytes used to wrap the VMK.
3
+ * CONTRACT: the SDK ZEROES the returned buffer after use — return a fresh buffer per call; do not
4
+ * reuse or share it. */
5
+ export type PrfProvider = () => Promise<Uint8Array>;
6
+ export interface PrfCreateOptions {
7
+ create: true;
8
+ rpId: string;
9
+ rpName: string;
10
+ userId: Uint8Array;
11
+ userName: string;
12
+ userDisplayName?: string;
13
+ challenge: Uint8Array;
14
+ }
15
+ export interface PrfGetOptions {
16
+ create: false;
17
+ rpId: string;
18
+ challenge: Uint8Array;
19
+ allowCredentialIds?: Uint8Array[];
20
+ }
21
+ export type PrfOptions = PrfCreateOptions | PrfGetOptions;
22
+ /** Best-effort, NO-user-gesture support probe. Definitive PRF support is only known after a real
23
+ * get()/create() returns prf results; this is a conservative gate so the UI can OFFER the option.
24
+ * Never throws (returns false on any failure / when WebAuthn is absent). */
25
+ export declare function isPasskeySupported(): Promise<boolean>;
26
+ /** Run a WebAuthn ceremony with the PRF extension and return the 32-byte first output. `create`
27
+ * registers a new credential (enable); otherwise asserts an existing one (unlock). Throws if the
28
+ * chosen authenticator does not return a PRF result (additive feature — caller surfaces it).
29
+ * Browser-only; exercised in the Playwright (virtual-authenticator) matrix, not the Node unit tests. */
30
+ export declare function evaluatePrf(opts: PrfOptions): Promise<Uint8Array>;
@@ -0,0 +1,75 @@
1
+ // WebAuthn-PRF passwordless unlock (ADDITIVE — password (OPAQUE) and the recovery phrase are the
2
+ // always-available paths; an authenticator without PRF simply cannot enable this, surfaced via
3
+ // isPasskeySupported(), never a silent failure).
4
+ //
5
+ // SEPARATION OF CONCERNS: a WebAuthn ceremony needs app/server context (rpId, challenge, the user's
6
+ // credential ids) that this SDK does not own. So the SDK's enable/unlock take a `PrfProvider` — the
7
+ // CALLER runs the ceremony (typically via `evaluatePrf` below) and hands back the 32-byte PRF output;
8
+ // the SDK wraps/unwraps the VMK under it. The PRF eval salt is the ONE ceremony input the SDK pins
9
+ // (it must match between enable and unlock or the derived secret differs).
10
+ import { wcView } from './encoding.js';
11
+ /** Fixed PRF eval input — pinned so the PRF output is stable across enable/unlock. */
12
+ const PRF_SALT = new TextEncoder().encode('tessera/prf/v1');
13
+ /** Best-effort, NO-user-gesture support probe. Definitive PRF support is only known after a real
14
+ * get()/create() returns prf results; this is a conservative gate so the UI can OFFER the option.
15
+ * Never throws (returns false on any failure / when WebAuthn is absent). */
16
+ export async function isPasskeySupported() {
17
+ if (!('PublicKeyCredential' in globalThis))
18
+ return false;
19
+ try {
20
+ return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /** Run a WebAuthn ceremony with the PRF extension and return the 32-byte first output. `create`
27
+ * registers a new credential (enable); otherwise asserts an existing one (unlock). Throws if the
28
+ * chosen authenticator does not return a PRF result (additive feature — caller surfaces it).
29
+ * Browser-only; exercised in the Playwright (virtual-authenticator) matrix, not the Node unit tests. */
30
+ export async function evaluatePrf(opts) {
31
+ // The PRF extension types are not in every TS lib.dom; cast the extensions object at the boundary.
32
+ // PRF_SALT is already a Uint8Array (a valid BufferSource), so no wcView wrap is needed here.
33
+ const extensions = { prf: { eval: { first: PRF_SALT } } };
34
+ let cred;
35
+ if (opts.create) {
36
+ cred = (await navigator.credentials.create({
37
+ publicKey: {
38
+ challenge: wcView(opts.challenge),
39
+ rp: { id: opts.rpId, name: opts.rpName },
40
+ user: {
41
+ id: wcView(opts.userId),
42
+ name: opts.userName,
43
+ displayName: opts.userDisplayName ?? opts.userName,
44
+ },
45
+ pubKeyCredParams: [
46
+ { type: 'public-key', alg: -7 }, // ES256
47
+ { type: 'public-key', alg: -257 }, // RS256
48
+ ],
49
+ authenticatorSelection: { userVerification: 'required', residentKey: 'required' },
50
+ extensions,
51
+ },
52
+ }));
53
+ }
54
+ else {
55
+ cred = (await navigator.credentials.get({
56
+ publicKey: {
57
+ challenge: wcView(opts.challenge),
58
+ rpId: opts.rpId,
59
+ allowCredentials: (opts.allowCredentialIds ?? []).map((id) => ({
60
+ type: 'public-key',
61
+ id: wcView(id),
62
+ })),
63
+ userVerification: 'required',
64
+ extensions,
65
+ },
66
+ }));
67
+ }
68
+ if (!cred)
69
+ throw new Error('tessera: WebAuthn ceremony returned no credential');
70
+ const first = cred.getClientExtensionResults().prf?.results?.first;
71
+ if (!first) {
72
+ throw new Error('tessera: authenticator did not return a PRF result (PRF unsupported by this authenticator)');
73
+ }
74
+ return first instanceof ArrayBuffer ? new Uint8Array(first) : new Uint8Array(first.buffer, first.byteOffset, first.byteLength);
75
+ }
@@ -0,0 +1,7 @@
1
+ /** A fresh 24-word (256-bit) recovery phrase. Show to the user ONCE; never persist it. NOTE: the
2
+ * returned string is immutable (JS strings cannot be zeroed) — minimise its lifetime; the SDK
3
+ * derives the entropy from it and zeroes THAT, but the phrase string itself cannot be wiped. */
4
+ export declare function newRecoveryPhrase(): string;
5
+ /** The 'recovery' VMK-wrap secret = the 32-byte BIP-39 entropy. Throws on an invalid-checksum phrase.
6
+ * CALLER must zero the returned buffer after wrapping/unwrapping. */
7
+ export declare function recoverySecret(phrase: string): Uint8Array;
@@ -0,0 +1,19 @@
1
+ // BIP-39 (24-word / 256-bit) recovery. The recovery WRAP secret is the mnemonic ENTROPY (already
2
+ // 256 bits of high entropy) — NOT the PBKDF2 seed: there is no passphrase, the entropy is the secret,
3
+ // and using it directly avoids a redundant 2048-round PBKDF2. The phrase is shown to the user ONCE at
4
+ // registration; losing it means losing the recovery path (the password path remains).
5
+ import { generateMnemonic, mnemonicToEntropy, validateMnemonic } from '@scure/bip39';
6
+ import { wordlist } from '@scure/bip39/wordlists/english';
7
+ /** A fresh 24-word (256-bit) recovery phrase. Show to the user ONCE; never persist it. NOTE: the
8
+ * returned string is immutable (JS strings cannot be zeroed) — minimise its lifetime; the SDK
9
+ * derives the entropy from it and zeroes THAT, but the phrase string itself cannot be wiped. */
10
+ export function newRecoveryPhrase() {
11
+ return generateMnemonic(wordlist, 256);
12
+ }
13
+ /** The 'recovery' VMK-wrap secret = the 32-byte BIP-39 entropy. Throws on an invalid-checksum phrase.
14
+ * CALLER must zero the returned buffer after wrapping/unwrapping. */
15
+ export function recoverySecret(phrase) {
16
+ if (!validateMnemonic(phrase, wordlist))
17
+ throw new Error('tessera: invalid recovery phrase');
18
+ return mnemonicToEntropy(phrase, wordlist); // 32 bytes
19
+ }
@@ -0,0 +1,109 @@
1
+ import type { PrfProvider } from './passkey.js';
2
+ import { type VaultKey } from './vault.js';
3
+ import type { Transport } from './transport.js';
4
+ export interface Session {
5
+ sessionKeyB64: string | null;
6
+ vault: {
7
+ seal(context: string, plaintext: Uint8Array): Promise<Uint8Array>;
8
+ open(context: string, envelope: Uint8Array): Promise<Uint8Array>;
9
+ };
10
+ }
11
+ export interface RecoverySession extends Session {
12
+ /** Re-key auth to a new password. Preserves the vault (the SAME VMK is re-wrapped under the new
13
+ * export_key — the vault content is never re-encrypted). Single-use: the recovery secret is zeroed
14
+ * after, so a second call will fail. */
15
+ resetPassword(newPassword: Uint8Array): Promise<void>;
16
+ /** Zero the in-memory recovery secret when finished WITHOUT calling resetPassword. The recovery
17
+ * secret is retained in this session because the non-extractable VMK cannot itself be re-wrapped, so
18
+ * resetPassword needs it; if you do not call resetPassword, call dispose() to wipe it. After dispose()
19
+ * (or resetPassword) the secret is zeroed, and a subsequent resetPassword would fail. */
20
+ dispose(): void;
21
+ }
22
+ declare function sessionFor(vmk: VaultKey, sessionKeyB64: string | null): Session;
23
+ export declare class Tessera {
24
+ private readonly transport;
25
+ constructor(transport: Transport);
26
+ /** Register: enroll OPAQUE, mint a VMK, wrap it under the password (export_key) and a fresh recovery
27
+ * phrase, store both wraps. Returns the recovery phrase to show the user ONCE. */
28
+ register({ email, password, }: {
29
+ email: string;
30
+ password: Uint8Array;
31
+ }): Promise<{
32
+ recoveryPhrase: string;
33
+ session: Session;
34
+ }>;
35
+ /** Migration-only enrolment for an existing SRP account. Same crypto as register(), with two
36
+ * deliberate differences that make a forced SRP→OPAQUE upgrade safe:
37
+ * (1) VERIFY-BEFORE-ZERO — it PROVES both the opaque and recovery wraps round-trip while export_key
38
+ * and the recovery entropy are STILL LIVE. register() zeroes those secrets in its finally before
39
+ * returning, which makes any post-hoc wrap verification impossible; here the openVaultKey checks
40
+ * run inside the try, so a bad wrap throws (AES-GCM tag failure) and NOTHING is returned.
41
+ * (2) NO putWraps — the caller submits the wraps itself, atomically, to /auth/migrate/opaque (so the
42
+ * auth_version flip and the wrap writes commit in one DB transaction). The wraps are returned as
43
+ * base64 for that POST. */
44
+ registerForMigration({ email, password, }: {
45
+ email: string;
46
+ password: Uint8Array;
47
+ }): Promise<{
48
+ recoveryPhrase: string;
49
+ session: Session;
50
+ wraps: {
51
+ opaque: string;
52
+ recovery: string;
53
+ };
54
+ }>;
55
+ /** Login: OPAQUE → export_key → unwrap the VMK (non-extractable) → Session with vault ops. */
56
+ login({ email, password }: {
57
+ email: string;
58
+ password: Uint8Array;
59
+ }): Promise<Session>;
60
+ /** Recover via the BIP-39 phrase: unwrap the VMK from the 'recovery' wrap → a Session (no OPAQUE
61
+ * session key) plus a single-use `resetPassword`. The recovery secret + wrap blob are held in the
62
+ * returned closure ONLY because the session VMK is non-extractable and cannot itself be re-wrapped;
63
+ * resetPassword re-derives the raw VMK from the recovery wrap and re-wraps it under the new password,
64
+ * so the vault is never re-encrypted. The recovery secret is zeroed once resetPassword runs — or, if
65
+ * the caller never calls resetPassword, once dispose() is called. If NEITHER is called, the 32-byte
66
+ * recovery secret persists in this session for its lifetime; discard the session promptly. */
67
+ recoverWithPhrase({ email, phrase, }: {
68
+ email: string;
69
+ phrase: string;
70
+ }): Promise<RecoverySession>;
71
+ /** Enable passwordless unlock (ADDITIVE). RE-AUTHENTICATES with the password (a non-extractable
72
+ * session VMK cannot be re-wrapped), then re-wraps the VMK from the 'opaque' wrap into a 'webauthn'
73
+ * wrap keyed by the PRF output. `prf` runs the WebAuthn create() ceremony (see passkey.evaluatePrf).
74
+ * Both the export_key and the PRF output are zeroed after use. */
75
+ enablePasskey({ email, password, prf, }: {
76
+ email: string;
77
+ password: Uint8Array;
78
+ prf: PrfProvider;
79
+ }): Promise<void>;
80
+ /** Passwordless unlock via the 'webauthn' wrap. `prf` runs the WebAuthn get() ceremony. No OPAQUE
81
+ * handshake on this path, so the Session's sessionKeyB64 is null. The PRF output is zeroed after. */
82
+ unlockWithPasskey({ email, prf }: {
83
+ email: string;
84
+ prf: PrfProvider;
85
+ }): Promise<Session>;
86
+ /** Change the password from a logged-in context. Re-authenticates with the OLD
87
+ * password, runs a fresh OPAQUE registration under the NEW password, and re-wraps
88
+ * the SAME VMK from the 'opaque' wrap into a new 'opaque' wrap under the new
89
+ * export_key. The vault is NEVER re-encrypted, and the recovery + passkey wraps
90
+ * (which wrap the same VMK) stay valid. Both export_keys are zeroed after use. */
91
+ changePassword({ email, oldPassword, newPassword, }: {
92
+ email: string;
93
+ oldPassword: Uint8Array;
94
+ newPassword: Uint8Array;
95
+ }): Promise<void>;
96
+ /** Rotate the recovery phrase from a logged-in context. Re-authenticates with the
97
+ * password, mints a fresh 24-word phrase, and re-wraps the SAME VMK from the
98
+ * 'opaque' wrap into a new 'recovery' wrap under the new phrase's secret. The vault
99
+ * is never re-encrypted, and the OLD phrase's wrap is overwritten. Returns the new
100
+ * phrase to show ONCE. export_key and recovery entropy are zeroed after use. */
101
+ regenerateRecovery({ email, password, }: {
102
+ email: string;
103
+ password: Uint8Array;
104
+ }): Promise<{
105
+ recoveryPhrase: string;
106
+ }>;
107
+ }
108
+ /** @internal Exposed for the recovery/passkey methods added in later tasks (not re-exported from index). */
109
+ export { sessionFor };
@@ -0,0 +1,228 @@
1
+ // High-level Tessera SDK. Orchestrates blind index → OPAQUE → VMK → vault, transport-agnostic. The
2
+ // 64-byte OPAQUE export_key and the 32-byte recovery entropy are used ONLY to wrap the VMK, then zeroed
3
+ // — they never persist and never cross the wire. The VMK is held as a non-extractable CryptoKey inside
4
+ // the returned Session; the raw VMK never leaves WASM/JS linear memory at rest.
5
+ import { blindIndexString } from './blindIndex.js';
6
+ import { loginOpaque, registerOpaque, resetPasswordOpaque } from './opaque.js';
7
+ import { generateAndWrap, openVaultKey, rewrapForMethod } from './vmk.js';
8
+ import { newRecoveryPhrase, recoverySecret } from './recovery.js';
9
+ import { open as vaultOpen, seal as vaultSeal } from './vault.js';
10
+ import { fromBase64Std, toBase64Std } from './encoding.js';
11
+ // VMK-wrap blobs are stored as standard base64 (they are opaque server storage, not OPAQUE wire blobs).
12
+ const b64 = toBase64Std;
13
+ const fromB64 = fromBase64Std;
14
+ function sessionFor(vmk, sessionKeyB64) {
15
+ return {
16
+ sessionKeyB64,
17
+ vault: {
18
+ seal: (context, plaintext) => vaultSeal(vmk, context, plaintext),
19
+ open: (context, envelope) => vaultOpen(vmk, context, envelope),
20
+ },
21
+ };
22
+ }
23
+ export class Tessera {
24
+ transport;
25
+ constructor(transport) {
26
+ this.transport = transport;
27
+ }
28
+ /** Register: enroll OPAQUE, mint a VMK, wrap it under the password (export_key) and a fresh recovery
29
+ * phrase, store both wraps. Returns the recovery phrase to show the user ONCE. */
30
+ async register({ email, password, }) {
31
+ const credentialId = blindIndexString(email);
32
+ const { exportKey } = await registerOpaque(this.transport, credentialId, password);
33
+ // Open the try IMMEDIATELY so exportKey is zeroed on ANY subsequent throw. recovEntropy is
34
+ // nullable (derived inside) and zeroed only if it was created — no sentinel.
35
+ let recovEntropy;
36
+ try {
37
+ const recoveryPhrase = newRecoveryPhrase();
38
+ recovEntropy = recoverySecret(recoveryPhrase);
39
+ // The WHOLE 64-byte export_key is the 'opaque' wrap secret — do NOT slice it.
40
+ const { vmk, wraps } = await generateAndWrap({ opaque: exportKey, recovery: recovEntropy });
41
+ await this.transport.putWraps({
42
+ credentialId,
43
+ wraps: { opaque: b64(wraps.opaque), recovery: b64(wraps.recovery) },
44
+ });
45
+ return { recoveryPhrase, session: sessionFor(vmk, null) };
46
+ }
47
+ finally {
48
+ exportKey.fill(0);
49
+ recovEntropy?.fill(0);
50
+ }
51
+ }
52
+ /** Migration-only enrolment for an existing SRP account. Same crypto as register(), with two
53
+ * deliberate differences that make a forced SRP→OPAQUE upgrade safe:
54
+ * (1) VERIFY-BEFORE-ZERO — it PROVES both the opaque and recovery wraps round-trip while export_key
55
+ * and the recovery entropy are STILL LIVE. register() zeroes those secrets in its finally before
56
+ * returning, which makes any post-hoc wrap verification impossible; here the openVaultKey checks
57
+ * run inside the try, so a bad wrap throws (AES-GCM tag failure) and NOTHING is returned.
58
+ * (2) NO putWraps — the caller submits the wraps itself, atomically, to /auth/migrate/opaque (so the
59
+ * auth_version flip and the wrap writes commit in one DB transaction). The wraps are returned as
60
+ * base64 for that POST. */
61
+ async registerForMigration({ email, password, }) {
62
+ const credentialId = blindIndexString(email);
63
+ const { exportKey } = await registerOpaque(this.transport, credentialId, password);
64
+ let recovEntropy;
65
+ try {
66
+ const recoveryPhrase = newRecoveryPhrase();
67
+ recovEntropy = recoverySecret(recoveryPhrase);
68
+ // The WHOLE 64-byte export_key is the 'opaque' wrap secret — do NOT slice it.
69
+ const { vmk, wraps } = await generateAndWrap({ opaque: exportKey, recovery: recovEntropy });
70
+ // RECOVERABILITY PROOF — both wraps must decrypt to the real VMK BEFORE the finally zeroes the
71
+ // secrets. A garbled/empty wrap makes openVaultKey throw, aborting the migration with no writes.
72
+ await openVaultKey(wraps.opaque, exportKey, 'opaque');
73
+ await openVaultKey(wraps.recovery, recovEntropy, 'recovery');
74
+ return {
75
+ recoveryPhrase,
76
+ session: sessionFor(vmk, null),
77
+ wraps: { opaque: b64(wraps.opaque), recovery: b64(wraps.recovery) },
78
+ };
79
+ }
80
+ finally {
81
+ exportKey.fill(0);
82
+ recovEntropy?.fill(0);
83
+ }
84
+ }
85
+ /** Login: OPAQUE → export_key → unwrap the VMK (non-extractable) → Session with vault ops. */
86
+ async login({ email, password }) {
87
+ const credentialId = blindIndexString(email);
88
+ const { exportKey, sessionKeyB64 } = await loginOpaque(this.transport, credentialId, password);
89
+ try {
90
+ const wrap = await this.transport.getWrap({ credentialId, method: 'opaque' });
91
+ if (!wrap)
92
+ throw new Error('tessera: no opaque VMK wrap for this account');
93
+ const vmk = await openVaultKey(fromB64(wrap.blobB64), exportKey, 'opaque');
94
+ return sessionFor(vmk, sessionKeyB64);
95
+ }
96
+ finally {
97
+ exportKey.fill(0);
98
+ }
99
+ }
100
+ /** Recover via the BIP-39 phrase: unwrap the VMK from the 'recovery' wrap → a Session (no OPAQUE
101
+ * session key) plus a single-use `resetPassword`. The recovery secret + wrap blob are held in the
102
+ * returned closure ONLY because the session VMK is non-extractable and cannot itself be re-wrapped;
103
+ * resetPassword re-derives the raw VMK from the recovery wrap and re-wraps it under the new password,
104
+ * so the vault is never re-encrypted. The recovery secret is zeroed once resetPassword runs — or, if
105
+ * the caller never calls resetPassword, once dispose() is called. If NEITHER is called, the 32-byte
106
+ * recovery secret persists in this session for its lifetime; discard the session promptly. */
107
+ async recoverWithPhrase({ email, phrase, }) {
108
+ const credentialId = blindIndexString(email);
109
+ const recovSecret = recoverySecret(phrase); // throws on bad checksum
110
+ const recoveryWrap = await this.transport.getWrap({ credentialId, method: 'recovery' });
111
+ if (!recoveryWrap)
112
+ throw new Error('tessera: no recovery wrap for this account');
113
+ const recoveryBlob = fromB64(recoveryWrap.blobB64);
114
+ const vmk = await openVaultKey(recoveryBlob, recovSecret, 'recovery'); // throws if phrase is wrong
115
+ const transport = this.transport;
116
+ return {
117
+ ...sessionFor(vmk, /* no OPAQUE session on the recovery path */ null),
118
+ async resetPassword(newPassword) {
119
+ const { exportKey } = await resetPasswordOpaque(transport, credentialId, newPassword);
120
+ try {
121
+ // Re-wrap the SAME VMK (re-derived from the recovery wrap) under the new export_key.
122
+ const newOpaqueWrap = await rewrapForMethod({ blob: recoveryBlob, secret: recovSecret, method: 'recovery' }, { secret: exportKey, method: 'opaque' });
123
+ await transport.putWraps({ credentialId, wraps: { opaque: b64(newOpaqueWrap) } });
124
+ }
125
+ finally {
126
+ exportKey.fill(0);
127
+ recovSecret.fill(0);
128
+ }
129
+ },
130
+ dispose() {
131
+ // Zero the recovery secret when finished WITHOUT re-keying. Idempotent with the resetPassword
132
+ // wipe; after this, resetPassword would fail (a zeroed secret cannot unwrap the recovery blob).
133
+ recovSecret.fill(0);
134
+ },
135
+ };
136
+ }
137
+ /** Enable passwordless unlock (ADDITIVE). RE-AUTHENTICATES with the password (a non-extractable
138
+ * session VMK cannot be re-wrapped), then re-wraps the VMK from the 'opaque' wrap into a 'webauthn'
139
+ * wrap keyed by the PRF output. `prf` runs the WebAuthn create() ceremony (see passkey.evaluatePrf).
140
+ * Both the export_key and the PRF output are zeroed after use. */
141
+ async enablePasskey({ email, password, prf, }) {
142
+ const credentialId = blindIndexString(email);
143
+ const { exportKey } = await loginOpaque(this.transport, credentialId, password); // re-auth
144
+ try {
145
+ const prfOutput = await prf();
146
+ try {
147
+ const opaqueWrap = await this.transport.getWrap({ credentialId, method: 'opaque' });
148
+ if (!opaqueWrap)
149
+ throw new Error('tessera: no opaque wrap for this account');
150
+ const webauthnWrap = await rewrapForMethod({ blob: fromB64(opaqueWrap.blobB64), secret: exportKey, method: 'opaque' }, { secret: prfOutput, method: 'webauthn' });
151
+ await this.transport.putWraps({ credentialId, wraps: { webauthn: b64(webauthnWrap) } });
152
+ }
153
+ finally {
154
+ prfOutput.fill(0);
155
+ }
156
+ }
157
+ finally {
158
+ exportKey.fill(0);
159
+ }
160
+ }
161
+ /** Passwordless unlock via the 'webauthn' wrap. `prf` runs the WebAuthn get() ceremony. No OPAQUE
162
+ * handshake on this path, so the Session's sessionKeyB64 is null. The PRF output is zeroed after. */
163
+ async unlockWithPasskey({ email, prf }) {
164
+ const credentialId = blindIndexString(email);
165
+ const prfOutput = await prf();
166
+ try {
167
+ const wrap = await this.transport.getWrap({ credentialId, method: 'webauthn' });
168
+ if (!wrap)
169
+ throw new Error('tessera: no passkey wrap for this account');
170
+ const vmk = await openVaultKey(fromB64(wrap.blobB64), prfOutput, 'webauthn');
171
+ return sessionFor(vmk, null); // no OPAQUE session on the passkey path
172
+ }
173
+ finally {
174
+ prfOutput.fill(0);
175
+ }
176
+ }
177
+ /** Change the password from a logged-in context. Re-authenticates with the OLD
178
+ * password, runs a fresh OPAQUE registration under the NEW password, and re-wraps
179
+ * the SAME VMK from the 'opaque' wrap into a new 'opaque' wrap under the new
180
+ * export_key. The vault is NEVER re-encrypted, and the recovery + passkey wraps
181
+ * (which wrap the same VMK) stay valid. Both export_keys are zeroed after use. */
182
+ async changePassword({ email, oldPassword, newPassword, }) {
183
+ const credentialId = blindIndexString(email);
184
+ const { exportKey: oldExport } = await loginOpaque(this.transport, credentialId, oldPassword); // re-auth
185
+ try {
186
+ const opaqueWrap = await this.transport.getWrap({ credentialId, method: 'opaque' });
187
+ if (!opaqueWrap)
188
+ throw new Error('tessera: no opaque wrap for this account');
189
+ const { exportKey: newExport } = await resetPasswordOpaque(this.transport, credentialId, newPassword);
190
+ try {
191
+ const newOpaqueWrap = await rewrapForMethod({ blob: fromB64(opaqueWrap.blobB64), secret: oldExport, method: 'opaque' }, { secret: newExport, method: 'opaque' });
192
+ await this.transport.putWraps({ credentialId, wraps: { opaque: b64(newOpaqueWrap) } });
193
+ }
194
+ finally {
195
+ newExport.fill(0);
196
+ }
197
+ }
198
+ finally {
199
+ oldExport.fill(0);
200
+ }
201
+ }
202
+ /** Rotate the recovery phrase from a logged-in context. Re-authenticates with the
203
+ * password, mints a fresh 24-word phrase, and re-wraps the SAME VMK from the
204
+ * 'opaque' wrap into a new 'recovery' wrap under the new phrase's secret. The vault
205
+ * is never re-encrypted, and the OLD phrase's wrap is overwritten. Returns the new
206
+ * phrase to show ONCE. export_key and recovery entropy are zeroed after use. */
207
+ async regenerateRecovery({ email, password, }) {
208
+ const credentialId = blindIndexString(email);
209
+ const { exportKey } = await loginOpaque(this.transport, credentialId, password);
210
+ let recovEntropy;
211
+ try {
212
+ const opaqueWrap = await this.transport.getWrap({ credentialId, method: 'opaque' });
213
+ if (!opaqueWrap)
214
+ throw new Error('tessera: no opaque wrap for this account');
215
+ const recoveryPhrase = newRecoveryPhrase();
216
+ recovEntropy = recoverySecret(recoveryPhrase);
217
+ const newRecoveryWrap = await rewrapForMethod({ blob: fromB64(opaqueWrap.blobB64), secret: exportKey, method: 'opaque' }, { secret: recovEntropy, method: 'recovery' });
218
+ await this.transport.putWraps({ credentialId, wraps: { recovery: b64(newRecoveryWrap) } });
219
+ return { recoveryPhrase };
220
+ }
221
+ finally {
222
+ exportKey.fill(0);
223
+ recovEntropy?.fill(0);
224
+ }
225
+ }
226
+ }
227
+ /** @internal Exposed for the recovery/passkey methods added in later tasks (not re-exported from index). */
228
+ export { sessionFor };
@@ -0,0 +1,39 @@
1
+ export interface Transport {
2
+ registerStart(req: {
3
+ requestB64: string;
4
+ credentialId: string;
5
+ }): Promise<{
6
+ responseB64: string;
7
+ }>;
8
+ registerFinish(req: {
9
+ credentialId: string;
10
+ uploadB64: string;
11
+ }): Promise<void>;
12
+ loginStart(req: {
13
+ requestB64: string;
14
+ credentialId: string;
15
+ }): Promise<{
16
+ loginId: string;
17
+ responseB64: string;
18
+ }>;
19
+ loginFinish(req: {
20
+ loginId: string;
21
+ finalizationB64: string;
22
+ }): Promise<{
23
+ sessionKeyB64: string;
24
+ }>;
25
+ replacePasswordFile(req: {
26
+ credentialId: string;
27
+ uploadB64: string;
28
+ }): Promise<void>;
29
+ putWraps(req: {
30
+ credentialId: string;
31
+ wraps: Record<string, string>;
32
+ }): Promise<void>;
33
+ getWrap(req: {
34
+ credentialId: string;
35
+ method: string;
36
+ }): Promise<{
37
+ blobB64: string;
38
+ } | null>;
39
+ }