@ciphera-net/tessera 0.2.0 → 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 +39 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +41 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/opaque.js +76 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -297,6 +297,45 @@ An envelope sealed under `"address"` cannot be opened under `"totp"` — wrong-c
|
|
|
297
297
|
|
|
298
298
|
For wrong-key, wrong-context, and GCM tag failure, `open` throws a generic `Error` — there is no specific class that reveals which check failed (no decryption oracle).
|
|
299
299
|
|
|
300
|
+
### 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
|
+
|
|
300
339
|
---
|
|
301
340
|
|
|
302
341
|
## Crypto parameters (pinned)
|
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
|
@@ -5,4 +5,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
|
@@ -4,4 +4,4 @@ export { init } from './wasm.js';
|
|
|
4
4
|
export { blindIndexString } from './blindIndex.js';
|
|
5
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.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -88,7 +142,17 @@ export async function recoveryLoginOpaque(t, blindIndex, phrasePassword) {
|
|
|
88
142
|
requestB64: toBase64Std(lh.request),
|
|
89
143
|
blindIndex,
|
|
90
144
|
});
|
|
91
|
-
|
|
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
|
+
}
|
|
92
156
|
try {
|
|
93
157
|
const finalizationB64 = toBase64Std(lf.finalization);
|
|
94
158
|
const exportKey = lf.exportKey;
|
|
@@ -121,7 +185,14 @@ export async function registerRecoveryIdentity(t, credentialIdB64, phrasePasswor
|
|
|
121
185
|
requestB64: toBase64Std(reg.request),
|
|
122
186
|
credentialId: credentialIdB64,
|
|
123
187
|
});
|
|
124
|
-
|
|
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
|
+
}
|
|
125
196
|
try {
|
|
126
197
|
const uploadB64 = toBase64Std(fin.upload);
|
|
127
198
|
fin.exportKey.fill(0); // materialised only to be zeroed; see above
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ciphera-net/tessera",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
|
-
"description": "Tessera browser SDK
|
|
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",
|