@ciphera-net/tessera 0.1.4 → 0.2.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
@@ -93,8 +93,8 @@ const transport: Transport = {
93
93
  return res.json(); // { sessionKeyB64: string }
94
94
  },
95
95
 
96
- // Recovery password reset replaces the server-side password file after the OPAQUE re-enrollment
97
- // driven by recoverWithPhrase().resetPassword(). Vault content is untouched.
96
+ // Password reset outside recovery. The RECOVERY-driven reset goes through
97
+ // recoveryResetPassword() instead, which is authorised by a reset token and carries both wraps.
98
98
  async replacePasswordFile({ credentialId, uploadB64 }) {
99
99
  await fetch('/auth/reset-password', {
100
100
  method: 'POST',
@@ -159,21 +159,51 @@ const plaintext = await session.vault.open('address', envelope);
159
159
 
160
160
  ### 5. Recovery
161
161
 
162
+ Recovery runs a real OPAQUE ceremony against a **second identity** on the account, authenticated by
163
+ the phrase. The server verifies the phrase, can throttle guessing, and releases the vault and the
164
+ recovery wrap only afterwards.
165
+
166
+ **Enrol (or rotate) the recovery identity.** Needs the password — the VMK is non-extractable from a
167
+ session and has to be re-derived — and a fresh re-auth proof, because this installs a permanent
168
+ second way into the account.
169
+
162
170
  ```ts
163
- const rec = await t.recoverWithPhrase({
171
+ const { recoveryPhrase } = await t.enrolRecoveryIdentity({
164
172
  email: 'alice@example.com',
165
- phrase: 'word1 word2 ... word24', // the 24-word BIP-39 phrase from registration
173
+ password: new TextEncoder().encode('password'),
174
+ reauthToken, // purpose "enr", single-use
166
175
  });
176
+ // Show recoveryPhrase ONCE. It cannot be recovered from the server, which is the point.
177
+ ```
167
178
 
168
- // rec is a RecoverySession — a Session extended with resetPassword.
169
- // rec.sessionKeyB64 is null (no OPAQUE handshake on this path).
170
- // Vault is fully accessible while the session is alive.
179
+ **Recover with the phrase.**
180
+
181
+ ```ts
182
+ const rec = await t.recoverWithRecoveryIdentity({
183
+ email: 'alice@example.com',
184
+ phrase: 'word1 word2 ... word24',
185
+ });
186
+
187
+ // rec.sessionKeyB64 is null — recovery proves the phrase, it is not a login.
188
+ // rec.resetToken is the server's single-use evidence that the ceremony succeeded.
189
+ // The vault is fully accessible while the session is alive.
171
190
 
172
- // Optionally reset the password (re-keys auth; vault content is never re-encrypted).
173
191
  await rec.resetPassword(new TextEncoder().encode('new password'));
174
- // After resetPassword, the recovery secret is zeroed; calling resetPassword again will fail.
192
+ // Re-keys auth only; vault content is never re-encrypted. The recovery identity keeps working.
193
+ // If you do NOT reset, call rec.dispose() to zero the retained recovery secret.
175
194
  ```
176
195
 
196
+ > #### 🔴 Removed in 0.2.0
197
+ >
198
+ > **`recoverWithPhrase`** derived the recovery secret locally and unwrapped the VMK in the browser.
199
+ > The server never saw the phrase, so it could not verify it, could not rate-limit guessing, and had
200
+ > to hand the recovery wrap to whoever asked. Replaced by `recoverWithRecoveryIdentity`.
201
+ >
202
+ > **`regenerateRecovery`** rotated the phrase by writing the new wrap and nothing else, leaving the
203
+ > recovery record on the OLD phrase — the old phrase then passed the ceremony and could not decrypt,
204
+ > the new phrase failed it, and recovery was dead with no error at rotation time. Rotation now goes
205
+ > through `enrolRecoveryIdentity`, which writes the record and the wrap in one server-side statement.
206
+
177
207
  ### 6. Passkey (WebAuthn-PRF)
178
208
 
179
209
  ```ts
@@ -337,7 +367,7 @@ AAD = [0x01] ‖ utf8(context)
337
367
  ### What the SDK does
338
368
 
339
369
  - The vault master key (VMK) is held as a **non-extractable `CryptoKey`** inside the `Session` object. `extractable: false` prevents `crypto.subtle.exportKey` from returning the raw bytes.
340
- - The OPAQUE `export_key` (64 bytes) and the recovery entropy (32 bytes) transit WASM/JS linear memory transiently during unlock. On the register / login / passkey paths they are zeroed in `finally` blocks immediately after the VMK is wrapped or unwrapped. **Exception:** `recoverWithPhrase` retains the 32-byte recovery secret inside the returned `RecoverySession` — the non-extractable VMK cannot itself be re-wrapped, so `resetPassword` needs it. That secret is zeroed by `resetPassword` **or** by `RecoverySession.dispose()`; if you call neither, it persists for the session's lifetime (discard the session promptly). None of these values ever cross the network.
370
+ - The OPAQUE `export_key` (64 bytes) and the recovery entropy (32 bytes) transit WASM/JS linear memory transiently during unlock. On the register / login / passkey paths they are zeroed in `finally` blocks immediately after the VMK is wrapped or unwrapped. **Exception:** `recoverWithRecoveryIdentity` retains the 32-byte recovery entropy inside the returned session — the non-extractable VMK cannot itself be re-wrapped, so `resetPassword` needs it. That secret is zeroed by `resetPassword` **or** by `dispose()`; if you call neither, it persists for the session's lifetime (discard the session promptly). The phrase's aPAKE password and the ceremony's `export_key` are both zeroed before the call returns. None of these values ever cross the network.
341
371
  - VMK-wrap blobs stored server-side are opaque byte sequences. The server holds no plaintext passwords and no vault keys.
342
372
 
343
373
  ### What the SDK cannot guarantee
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { Tessera, type Session, type RecoverySession } from './tessera.js';
2
2
  export { init } from './wasm.js';
3
3
  export { blindIndexString } from './blindIndex.js';
4
- export { newRecoveryPhrase } from './recovery.js';
4
+ export { newRecoveryPhrase, recoveryPhrasePassword } from './recovery.js';
5
5
  export { isPasskeySupported, evaluatePrf, type PrfProvider, type PrfOptions, type PrfCreateOptions, type PrfGetOptions, } from './passkey.js';
6
6
  export type { Transport } from './transport.js';
7
7
  export type { UnlockMethod } from './vmk.js';
package/dist/index.js CHANGED
@@ -2,6 +2,6 @@
2
2
  export { Tessera } from './tessera.js';
3
3
  export { init } from './wasm.js';
4
4
  export { blindIndexString } from './blindIndex.js';
5
- export { newRecoveryPhrase } from './recovery.js';
5
+ export { newRecoveryPhrase, recoveryPhrasePassword } from './recovery.js';
6
6
  export { isPasskeySupported, evaluatePrf, } from './passkey.js';
7
7
  export { UnsupportedVersionError, MalformedEnvelopeError, EmptyVaultKeyError, EmptyContextError, } from './errors.js';
package/dist/opaque.d.ts CHANGED
@@ -14,3 +14,37 @@ export declare function loginOpaque(t: Transport, credentialId: string, password
14
14
  export declare function resetPasswordOpaque(t: Transport, credentialId: string, newPassword: Uint8Array): Promise<{
15
15
  exportKey: Uint8Array;
16
16
  }>;
17
+ /**
18
+ * Drive OPAQUE login against the account's RECOVERY record.
19
+ *
20
+ * Structurally identical to `loginOpaque` — same handles, same wire encoding — but pointed at the
21
+ * recovery endpoints, because the two records must never be confused by the server. The password
22
+ * here is `recoveryPhrasePassword(phrase)`, NOT the account password and NOT the phrase entropy.
23
+ *
24
+ * Returns the reset token the server minted, plus the encrypted vault and the recovery wrap, which
25
+ * it releases only now that the ceremony has proved phrase possession.
26
+ *
27
+ * 🔑 The export_key is returned too and the caller owns zeroing it. It is NOT what opens the vault
28
+ * on this path — the `recovery` wrap is sealed under the phrase ENTROPY — but it is real key
29
+ * material and must not be left lying around.
30
+ */
31
+ export declare function recoveryLoginOpaque(t: Transport, blindIndex: string, phrasePassword: Uint8Array): Promise<{
32
+ exportKey: Uint8Array;
33
+ resetToken: string;
34
+ encryptedVaultB64: string;
35
+ recoveryWrappedKeyB64: string;
36
+ }>;
37
+ /**
38
+ * Drive OPAQUE registration for the account's RECOVERY record.
39
+ *
40
+ * Returns the upload the server stores as the recovery password file, and the credential id it is
41
+ * keyed by. The caller pairs these with the VMK wrap in ONE `enrolRecoveryIdentity` call — see the
42
+ * Transport docs for why they may never be written separately.
43
+ *
44
+ * ⚠️ The export_key from this ceremony is deliberately DISCARDED. The `recovery` wrap is sealed
45
+ * under the phrase entropy, not under this key, so keeping it would be key material with no
46
+ * purpose — and a secret with no purpose is a secret waiting to be misused.
47
+ */
48
+ export declare function registerRecoveryIdentity(t: Transport, credentialIdB64: string, phrasePassword: Uint8Array): Promise<{
49
+ uploadB64: string;
50
+ }>;
package/dist/opaque.js CHANGED
@@ -67,3 +67,71 @@ export async function resetPasswordOpaque(t, credentialId, newPassword) {
67
67
  reg.free();
68
68
  }
69
69
  }
70
+ /**
71
+ * Drive OPAQUE login against the account's RECOVERY record.
72
+ *
73
+ * Structurally identical to `loginOpaque` — same handles, same wire encoding — but pointed at the
74
+ * recovery endpoints, because the two records must never be confused by the server. The password
75
+ * here is `recoveryPhrasePassword(phrase)`, NOT the account password and NOT the phrase entropy.
76
+ *
77
+ * Returns the reset token the server minted, plus the encrypted vault and the recovery wrap, which
78
+ * it releases only now that the ceremony has proved phrase possession.
79
+ *
80
+ * 🔑 The export_key is returned too and the caller owns zeroing it. It is NOT what opens the vault
81
+ * on this path — the `recovery` wrap is sealed under the phrase ENTROPY — but it is real key
82
+ * material and must not be left lying around.
83
+ */
84
+ export async function recoveryLoginOpaque(t, blindIndex, phrasePassword) {
85
+ const lh = createLoginHandle(phrasePassword);
86
+ try {
87
+ const { loginId, responseB64 } = await t.recoveryLoginStart({
88
+ requestB64: toBase64Std(lh.request),
89
+ blindIndex,
90
+ });
91
+ const lf = lh.finish(phrasePassword, fromBase64Std(responseB64));
92
+ try {
93
+ const finalizationB64 = toBase64Std(lf.finalization);
94
+ const exportKey = lf.exportKey;
95
+ const res = await t.recoveryLoginFinish({ loginId, finalizationB64 });
96
+ return { exportKey, ...res };
97
+ }
98
+ finally {
99
+ lf.free();
100
+ }
101
+ }
102
+ finally {
103
+ lh.free();
104
+ }
105
+ }
106
+ /**
107
+ * Drive OPAQUE registration for the account's RECOVERY record.
108
+ *
109
+ * Returns the upload the server stores as the recovery password file, and the credential id it is
110
+ * keyed by. The caller pairs these with the VMK wrap in ONE `enrolRecoveryIdentity` call — see the
111
+ * Transport docs for why they may never be written separately.
112
+ *
113
+ * ⚠️ The export_key from this ceremony is deliberately DISCARDED. The `recovery` wrap is sealed
114
+ * under the phrase entropy, not under this key, so keeping it would be key material with no
115
+ * purpose — and a secret with no purpose is a secret waiting to be misused.
116
+ */
117
+ export async function registerRecoveryIdentity(t, credentialIdB64, phrasePassword) {
118
+ const reg = createRegistrationHandle(phrasePassword);
119
+ try {
120
+ const { responseB64 } = await t.registerStart({
121
+ requestB64: toBase64Std(reg.request),
122
+ credentialId: credentialIdB64,
123
+ });
124
+ const fin = reg.finish(phrasePassword, fromBase64Std(responseB64));
125
+ try {
126
+ const uploadB64 = toBase64Std(fin.upload);
127
+ fin.exportKey.fill(0); // materialised only to be zeroed; see above
128
+ return { uploadB64 };
129
+ }
130
+ finally {
131
+ fin.free();
132
+ }
133
+ }
134
+ finally {
135
+ reg.free();
136
+ }
137
+ }
@@ -5,3 +5,32 @@ export declare function newRecoveryPhrase(): string;
5
5
  /** The 'recovery' VMK-wrap secret = the 32-byte BIP-39 entropy. Throws on an invalid-checksum phrase.
6
6
  * CALLER must zero the returned buffer after wrapping/unwrapping. */
7
7
  export declare function recoverySecret(phrase: string): Uint8Array;
8
+ /**
9
+ * The recovery phrase, encoded as the aPAKE password for the account's SECOND OPAQUE identity.
10
+ *
11
+ * 🔴 THIS IS A WIRE CONTRACT. It is what the browser hands to OPAQUE when it registers or logs in
12
+ * against the recovery identity, and the server stores only the resulting password file. Change a
13
+ * byte of it after anyone has enrolled and every enrolled recovery identity becomes unopenable —
14
+ * silently, because a wrong password is indistinguishable from a wrong phrase. It is pinned by a
15
+ * known-answer vector (`recoveryPhrasePassword` in `test/vectors.test.ts`) for exactly that reason,
16
+ * in the same spirit as `@ciphera-net/auth`'s blind-index KAT.
17
+ *
18
+ * The encoding is: **NFKD-normalise, split on any whitespace, re-join with single U+0020 spaces,
19
+ * take the UTF-8 bytes.**
20
+ *
21
+ * - **NFKD** because a mnemonic may be typed, pasted or autocorrected on a platform that composes
22
+ * accents differently. BIP-39 itself mandates NFKD for exactly this reason, and the English
23
+ * wordlist is pure ASCII so this is a no-op for us today — it is here so a future non-English
24
+ * wordlist cannot silently break every enrolled identity.
25
+ * - **Re-joining on single spaces** normalises the whitespace a human paste introduces: a trailing
26
+ * newline, a double space, a non-breaking space from a PDF. Without it, two people typing the
27
+ * same 24 words produce different passwords.
28
+ *
29
+ * 🔑 IT IS DELIBERATELY THE WORDS, NOT THE ENTROPY. The 32-byte entropy is already the `recovery`
30
+ * VMK-WRAP secret. Reusing it as the aPAKE password would couple the two secrets for no gain: an
31
+ * attacker who obtained one would hold the other, and the wrap secret would then transit the OPAQUE
32
+ * ceremony. They are separate derivations of the same phrase, on purpose.
33
+ *
34
+ * Returns a fresh buffer the CALLER should zero after use.
35
+ */
36
+ export declare function recoveryPhrasePassword(phrase: string): Uint8Array;
package/dist/recovery.js CHANGED
@@ -17,3 +17,42 @@ export function recoverySecret(phrase) {
17
17
  throw new Error('tessera: invalid recovery phrase');
18
18
  return mnemonicToEntropy(phrase, wordlist); // 32 bytes
19
19
  }
20
+ /**
21
+ * The recovery phrase, encoded as the aPAKE password for the account's SECOND OPAQUE identity.
22
+ *
23
+ * 🔴 THIS IS A WIRE CONTRACT. It is what the browser hands to OPAQUE when it registers or logs in
24
+ * against the recovery identity, and the server stores only the resulting password file. Change a
25
+ * byte of it after anyone has enrolled and every enrolled recovery identity becomes unopenable —
26
+ * silently, because a wrong password is indistinguishable from a wrong phrase. It is pinned by a
27
+ * known-answer vector (`recoveryPhrasePassword` in `test/vectors.test.ts`) for exactly that reason,
28
+ * in the same spirit as `@ciphera-net/auth`'s blind-index KAT.
29
+ *
30
+ * The encoding is: **NFKD-normalise, split on any whitespace, re-join with single U+0020 spaces,
31
+ * take the UTF-8 bytes.**
32
+ *
33
+ * - **NFKD** because a mnemonic may be typed, pasted or autocorrected on a platform that composes
34
+ * accents differently. BIP-39 itself mandates NFKD for exactly this reason, and the English
35
+ * wordlist is pure ASCII so this is a no-op for us today — it is here so a future non-English
36
+ * wordlist cannot silently break every enrolled identity.
37
+ * - **Re-joining on single spaces** normalises the whitespace a human paste introduces: a trailing
38
+ * newline, a double space, a non-breaking space from a PDF. Without it, two people typing the
39
+ * same 24 words produce different passwords.
40
+ *
41
+ * 🔑 IT IS DELIBERATELY THE WORDS, NOT THE ENTROPY. The 32-byte entropy is already the `recovery`
42
+ * VMK-WRAP secret. Reusing it as the aPAKE password would couple the two secrets for no gain: an
43
+ * attacker who obtained one would hold the other, and the wrap secret would then transit the OPAQUE
44
+ * ceremony. They are separate derivations of the same phrase, on purpose.
45
+ *
46
+ * Returns a fresh buffer the CALLER should zero after use.
47
+ */
48
+ export function recoveryPhrasePassword(phrase) {
49
+ // 🔴 CANONICALISE FIRST, THEN VALIDATE. Validating the raw string would reject exactly the input
50
+ // this function exists to accept — a real paste, with a leading space, a tab between two words or
51
+ // a trailing newline. Validate the bytes that are actually going to be used, never a different
52
+ // string that merely resembles them.
53
+ const canonical = phrase.normalize('NFKD').split(/\s+/u).filter(Boolean).join(' ');
54
+ if (!validateMnemonic(canonical, wordlist)) {
55
+ throw new Error('tessera: invalid recovery phrase');
56
+ }
57
+ return new TextEncoder().encode(canonical);
58
+ }
package/dist/tessera.d.ts CHANGED
@@ -57,17 +57,17 @@ export declare class Tessera {
57
57
  email: string;
58
58
  password: Uint8Array;
59
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>;
60
+ /**
61
+ * 🔴 `recoverWithPhrase` WAS REMOVED IN 0.2.0. Use `recoverWithRecoveryIdentity`.
62
+ *
63
+ * The old method derived the recovery secret from the phrase LOCALLY and unwrapped the VMK in the
64
+ * browser. The server never saw the phrase and therefore never verified itwhich meant it could
65
+ * not rate-limit guessing, and had to hand the recovery wrap to whoever asked. That is the design
66
+ * the 08-08-2026 audit condemned, and it does not ship in a public Apache-2.0 package.
67
+ *
68
+ * The replacement runs a real OPAQUE ceremony against a SECOND identity on the account. The server
69
+ * verifies the phrase, throttles attempts, and releases the vault and the wrap only afterwards.
70
+ */
71
71
  /** Enable passwordless unlock (ADDITIVE). RE-AUTHENTICATES with the password (a non-extractable
72
72
  * session VMK cannot be re-wrapped), then re-wraps the VMK from the 'opaque' wrap into a 'webauthn'
73
73
  * wrap keyed by the PRF output. `prf` runs the WebAuthn create() ceremony (see passkey.evaluatePrf).
@@ -98,9 +98,47 @@ export declare class Tessera {
98
98
  * 'opaque' wrap into a new 'recovery' wrap under the new phrase's secret. The vault
99
99
  * is never re-encrypted, and the OLD phrase's wrap is overwritten. Returns the new
100
100
  * phrase to show ONCE. export_key and recovery entropy are zeroed after use. */
101
- regenerateRecovery({ email, password, }: {
101
+ /**
102
+ * Recover with the phrase, via the account's RECOVERY OPAQUE identity.
103
+ *
104
+ * Replaces `recoverWithPhrase`. The difference is not cosmetic: the server now runs an OPAQUE
105
+ * ceremony against a second record, so it VERIFIES the phrase, can throttle guesses, and releases
106
+ * the encrypted vault and the recovery wrap only after that ceremony succeeds. The old method
107
+ * proved nothing to anyone and required the wrap to be readable by whoever asked.
108
+ *
109
+ * 🔑 Two secrets, both from the same phrase, deliberately kept apart:
110
+ * - `recoveryPhrasePassword(phrase)` authenticates — it goes into OPAQUE.
111
+ * - `recoverySecret(phrase)` (the BIP-39 entropy) decrypts — it opens the `recovery` wrap.
112
+ * Reusing one for both would put the wrap secret through the ceremony and couple them.
113
+ *
114
+ * The returned `resetToken` is what `POST /auth/recovery/opaque/reset` requires; it is the
115
+ * server's evidence that this caller proved possession, and it is single-use.
116
+ */
117
+ recoverWithRecoveryIdentity({ email, phrase, }: {
118
+ email: string;
119
+ phrase: string;
120
+ }): Promise<RecoverySession & {
121
+ resetToken: string;
122
+ encryptedVaultB64: string;
123
+ }>;
124
+ /**
125
+ * Enrol (or ROTATE) the account's recovery identity.
126
+ *
127
+ * Mints a fresh phrase, registers it as a second OPAQUE identity, and re-wraps the SAME VMK under
128
+ * its entropy — then sends the record and the wrap in ONE request, because an account holding one
129
+ * without the other passes recovery login and cannot decrypt.
130
+ *
131
+ * Requires the password: the VMK is non-extractable from a session, so it has to be re-derived
132
+ * from a live ceremony. `reauthToken` is a fresh proof for purpose `enr` — replacing this record
133
+ * installs a permanent second way into the account, so a stolen session must not be enough.
134
+ *
135
+ * Returns the new phrase. Show it ONCE; it cannot be recovered from the server, which is the
136
+ * point.
137
+ */
138
+ enrolRecoveryIdentity({ email, password, reauthToken, }: {
102
139
  email: string;
103
140
  password: Uint8Array;
141
+ reauthToken: string;
104
142
  }): Promise<{
105
143
  recoveryPhrase: string;
106
144
  }>;
package/dist/tessera.js CHANGED
@@ -3,11 +3,12 @@
3
3
  // — they never persist and never cross the wire. The VMK is held as a non-extractable CryptoKey inside
4
4
  // the returned Session; the raw VMK never leaves WASM/JS linear memory at rest.
5
5
  import { blindIndexString } from './blindIndex.js';
6
- import { loginOpaque, registerOpaque, resetPasswordOpaque } from './opaque.js';
6
+ import { loginOpaque, registerOpaque, resetPasswordOpaque, recoveryLoginOpaque, registerRecoveryIdentity, } from './opaque.js';
7
7
  import { generateAndWrap, openVaultKey, rewrapForMethod } from './vmk.js';
8
- import { newRecoveryPhrase, recoverySecret } from './recovery.js';
8
+ import { newRecoveryPhrase, recoverySecret, recoveryPhrasePassword } from './recovery.js';
9
9
  import { open as vaultOpen, seal as vaultSeal } from './vault.js';
10
10
  import { fromBase64Std, toBase64Std } from './encoding.js';
11
+ import { createRegistrationHandle } from './wasm.js';
11
12
  // VMK-wrap blobs are stored as standard base64 (they are opaque server storage, not OPAQUE wire blobs).
12
13
  const b64 = toBase64Std;
13
14
  const fromB64 = fromBase64Std;
@@ -97,43 +98,17 @@ export class Tessera {
97
98
  exportKey.fill(0);
98
99
  }
99
100
  }
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
- }
101
+ /**
102
+ * 🔴 `recoverWithPhrase` WAS REMOVED IN 0.2.0. Use `recoverWithRecoveryIdentity`.
103
+ *
104
+ * The old method derived the recovery secret from the phrase LOCALLY and unwrapped the VMK in the
105
+ * browser. The server never saw the phrase and therefore never verified itwhich meant it could
106
+ * not rate-limit guessing, and had to hand the recovery wrap to whoever asked. That is the design
107
+ * the 08-08-2026 audit condemned, and it does not ship in a public Apache-2.0 package.
108
+ *
109
+ * The replacement runs a real OPAQUE ceremony against a SECOND identity on the account. The server
110
+ * verifies the phrase, throttles attempts, and releases the vault and the wrap only afterwards.
111
+ */
137
112
  /** Enable passwordless unlock (ADDITIVE). RE-AUTHENTICATES with the password (a non-extractable
138
113
  * session VMK cannot be re-wrapped), then re-wraps the VMK from the 'opaque' wrap into a 'webauthn'
139
114
  * wrap keyed by the PRF output. `prf` runs the WebAuthn create() ceremony (see passkey.evaluatePrf).
@@ -204,23 +179,138 @@ export class Tessera {
204
179
  * 'opaque' wrap into a new 'recovery' wrap under the new phrase's secret. The vault
205
180
  * is never re-encrypted, and the OLD phrase's wrap is overwritten. Returns the new
206
181
  * phrase to show ONCE. export_key and recovery entropy are zeroed after use. */
207
- async regenerateRecovery({ email, password, }) {
182
+ /**
183
+ * Recover with the phrase, via the account's RECOVERY OPAQUE identity.
184
+ *
185
+ * Replaces `recoverWithPhrase`. The difference is not cosmetic: the server now runs an OPAQUE
186
+ * ceremony against a second record, so it VERIFIES the phrase, can throttle guesses, and releases
187
+ * the encrypted vault and the recovery wrap only after that ceremony succeeds. The old method
188
+ * proved nothing to anyone and required the wrap to be readable by whoever asked.
189
+ *
190
+ * 🔑 Two secrets, both from the same phrase, deliberately kept apart:
191
+ * - `recoveryPhrasePassword(phrase)` authenticates — it goes into OPAQUE.
192
+ * - `recoverySecret(phrase)` (the BIP-39 entropy) decrypts — it opens the `recovery` wrap.
193
+ * Reusing one for both would put the wrap secret through the ceremony and couple them.
194
+ *
195
+ * The returned `resetToken` is what `POST /auth/recovery/opaque/reset` requires; it is the
196
+ * server's evidence that this caller proved possession, and it is single-use.
197
+ */
198
+ async recoverWithRecoveryIdentity({ email, phrase, }) {
199
+ const blindIndex = blindIndexString(email);
200
+ const phrasePassword = recoveryPhrasePassword(phrase); // throws on a bad checksum
201
+ let entropy;
202
+ let exportKey;
203
+ try {
204
+ const res = await recoveryLoginOpaque(this.transport, blindIndex, phrasePassword);
205
+ exportKey = res.exportKey;
206
+ if (!res.recoveryWrappedKeyB64) {
207
+ // The server proved the phrase but holds no wrap for it. That is the half-written state
208
+ // ciphera-id#68 made unrepresentable going forward; say so plainly rather than throwing a
209
+ // decryption error the user would read as "wrong phrase".
210
+ throw new Error('tessera: this account has a recovery identity but no recovery wrap');
211
+ }
212
+ entropy = recoverySecret(phrase);
213
+ const recoveryBlob = fromB64(res.recoveryWrappedKeyB64);
214
+ const vmk = await openVaultKey(recoveryBlob, entropy, 'recovery');
215
+ // Retained for resetPassword ONLY, and zeroed by it or by dispose(). The session VMK is a
216
+ // non-extractable CryptoKey and cannot be re-wrapped, so the reset has to re-derive the raw
217
+ // key from this blob — the same reason the old API held it, on a mechanism that now has the
218
+ // server's verification in front of it.
219
+ const heldEntropy = entropy;
220
+ entropy = undefined; // ownership moves into the closures below
221
+ const transport = this.transport;
222
+ const { resetToken, encryptedVaultB64 } = res;
223
+ return {
224
+ // No OPAQUE session key on this path: recovery proves the phrase, not a login.
225
+ ...sessionFor(vmk, null),
226
+ resetToken,
227
+ encryptedVaultB64,
228
+ async resetPassword(newPassword) {
229
+ const reg = createRegistrationHandle(newPassword);
230
+ try {
231
+ const { responseB64 } = await transport.registerStart({
232
+ requestB64: toBase64Std(reg.request),
233
+ credentialId: blindIndex,
234
+ });
235
+ const fin = reg.finish(newPassword, fromBase64Std(responseB64));
236
+ try {
237
+ // Re-wrap the SAME VMK under the new export_key. The vault is never re-encrypted.
238
+ const newOpaqueWrap = await rewrapForMethod({ blob: recoveryBlob, secret: heldEntropy, method: 'recovery' }, { secret: fin.exportKey, method: 'opaque' });
239
+ // 🔴 BOTH wraps travel. Sending only the opaque one would leave the account with a
240
+ // recovery record whose wrap the server no longer holds — the exact half-written
241
+ // state ciphera-id#68 exists to prevent, arrived at from the other side.
242
+ await transport.recoveryResetPassword({
243
+ resetToken,
244
+ blindIndex,
245
+ encryptedVaultB64,
246
+ opaqueWrappedKeyB64: b64(newOpaqueWrap),
247
+ registrationUploadB64: toBase64Std(fin.upload),
248
+ credentialIdB64: blindIndex,
249
+ recoveryWrappedKeyB64: b64(recoveryBlob),
250
+ });
251
+ }
252
+ finally {
253
+ fin.free();
254
+ }
255
+ }
256
+ finally {
257
+ reg.free();
258
+ heldEntropy.fill(0);
259
+ }
260
+ },
261
+ dispose() {
262
+ heldEntropy.fill(0);
263
+ },
264
+ };
265
+ }
266
+ finally {
267
+ phrasePassword.fill(0);
268
+ entropy?.fill(0);
269
+ exportKey?.fill(0);
270
+ }
271
+ }
272
+ /**
273
+ * Enrol (or ROTATE) the account's recovery identity.
274
+ *
275
+ * Mints a fresh phrase, registers it as a second OPAQUE identity, and re-wraps the SAME VMK under
276
+ * its entropy — then sends the record and the wrap in ONE request, because an account holding one
277
+ * without the other passes recovery login and cannot decrypt.
278
+ *
279
+ * Requires the password: the VMK is non-extractable from a session, so it has to be re-derived
280
+ * from a live ceremony. `reauthToken` is a fresh proof for purpose `enr` — replacing this record
281
+ * installs a permanent second way into the account, so a stolen session must not be enough.
282
+ *
283
+ * Returns the new phrase. Show it ONCE; it cannot be recovered from the server, which is the
284
+ * point.
285
+ */
286
+ async enrolRecoveryIdentity({ email, password, reauthToken, }) {
208
287
  const credentialId = blindIndexString(email);
209
288
  const { exportKey } = await loginOpaque(this.transport, credentialId, password);
210
- let recovEntropy;
289
+ let entropy;
290
+ let phrasePassword;
211
291
  try {
212
292
  const opaqueWrap = await this.transport.getWrap({ credentialId, method: 'opaque' });
213
293
  if (!opaqueWrap)
214
294
  throw new Error('tessera: no opaque wrap for this account');
215
295
  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) } });
296
+ entropy = recoverySecret(recoveryPhrase);
297
+ phrasePassword = recoveryPhrasePassword(recoveryPhrase);
298
+ // Re-wrap the SAME VMK. The vault is never re-encrypted.
299
+ const recoveryWrap = await rewrapForMethod({ blob: fromB64(opaqueWrap.blobB64), secret: exportKey, method: 'opaque' }, { secret: entropy, method: 'recovery' });
300
+ const { uploadB64 } = await registerRecoveryIdentity(this.transport, credentialId, phrasePassword);
301
+ // One call: record + wrap, or neither.
302
+ await this.transport.enrolRecoveryIdentity({
303
+ uploadB64,
304
+ credentialIdB64: credentialId,
305
+ recoveryWrappedKeyB64: b64(recoveryWrap),
306
+ reauthToken,
307
+ });
219
308
  return { recoveryPhrase };
220
309
  }
221
310
  finally {
222
311
  exportKey.fill(0);
223
- recovEntropy?.fill(0);
312
+ entropy?.fill(0);
313
+ phrasePassword?.fill(0);
224
314
  }
225
315
  }
226
316
  }
@@ -36,4 +36,58 @@ export interface Transport {
36
36
  }): Promise<{
37
37
  blobB64: string;
38
38
  } | null>;
39
+ /** First OPAQUE message against the account's RECOVERY record.
40
+ *
41
+ * Like `loginStart`, this MUST always resolve for a well-formed request — an unknown account
42
+ * gets a timing-safe dummy — or it becomes an account-existence oracle for anyone who can guess
43
+ * an email. */
44
+ recoveryLoginStart(req: {
45
+ blindIndex: string;
46
+ requestB64: string;
47
+ }): Promise<{
48
+ loginId: string;
49
+ responseB64: string;
50
+ }>;
51
+ /** Second OPAQUE message. The vault and the recovery wrap come back ONLY here, AFTER the ceremony
52
+ * has proved possession of the phrase — they used to be handed to any unauthenticated caller,
53
+ * which is what made the old endpoint a disclosure as well as an oracle. */
54
+ recoveryLoginFinish(req: {
55
+ loginId: string;
56
+ finalizationB64: string;
57
+ }): Promise<{
58
+ resetToken: string;
59
+ encryptedVaultB64: string;
60
+ recoveryWrappedKeyB64: string;
61
+ }>;
62
+ /** Register (or REPLACE) the recovery identity, together with the VMK wrap sealed under the same
63
+ * phrase.
64
+ *
65
+ * 🔴 BOTH IN ONE CALL, deliberately. The record is what the phrase authenticates against and the
66
+ * wrap is the vault key sealed under it; an account holding one without a matching other passes
67
+ * recovery login and then cannot decrypt. The server writes them in a single UPDATE for the same
68
+ * reason (ciphera-id#68).
69
+ *
70
+ * `reauthToken` is a fresh password proof — replacing this record installs a permanent second
71
+ * credential, so a live session alone must not be enough. */
72
+ enrolRecoveryIdentity(req: {
73
+ uploadB64: string;
74
+ credentialIdB64: string;
75
+ recoveryWrappedKeyB64: string;
76
+ reauthToken: string;
77
+ }): Promise<void>;
78
+ /** Set a new password after a recovery ceremony, in ONE server-side transaction.
79
+ *
80
+ * `resetToken` is the entire authorisation — it is minted only by a completed recovery login and
81
+ * is single-use. Everything else is the re-registered account: a new OPAQUE record, the vault
82
+ * re-sealed, and BOTH wraps carried forward so the account is not left holding one without the
83
+ * other. */
84
+ recoveryResetPassword(req: {
85
+ resetToken: string;
86
+ blindIndex: string;
87
+ encryptedVaultB64: string;
88
+ opaqueWrappedKeyB64: string;
89
+ registrationUploadB64: string;
90
+ credentialIdB64: string;
91
+ recoveryWrappedKeyB64: string;
92
+ }): Promise<void>;
39
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphera-net/tessera",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
Binary file
Binary file