@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 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
- 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;
@@ -88,7 +142,17 @@ export async function recoveryLoginOpaque(t, blindIndex, phrasePassword) {
88
142
  requestB64: toBase64Std(lh.request),
89
143
  blindIndex,
90
144
  });
91
- const lf = lh.finish(phrasePassword, fromBase64Std(responseB64));
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
- const fin = reg.finish(phrasePassword, fromBase64Std(responseB64));
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.0",
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",