@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 +40 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/opaque.d.ts +34 -0
- package/dist/opaque.js +68 -0
- package/dist/recovery.d.ts +29 -0
- package/dist/recovery.js +39 -0
- package/dist/tessera.d.ts +50 -12
- package/dist/tessera.js +135 -45
- package/dist/transport.d.ts +54 -0
- package/package.json +1 -1
- package/wasm/node/tessera_bg.wasm +0 -0
- package/wasm/web/tessera_bg.wasm +0 -0
package/README.md
CHANGED
|
@@ -93,8 +93,8 @@ const transport: Transport = {
|
|
|
93
93
|
return res.json(); // { sessionKeyB64: string }
|
|
94
94
|
},
|
|
95
95
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
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
|
|
171
|
+
const { recoveryPhrase } = await t.enrolRecoveryIdentity({
|
|
164
172
|
email: 'alice@example.com',
|
|
165
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
//
|
|
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:** `
|
|
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
|
+
}
|
package/dist/recovery.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 it — which 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
|
-
|
|
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
|
-
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 it — which 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
|
-
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
312
|
+
entropy?.fill(0);
|
|
313
|
+
phrasePassword?.fill(0);
|
|
224
314
|
}
|
|
225
315
|
}
|
|
226
316
|
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -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
|
Binary file
|
package/wasm/web/tessera_bg.wasm
CHANGED
|
Binary file
|