@ciphera-net/tessera 0.1.4 → 0.2.1

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
@@ -267,6 +297,45 @@ An envelope sealed under `"address"` cannot be opened under `"totp"` — wrong-c
267
297
 
268
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).
269
299
 
300
+ ### OPAQUE ceremony errors
301
+
302
+ The register/login ceremonies raise two further classes. Both carry a fixed
303
+ `tessera:`-prefixed message, so nothing from inside the WASM core can reach a UI.
304
+
305
+ | Class | When |
306
+ |---|---|
307
+ | `InvalidCredentialsError` | the password (or recovery phrase) did not open the OPAQUE envelope — i.e. it is wrong |
308
+ | `OpaqueProtocolError` | the ceremony failed for any other reason: malformed or tampered server response, serialization fault, internal library error. `cause` carries the original |
309
+
310
+ 🔑 **The server cannot tell a right password from a wrong one.** In OPAQUE only
311
+ the client can, at `login_finish`, when the envelope fails to open — so
312
+ `InvalidCredentialsError` is the canonical wrong-password signal for the whole
313
+ system, not any HTTP status.
314
+
315
+ ⚠️ **Distinguishing these two is deliberate, and folding them together is a
316
+ bug.** Reporting a broken ceremony as "wrong password" sends a user to reset a
317
+ password that was already correct, and hides a real fault behind the one message
318
+ nobody investigates. Unlike the vault-envelope errors above — which are kept
319
+ indistinguishable because telling them apart would be a decryption oracle — the
320
+ login outcome is safe to report: it describes a password the caller just typed.
321
+
322
+ ```ts
323
+ import { InvalidCredentialsError, OpaqueProtocolError } from '@ciphera-net/tessera'
324
+
325
+ try {
326
+ await tessera.login({ email, password })
327
+ } catch (e) {
328
+ if (e instanceof InvalidCredentialsError) setError('Incorrect email or password.')
329
+ else if (e instanceof OpaqueProtocolError) setError('Sign-in is temporarily unavailable.')
330
+ else throw e
331
+ }
332
+ ```
333
+
334
+ Before 0.2.1 these were not wrapped at all: the Rust core raises
335
+ `JsError::new(&format!("{e:?}"))`, so a wrong password surfaced as an `Error`
336
+ whose message was literally `Opaque(InvalidLoginError)` — and consumers rendered
337
+ that string to users.
338
+
270
339
  ---
271
340
 
272
341
  ## Crypto parameters (pinned)
@@ -337,7 +406,7 @@ AAD = [0x01] ‖ utf8(context)
337
406
  ### What the SDK does
338
407
 
339
408
  - 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.
409
+ - 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
410
  - VMK-wrap blobs stored server-side are opaque byte sequences. The server holds no plaintext passwords and no vault keys.
342
411
 
343
412
  ### What the SDK cannot guarantee
package/dist/errors.d.ts CHANGED
@@ -10,3 +10,37 @@ export declare class EmptyVaultKeyError extends Error {
10
10
  export declare class EmptyContextError extends Error {
11
11
  constructor();
12
12
  }
13
+ /**
14
+ * The password did not open the OPAQUE envelope — i.e. wrong password (or wrong
15
+ * account for this password file).
16
+ *
17
+ * 🔑 In OPAQUE the SERVER cannot tell a right password from a wrong one; only
18
+ * the client can, at `login_finish`, when the envelope fails to open. So this is
19
+ * the canonical wrong-password signal for the whole system, and it is raised
20
+ * here rather than by any HTTP status.
21
+ *
22
+ * ⚠️ Unlike the vault-envelope errors above — which are deliberately
23
+ * indistinguishable because telling them apart would be a decryption oracle —
24
+ * this one is safe to distinguish. It reports the outcome of a password the
25
+ * caller just supplied; it tells an attacker nothing they did not already know
26
+ * by typing it.
27
+ */
28
+ export declare class InvalidCredentialsError extends Error {
29
+ constructor();
30
+ }
31
+ /**
32
+ * The OPAQUE ceremony failed for a reason that is NOT a wrong password —
33
+ * a malformed or tampered server response, a serialization fault, or an
34
+ * internal library error.
35
+ *
36
+ * 🔴 This is deliberately NOT folded into `InvalidCredentialsError`. Reporting a
37
+ * broken ceremony as "wrong password" would send a user to reset a password that
38
+ * was already correct and would hide a real fault behind the one message nobody
39
+ * investigates. A wrong password is expected; this is not.
40
+ *
41
+ * `cause` carries the underlying error for logs. The MESSAGE is fixed and
42
+ * `tessera:`-prefixed so it can never carry WASM internals into a UI.
43
+ */
44
+ export declare class OpaqueProtocolError extends Error {
45
+ constructor(cause?: unknown);
46
+ }
package/dist/errors.js CHANGED
@@ -25,3 +25,44 @@ export class EmptyContextError extends Error {
25
25
  this.name = 'EmptyContextError';
26
26
  }
27
27
  }
28
+ /**
29
+ * The password did not open the OPAQUE envelope — i.e. wrong password (or wrong
30
+ * account for this password file).
31
+ *
32
+ * 🔑 In OPAQUE the SERVER cannot tell a right password from a wrong one; only
33
+ * the client can, at `login_finish`, when the envelope fails to open. So this is
34
+ * the canonical wrong-password signal for the whole system, and it is raised
35
+ * here rather than by any HTTP status.
36
+ *
37
+ * ⚠️ Unlike the vault-envelope errors above — which are deliberately
38
+ * indistinguishable because telling them apart would be a decryption oracle —
39
+ * this one is safe to distinguish. It reports the outcome of a password the
40
+ * caller just supplied; it tells an attacker nothing they did not already know
41
+ * by typing it.
42
+ */
43
+ export class InvalidCredentialsError extends Error {
44
+ constructor() {
45
+ super('tessera: invalid credentials');
46
+ this.name = 'InvalidCredentialsError';
47
+ }
48
+ }
49
+ /**
50
+ * The OPAQUE ceremony failed for a reason that is NOT a wrong password —
51
+ * a malformed or tampered server response, a serialization fault, or an
52
+ * internal library error.
53
+ *
54
+ * 🔴 This is deliberately NOT folded into `InvalidCredentialsError`. Reporting a
55
+ * broken ceremony as "wrong password" would send a user to reset a password that
56
+ * was already correct and would hide a real fault behind the one message nobody
57
+ * investigates. A wrong password is expected; this is not.
58
+ *
59
+ * `cause` carries the underlying error for logs. The MESSAGE is fixed and
60
+ * `tessera:`-prefixed so it can never carry WASM internals into a UI.
61
+ */
62
+ export class OpaqueProtocolError extends Error {
63
+ constructor(cause) {
64
+ super('tessera: OPAQUE ceremony failed');
65
+ this.name = 'OpaqueProtocolError';
66
+ this.cause = cause;
67
+ }
68
+ }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
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';
8
- export { UnsupportedVersionError, MalformedEnvelopeError, EmptyVaultKeyError, EmptyContextError, } from './errors.js';
8
+ export { UnsupportedVersionError, MalformedEnvelopeError, EmptyVaultKeyError, EmptyContextError, InvalidCredentialsError, OpaqueProtocolError, } from './errors.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
- export { UnsupportedVersionError, MalformedEnvelopeError, EmptyVaultKeyError, EmptyContextError, } from './errors.js';
7
+ export { UnsupportedVersionError, MalformedEnvelopeError, EmptyVaultKeyError, EmptyContextError, InvalidCredentialsError, OpaqueProtocolError, } 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
@@ -5,13 +5,52 @@
5
5
  // WASM Finish handles are freed once their bytes are consumed (zeroizes the in-WASM key copies).
6
6
  import { fromBase64Std, toBase64Std } from './encoding.js';
7
7
  import { createRegistrationHandle, createLoginHandle } from './wasm.js';
8
+ import { InvalidCredentialsError, OpaqueProtocolError } from './errors.js';
9
+ /**
10
+ * Translate a failure from inside the WASM core into this package's error
11
+ * taxonomy.
12
+ *
13
+ * 🔴 WHY THIS EXISTS. The Rust binding raises every error as
14
+ * `JsError::new(&format!("{e:?}"))` — a `Debug` rendering of the Rust enum. So a
15
+ * wrong password arrives in JavaScript as an `Error` whose message is literally
16
+ * `Opaque(InvalidLoginError)`. Nothing wrapped it, so it flowed through
17
+ * consumers' catch blocks and was rendered to users verbatim: id.ciphera.net's
18
+ * sign-in page showed `Opaque(InvalidLoginError)` in its error banner for the
19
+ * single most common failure in the product (observed 03-09-2026).
20
+ *
21
+ * 🔑 The classification is by Debug string because that is the only signal the
22
+ * boundary offers today. That is brittle by nature, so the DEFAULT is the safe
23
+ * one: anything unrecognised becomes `OpaqueProtocolError`, never
24
+ * `InvalidCredentialsError`. A future core release can emit a stable code (the
25
+ * Rust `TesseraError::code()` already returns `"invalid_credentials"`) and this
26
+ * can match on that instead — the exported types would not change.
27
+ *
28
+ * Either way, BOTH results carry a fixed `tessera:`-prefixed message, so no
29
+ * WASM-internal string can reach a UI again regardless of how the match goes.
30
+ */
31
+ function opaqueFailure(cause) {
32
+ const raw = cause instanceof Error ? cause.message : String(cause);
33
+ // `ProtocolError::InvalidLoginError` is the OPAQUE "envelope did not open"
34
+ // result — the definition of a wrong password.
35
+ if (/InvalidLoginError/.test(raw))
36
+ return new InvalidCredentialsError();
37
+ return new OpaqueProtocolError(cause);
38
+ }
8
39
  /** Drive OPAQUE registration. Returns the 64-byte export_key (CLIENT-ONLY). The server stores the
9
40
  * password file (void). */
10
41
  export async function registerOpaque(t, credentialId, password) {
11
42
  const reg = createRegistrationHandle(password);
12
43
  try {
13
44
  const { responseB64 } = await t.registerStart({ requestB64: toBase64Std(reg.request), credentialId });
14
- const fin = reg.finish(password, fromBase64Std(responseB64));
45
+ let fin;
46
+ try {
47
+ fin = reg.finish(password, fromBase64Std(responseB64));
48
+ }
49
+ catch (e) {
50
+ // Registration has no wrong-password case — the password is being SET —
51
+ // so any failure here is a protocol fault, never invalid credentials.
52
+ throw new OpaqueProtocolError(e);
53
+ }
15
54
  try {
16
55
  const uploadB64 = toBase64Std(fin.upload);
17
56
  const exportKey = fin.exportKey; // getter returns a fresh JS copy; caller owns/zeroes it
@@ -31,7 +70,15 @@ export async function loginOpaque(t, credentialId, password) {
31
70
  const lh = createLoginHandle(password);
32
71
  try {
33
72
  const { loginId, responseB64 } = await t.loginStart({ requestB64: toBase64Std(lh.request), credentialId });
34
- const lf = lh.finish(password, fromBase64Std(responseB64));
73
+ // 🔴 THE wrong-password site. The server cannot detect a bad password; this
74
+ // call is where it surfaces, as a Rust `Debug` string. Classify it.
75
+ let lf;
76
+ try {
77
+ lf = lh.finish(password, fromBase64Std(responseB64));
78
+ }
79
+ catch (e) {
80
+ throw opaqueFailure(e);
81
+ }
35
82
  try {
36
83
  const finalizationB64 = toBase64Std(lf.finalization);
37
84
  const exportKey = lf.exportKey;
@@ -52,7 +99,14 @@ export async function resetPasswordOpaque(t, credentialId, newPassword) {
52
99
  const reg = createRegistrationHandle(newPassword);
53
100
  try {
54
101
  const { responseB64 } = await t.registerStart({ requestB64: toBase64Std(reg.request), credentialId });
55
- const fin = reg.finish(newPassword, fromBase64Std(responseB64));
102
+ let fin;
103
+ try {
104
+ fin = reg.finish(newPassword, fromBase64Std(responseB64));
105
+ }
106
+ catch (e) {
107
+ // Setting a password, not proving one — no invalid-credentials case here.
108
+ throw new OpaqueProtocolError(e);
109
+ }
56
110
  try {
57
111
  const uploadB64 = toBase64Std(fin.upload);
58
112
  const exportKey = fin.exportKey;
@@ -67,3 +121,88 @@ export async function resetPasswordOpaque(t, credentialId, newPassword) {
67
121
  reg.free();
68
122
  }
69
123
  }
124
+ /**
125
+ * Drive OPAQUE login against the account's RECOVERY record.
126
+ *
127
+ * Structurally identical to `loginOpaque` — same handles, same wire encoding — but pointed at the
128
+ * recovery endpoints, because the two records must never be confused by the server. The password
129
+ * here is `recoveryPhrasePassword(phrase)`, NOT the account password and NOT the phrase entropy.
130
+ *
131
+ * Returns the reset token the server minted, plus the encrypted vault and the recovery wrap, which
132
+ * it releases only now that the ceremony has proved phrase possession.
133
+ *
134
+ * 🔑 The export_key is returned too and the caller owns zeroing it. It is NOT what opens the vault
135
+ * on this path — the `recovery` wrap is sealed under the phrase ENTROPY — but it is real key
136
+ * material and must not be left lying around.
137
+ */
138
+ export async function recoveryLoginOpaque(t, blindIndex, phrasePassword) {
139
+ const lh = createLoginHandle(phrasePassword);
140
+ try {
141
+ const { loginId, responseB64 } = await t.recoveryLoginStart({
142
+ requestB64: toBase64Std(lh.request),
143
+ blindIndex,
144
+ });
145
+ // The wrong-PHRASE site. Same mechanism as a wrong password: the server
146
+ // verified nothing about the phrase's correctness, the envelope did. R6's
147
+ // /recover depends on telling this apart from a broken ceremony — a user who
148
+ // mistyped a word must be told to retype it, not that recovery is down.
149
+ let lf;
150
+ try {
151
+ lf = lh.finish(phrasePassword, fromBase64Std(responseB64));
152
+ }
153
+ catch (e) {
154
+ throw opaqueFailure(e);
155
+ }
156
+ try {
157
+ const finalizationB64 = toBase64Std(lf.finalization);
158
+ const exportKey = lf.exportKey;
159
+ const res = await t.recoveryLoginFinish({ loginId, finalizationB64 });
160
+ return { exportKey, ...res };
161
+ }
162
+ finally {
163
+ lf.free();
164
+ }
165
+ }
166
+ finally {
167
+ lh.free();
168
+ }
169
+ }
170
+ /**
171
+ * Drive OPAQUE registration for the account's RECOVERY record.
172
+ *
173
+ * Returns the upload the server stores as the recovery password file, and the credential id it is
174
+ * keyed by. The caller pairs these with the VMK wrap in ONE `enrolRecoveryIdentity` call — see the
175
+ * Transport docs for why they may never be written separately.
176
+ *
177
+ * ⚠️ The export_key from this ceremony is deliberately DISCARDED. The `recovery` wrap is sealed
178
+ * under the phrase entropy, not under this key, so keeping it would be key material with no
179
+ * purpose — and a secret with no purpose is a secret waiting to be misused.
180
+ */
181
+ export async function registerRecoveryIdentity(t, credentialIdB64, phrasePassword) {
182
+ const reg = createRegistrationHandle(phrasePassword);
183
+ try {
184
+ const { responseB64 } = await t.registerStart({
185
+ requestB64: toBase64Std(reg.request),
186
+ credentialId: credentialIdB64,
187
+ });
188
+ let fin;
189
+ try {
190
+ fin = reg.finish(phrasePassword, fromBase64Std(responseB64));
191
+ }
192
+ catch (e) {
193
+ // Registering the recovery record — setting a credential, not proving one.
194
+ throw new OpaqueProtocolError(e);
195
+ }
196
+ try {
197
+ const uploadB64 = toBase64Std(fin.upload);
198
+ fin.exportKey.fill(0); // materialised only to be zeroed; see above
199
+ return { uploadB64 };
200
+ }
201
+ finally {
202
+ fin.free();
203
+ }
204
+ }
205
+ finally {
206
+ reg.free();
207
+ }
208
+ }
@@ -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,10 +1,10 @@
1
1
  {
2
2
  "name": "@ciphera-net/tessera",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
- "description": "Tessera browser SDK OPAQUE auth, blind index, vault, BIP-39 recovery, WebAuthn-PRF (WASM + WebCrypto).",
7
+ "description": "Tessera browser SDK \u2014 OPAQUE auth, blind index, vault, BIP-39 recovery, WebAuthn-PRF (WASM + WebCrypto).",
8
8
  "homepage": "https://ciphera.net",
9
9
  "repository": {
10
10
  "type": "git",
Binary file
Binary file