@ciphera-net/tessera 0.1.3

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/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 Ciphera
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,380 @@
1
+ # @ciphera-net/tessera
2
+
3
+ Browser SDK for the Tessera authentication and vault system.
4
+
5
+ > **Self-reviewed, not independently audited.** Apache-2.0. Read the
6
+ > [security model](https://github.com/ciphera-net/tessera/blob/main/docs/THREAT-MODEL.md) and
7
+ > [self-audit](https://github.com/ciphera-net/tessera/blob/main/docs/SELF-AUDIT.md) before relying on it.
8
+
9
+ ---
10
+
11
+ ## What it is
12
+
13
+ `@ciphera-net/tessera` is a browser-first TypeScript SDK that implements:
14
+
15
+ - **OPAQUE (RFC 9807) password authentication** — the password never reaches the server in any form; the server stores only an opaque OPAQUE registration record (password file) that cannot be reversed into the password. The `export_key` produced by the OPAQUE handshake is used exclusively to wrap the vault master key (VMK), then zeroed.
16
+ - **Argon2id blind index** — a deterministic, pseudonymous lookup key derived from the email address (Argon2id/v0x13/m=64 MiB/t=3/p=1, domain-separated salt). Used as the server-side `credentialId`; the plaintext email never hits the database.
17
+ - **AES-256-GCM vault** — a two-layer envelope (wrapped DEK under a per-context KEK). Context is required on every seal/open call and is bound into the key derivation and AAD; it is never stored in the envelope.
18
+ - **BIP-39 (24-word / 256-bit) recovery phrase** — the mnemonic entropy is used directly as the VMK-wrap secret (no PBKDF2 indirection). Shown to the user once at registration.
19
+ - **WebAuthn-PRF passwordless unlock** — additive. The password and recovery phrase remain valid at all times; an authenticator without PRF support simply cannot enroll the passkey path.
20
+
21
+ The SDK is byte-for-byte interoperable with **tessera-go** (the Go server SDK) and the **Rust sidecar**. All three implementations share the pinned algorithm constants in the canonical conformance kit at `ciphera-net/tessera` (`conformance/schema.md` + `conformance/CONFORMANCE.md`) and are validated by cross-language parity vectors in `conformance/vectors/`.
22
+
23
+ ---
24
+
25
+ ## Install
26
+
27
+ ```
28
+ npm i @ciphera-net/tessera
29
+ ```
30
+
31
+ > Published to npm as [`@ciphera-net/tessera`](https://www.npmjs.com/package/@ciphera-net/tessera) (public, no auth required).
32
+
33
+ **Peer requirement:** the SDK is transport-agnostic. Your application must supply a `Transport` implementation that relays OPAQUE blobs and VMK-wrap storage to your backend (which fronts tessera-go → the Rust sidecar). See [Transport](#transport) below.
34
+
35
+ ---
36
+
37
+ ## Quick start
38
+
39
+ ### 1. Initialize the WASM module
40
+
41
+ ```ts
42
+ import { init, Tessera } from '@ciphera-net/tessera';
43
+
44
+ // Must resolve before any WASM-backed API (blind index, OPAQUE handles).
45
+ // Safe to call multiple times — idempotent.
46
+ await init();
47
+ ```
48
+
49
+ **Browser:** loads the `web` wasm-pack target (ESM + async fetch of the `.wasm` binary).
50
+ **Node (tests/SSR):** loads the `nodejs` wasm-pack target (CommonJS, auto-initialized on require — no separate async init needed, but calling `init()` is still safe and recommended for uniform code paths).
51
+
52
+ ### 2. Implement Transport
53
+
54
+ ```ts
55
+ import type { Transport } from '@ciphera-net/tessera';
56
+
57
+ const transport: Transport = {
58
+ // OPAQUE registration — phase 1: send the client request, receive the server response.
59
+ async registerStart({ requestB64, credentialId }) {
60
+ const res = await fetch('/auth/register/start', {
61
+ method: 'POST',
62
+ body: JSON.stringify({ requestB64, credentialId }),
63
+ });
64
+ return res.json(); // { responseB64: string }
65
+ },
66
+
67
+ // OPAQUE registration — phase 2: send the finalization upload. The server stores the password
68
+ // file; it is never returned to the browser.
69
+ async registerFinish({ credentialId, uploadB64 }) {
70
+ await fetch('/auth/register/finish', {
71
+ method: 'POST',
72
+ body: JSON.stringify({ credentialId, uploadB64 }),
73
+ });
74
+ },
75
+
76
+ // OPAQUE login — phase 1. passwordFile is nullable: an unknown account passes null to the
77
+ // sidecar for a timing-safe dummy response. The browser cannot distinguish existing from
78
+ // non-existing accounts.
79
+ async loginStart({ requestB64, credentialId }) {
80
+ const res = await fetch('/auth/login/start', {
81
+ method: 'POST',
82
+ body: JSON.stringify({ requestB64, credentialId }),
83
+ });
84
+ return res.json(); // { loginId: string; responseB64: string }
85
+ },
86
+
87
+ // OPAQUE login — phase 2. Returns the OPAQUE session key (opaque to the server).
88
+ async loginFinish({ loginId, finalizationB64 }) {
89
+ const res = await fetch('/auth/login/finish', {
90
+ method: 'POST',
91
+ body: JSON.stringify({ loginId, finalizationB64 }),
92
+ });
93
+ return res.json(); // { sessionKeyB64: string }
94
+ },
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.
98
+ async replacePasswordFile({ credentialId, uploadB64 }) {
99
+ await fetch('/auth/reset-password', {
100
+ method: 'POST',
101
+ body: JSON.stringify({ credentialId, uploadB64 }),
102
+ });
103
+ },
104
+
105
+ // Store one or more VMK-wrap blobs (opaque bytes to the server), keyed by method name.
106
+ // method names used by the SDK: "opaque", "recovery", "webauthn".
107
+ async putWraps({ credentialId, wraps }) {
108
+ await fetch('/auth/wraps', {
109
+ method: 'PUT',
110
+ body: JSON.stringify({ credentialId, wraps }),
111
+ });
112
+ },
113
+
114
+ // Fetch a single VMK-wrap blob by method. Returns null if the method has no stored wrap.
115
+ async getWrap({ credentialId, method }) {
116
+ const res = await fetch(`/auth/wraps/${credentialId}/${method}`);
117
+ if (res.status === 404) return null;
118
+ return res.json(); // { blobB64: string } | null
119
+ },
120
+ };
121
+ ```
122
+
123
+ All OPAQUE blobs exchanged with the server (`requestB64`, `responseB64`, `uploadB64`, `finalizationB64`, `sessionKeyB64`) are **base64-STANDARD** strings. VMK-wrap blobs (`blobB64`) are also base64-STANDARD (opaque byte sequences; not OPAQUE wire format).
124
+
125
+ ### 3. Register
126
+
127
+ ```ts
128
+ const t = new Tessera(transport);
129
+
130
+ // password must be a Uint8Array — the WASM OPAQUE layer operates on bytes.
131
+ const password = new TextEncoder().encode('correct horse battery staple');
132
+
133
+ const { recoveryPhrase } = await t.register({ email: 'alice@example.com', password });
134
+
135
+ // Show recoveryPhrase to the user EXACTLY ONCE and prompt them to write it down.
136
+ // The SDK does not store it. The string is immutable (JS strings cannot be zeroed —
137
+ // see Security model below).
138
+ console.log(recoveryPhrase); // 24 BIP-39 words
139
+ ```
140
+
141
+ `register` performs: blind-index → OPAQUE enrollment → VMK generation → wrap under `export_key` (method `"opaque"`) + fresh recovery entropy (method `"recovery"`) → `putWraps`. The `export_key` and recovery entropy are zeroed in a `finally` block before the call resolves.
142
+
143
+ ### 4. Login
144
+
145
+ ```ts
146
+ const session = await t.login({ email: 'alice@example.com', password });
147
+
148
+ // session.sessionKeyB64 — the OPAQUE session key (string | null).
149
+ // null on recovery and passkey paths (no OPAQUE handshake ran).
150
+ console.log(session.sessionKeyB64);
151
+
152
+ // Seal a record.
153
+ const envelope = await session.vault.seal('address', new TextEncoder().encode('123 Main St'));
154
+
155
+ // Open it later. Wrong key, wrong context, or tampered bytes all throw the same error
156
+ // (no decryption oracle — see Security model).
157
+ const plaintext = await session.vault.open('address', envelope);
158
+ ```
159
+
160
+ ### 5. Recovery
161
+
162
+ ```ts
163
+ const rec = await t.recoverWithPhrase({
164
+ email: 'alice@example.com',
165
+ phrase: 'word1 word2 ... word24', // the 24-word BIP-39 phrase from registration
166
+ });
167
+
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.
171
+
172
+ // Optionally reset the password (re-keys auth; vault content is never re-encrypted).
173
+ await rec.resetPassword(new TextEncoder().encode('new password'));
174
+ // After resetPassword, the recovery secret is zeroed; calling resetPassword again will fail.
175
+ ```
176
+
177
+ ### 6. Passkey (WebAuthn-PRF)
178
+
179
+ ```ts
180
+ import { isPasskeySupported, evaluatePrf } from '@ciphera-net/tessera';
181
+
182
+ // Conservative support probe (no user gesture needed, no credential created).
183
+ // Definitive PRF availability is only known after a real ceremony returns results.
184
+ if (await isPasskeySupported()) {
185
+ // Enable: creates a new WebAuthn credential and wraps the VMK under the PRF output.
186
+ // The app owns rpId, challenge, and userId — the SDK does not.
187
+ await t.enablePasskey({
188
+ email: 'alice@example.com',
189
+ password, // re-authenticates via OPAQUE (required — non-extractable VMK cannot be re-wrapped)
190
+ prf: () =>
191
+ evaluatePrf({
192
+ create: true,
193
+ rpId: 'example.com',
194
+ rpName: 'Example',
195
+ userId: crypto.getRandomValues(new Uint8Array(16)),
196
+ userName: 'alice@example.com',
197
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
198
+ }),
199
+ });
200
+
201
+ // Unlock on a later visit — no password needed.
202
+ const session = await t.unlockWithPasskey({
203
+ email: 'alice@example.com',
204
+ prf: () =>
205
+ evaluatePrf({
206
+ create: false,
207
+ rpId: 'example.com',
208
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
209
+ allowCredentialIds: [storedCredentialId], // optional; omit for discoverable credentials
210
+ }),
211
+ });
212
+ // session.sessionKeyB64 is null — no OPAQUE handshake on the passkey path.
213
+ }
214
+ ```
215
+
216
+ `PrfProvider` is `() => Promise<Uint8Array>` — a zero-argument async function that runs the WebAuthn ceremony and returns exactly 32 bytes. The SDK zeroes the returned buffer after use. Return a fresh buffer on each call; do not share or reuse it.
217
+
218
+ `evaluatePrf` uses a pinned PRF eval input (`"tessera/prf/v1"` UTF-8) so the PRF output is stable across `enablePasskey` and `unlockWithPasskey`. This input is an SDK internal — the app does not need to supply it.
219
+
220
+ ---
221
+
222
+ ## Transport
223
+
224
+ The full `Transport` interface (7 methods):
225
+
226
+ | Method | Direction | Description |
227
+ |---|---|---|
228
+ | `registerStart({ requestB64, credentialId })` | client → server | OPAQUE reg phase 1 |
229
+ | `registerFinish({ credentialId, uploadB64 })` | client → server | OPAQUE reg phase 2; returns `void` |
230
+ | `loginStart({ requestB64, credentialId })` | client → server | OPAQUE login phase 1; returns `{ loginId, responseB64 }` |
231
+ | `loginFinish({ loginId, finalizationB64 })` | client → server | OPAQUE login phase 2; returns `{ sessionKeyB64 }` |
232
+ | `replacePasswordFile({ credentialId, uploadB64 })` | client → server | password reset after recovery; returns `void` |
233
+ | `putWraps({ credentialId, wraps })` | client → server | store VMK-wrap blobs by method name; returns `void` |
234
+ | `getWrap({ credentialId, method })` | client ← server | fetch one VMK-wrap blob; returns `{ blobB64: string } \| null` |
235
+
236
+ `passwordFile` on the server side is `string | null` — `null` for an unknown account. The server passes it to the sidecar for a timing-safe dummy OPAQUE response. The browser cannot distinguish an existing from a non-existing account.
237
+
238
+ ---
239
+
240
+ ## The vault
241
+
242
+ ```ts
243
+ // seal — encrypts plaintext and returns an opaque envelope (Uint8Array).
244
+ const envelope: Uint8Array = await session.vault.seal(context, plaintext);
245
+
246
+ // open — decrypts and authenticates. Returns the original plaintext.
247
+ const plaintext: Uint8Array = await session.vault.open(context, envelope);
248
+ ```
249
+
250
+ **`context`** (a non-empty string) is mandatory on every call. It:
251
+
252
+ - Names the record type, e.g. `"address"`, `"totp"`, `"note"`.
253
+ - Is fed into the HKDF info string (`"tessera/vault/v1/record/" + context`), deriving an independent KEK per record type.
254
+ - Is bound as AAD into both GCM operations (wrap and content), preventing context substitution.
255
+ - Is **not stored** in the envelope — the caller must supply the same context to `open` that was used in `seal`.
256
+
257
+ An envelope sealed under `"address"` cannot be opened under `"totp"` — wrong-context is indistinguishable from wrong-key or tampered bytes, and will throw.
258
+
259
+ **Thrown errors** (all from the same error module):
260
+
261
+ | Class | When |
262
+ |---|---|
263
+ | `UnsupportedVersionError` | envelope version byte ≠ `0x01` |
264
+ | `MalformedEnvelopeError` | envelope too short to parse |
265
+ | `EmptyVaultKeyError` | zero-length key material passed |
266
+ | `EmptyContextError` | context is an empty string |
267
+
268
+ 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
+
270
+ ---
271
+
272
+ ## Crypto parameters (pinned)
273
+
274
+ These constants are fixed across all SDK implementations (TS, Go, Rust). Any change requires a new version label and a migration.
275
+
276
+ ### Blind index
277
+
278
+ | Parameter | Value |
279
+ |---|---|
280
+ | KDF | Argon2id |
281
+ | Version | `0x13` (19) — must be specified explicitly |
282
+ | Memory (m) | `65536 KiB` (64 MiB) |
283
+ | Time (t) | `3` |
284
+ | Parallelism (p) | `1` |
285
+ | Output length | 32 bytes |
286
+ | Salt | `"tessera/blind-index/v1"` (UTF-8, no NUL) |
287
+ | Encoding | base64url **unpadded** (`+→-`, `/→_`, no `=`) |
288
+
289
+ Email normalization (applied in order before hashing): trim whitespace → lowercase.
290
+
291
+ `p=1` is pinned because common browser/WASM Argon2 builds run single-threaded and may silently clamp `p>1` to `p=1`, yielding a different output from a native multi-lane build.
292
+
293
+ ### OPAQUE KSF
294
+
295
+ The OPAQUE password-hardening KSF uses the same Argon2id suite, applied at `register_finish` and `login_finish` on the client:
296
+
297
+ | Parameter | Value |
298
+ |---|---|
299
+ | KDF | Argon2id `0x13` |
300
+ | Memory (m) | `65536 KiB` (64 MiB) |
301
+ | Time (t) | `3` |
302
+ | Parallelism (p) | `1` |
303
+
304
+ OPAQUE blobs are encoded as **base64-STANDARD** (not base64url) on the wire.
305
+
306
+ ### Vault envelope v1
307
+
308
+ ```
309
+ [0x01][nonceW 12B][AES-256-GCM(KEK, DEK) = 48B][nonceC 12B][AES-256-GCM(DEK, msg)]
310
+ ```
311
+
312
+ Minimum size (empty plaintext): 89 bytes.
313
+
314
+ KEK derivation:
315
+
316
+ ```
317
+ KEK = HKDF-SHA-256(
318
+ IKM = vaultKey (VMK),
319
+ salt = 32 zero bytes, // RFC 5869 §2.2 nil-salt → HashLen zeros; SHA-256 HashLen = 32
320
+ info = "tessera/vault/v1/record/" ‖ utf8(context),
321
+ L = 32
322
+ )
323
+ ```
324
+
325
+ The 32-zero salt is intentional and matches Go's `hkdf.Key(sha256.New, vaultKey, nil, ...)`. Using a genuinely zero-length salt derives a different KEK and breaks interoperability.
326
+
327
+ AAD (bound into both GCM operations):
328
+
329
+ ```
330
+ AAD = [0x01] ‖ utf8(context)
331
+ ```
332
+
333
+ ---
334
+
335
+ ## Security model and honest limits
336
+
337
+ ### What the SDK does
338
+
339
+ - 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.
341
+ - VMK-wrap blobs stored server-side are opaque byte sequences. The server holds no plaintext passwords and no vault keys.
342
+
343
+ ### What the SDK cannot guarantee
344
+
345
+ **Non-extractable is an API-layer guard, not process isolation.** A compromised page (XSS, malicious dependency, compromised browser extension) can still *use* the non-extractable key to seal/open arbitrary records. It cannot export the raw VMK bytes via `exportKey`, but it can call `session.vault.seal` and `session.vault.open` freely. Non-extractable does not defend against a compromised execution context.
346
+
347
+ **Memory zeroing is best-effort.** The `export_key` and raw VMK transit WASM/JS linear memory and are zeroed after use, but the JavaScript runtime and the JIT compiler may copy values to internal buffers (AES round-key schedules, GC copying collectors) that are not reachable for zeroing. These copies are transient and never written to persistent storage or the network, but they cannot be guaranteed erased.
348
+
349
+ **The recovery phrase string cannot be zeroed.** JavaScript strings are immutable. `newRecoveryPhrase()` returns a `string`; the SDK derives the 32-byte entropy from it and zeroes that buffer, but the phrase string itself lives until it is garbage-collected. Minimize its lifetime: store it only long enough to display it to the user, then discard all references.
350
+
351
+ **Constant-time is not fully achievable in TS/WASM.** JavaScript engines make no constant-time guarantees for arithmetic or memory access. The SDK relies on the underlying OPAQUE and AES-GCM primitives (Rust sidecar, WebCrypto) for timing safety; the TS orchestration layer is not constant-time.
352
+
353
+ **WebAuthn-PRF is additive.** Enrolling a passkey does not remove the password or recovery paths. An attacker who compromises the password can still log in even if the user has a passkey. This is intentional — the passkey path is a convenience unlock, not a security upgrade of the authentication factor.
354
+
355
+ **`open` collapses all failure modes into one error.** Wrong key, wrong context, and GCM tag failure all result in a generic decryption error. This is intentional — a caller cannot tell which check failed, so there is no decryption oracle.
356
+
357
+ ---
358
+
359
+ ## Testing and status
360
+
361
+ ```bash
362
+ cd packages/tessera-ts
363
+
364
+ # Run unit and parity tests (Node, no browser needed).
365
+ npm test
366
+ ```
367
+
368
+ Tests run with **vitest**. The parity test suite (`test/vectors.test.ts`) validates byte-exact blind-index vectors and vault open-parity vectors against the Go-generated snapshots in the canonical kit (`ciphera-net/tessera` → `conformance/vectors/`).
369
+
370
+ Tests that require the **tessera-go sidecar** (OPAQUE handshake, recovery flow, passkey flow) read the sidecar binary path from `TESSERA_SIDECAR_BIN`. They are skipped automatically when the variable is unset.
371
+
372
+ ```bash
373
+ TESSERA_SIDECAR_BIN=/path/to/tessera-sidecar npm test
374
+ ```
375
+
376
+ Cross-language parity vectors are the canonical conformance kit in `ciphera-net/tessera` (`conformance/vectors/blind-index.json`, `conformance/vectors/vault.json`) alongside `conformance/schema.md` + `conformance/CONFORMANCE.md`, the authoritative contract for all pinned constants. The Go generator at `tessera-go/harness/vectors/gen_go.go` regenerates the files and performs an in-process round-trip assertion before writing output.
377
+
378
+ A Playwright browser matrix (real WebAuthn virtual-authenticator, full in-browser WASM path) is a planned follow-up and is not yet part of the CI suite.
379
+
380
+ Current test suite: **44 tests across 10 files**, all passing.
@@ -0,0 +1,2 @@
1
+ /** base64url-unpadded blind index for `email`. Requires `init()` (WASM) to have resolved. */
2
+ export declare function blindIndexString(email: string): string;
@@ -0,0 +1,10 @@
1
+ // Deterministic, privacy-preserving account lookup key. The normalization (trim → lowercase) and the
2
+ // Argon2id derivation live in the WASM core (single source of truth, byte-parity with tessera-go); this
3
+ // module only encodes the 32-byte result as base64url-UNPADDED — the form used as the OPAQUE
4
+ // credential_id and the server lookup key (matches Go's base64.RawURLEncoding).
5
+ import { toBase64UrlUnpadded } from './encoding.js';
6
+ import { blindIndexBytes } from './wasm.js';
7
+ /** base64url-unpadded blind index for `email`. Requires `init()` (WASM) to have resolved. */
8
+ export function blindIndexString(email) {
9
+ return toBase64UrlUnpadded(blindIndexBytes(email));
10
+ }
@@ -0,0 +1,19 @@
1
+ /** Standard padded base64 (`BASE64_STANDARD`) — for OPAQUE wire blobs. */
2
+ export declare function toBase64Std(bytes: Uint8Array): string;
3
+ export declare function fromBase64Std(b64: string): Uint8Array;
4
+ /** base64url UNPADDED — for the blind index (matches Go's `base64.RawURLEncoding`).
5
+ * No decode direction is provided on purpose: blind-index / credential_id values are write-only in
6
+ * this SDK (sent to the server, never received and decoded here). */
7
+ export declare function toBase64UrlUnpadded(bytes: Uint8Array): string;
8
+ export declare function utf8(s: string): Uint8Array;
9
+ /**
10
+ * Bridge the TS 5.7+ typed-array generic split at the WebCrypto boundary.
11
+ *
12
+ * TS 5.7 split `Uint8Array` into `Uint8Array<ArrayBufferLike>`, but lib.dom's WebCrypto signatures
13
+ * (`BufferSource`) require the `ArrayBuffer`-backed form (`SharedArrayBuffer` is rejected). Every byte
14
+ * value this SDK feeds to WebCrypto is ArrayBuffer-backed at runtime — fresh `new Uint8Array(n)`,
15
+ * `crypto.getRandomValues`, `TextEncoder`, copies out of WASM, and `@scure/bip39` output — and a
16
+ * `Uint8Array` is always a valid `BufferSource` at runtime regardless. This is therefore a sound,
17
+ * zero-copy re-view that closes a TYPE-ONLY gap; use it only when handing bytes to `crypto.subtle`.
18
+ */
19
+ export declare function wcView(b: Uint8Array): Uint8Array<ArrayBuffer>;
@@ -0,0 +1,44 @@
1
+ // Base64 + UTF-8 helpers. Browser-safe by design: uses `btoa`/`atob` and `TextEncoder`, never Node's
2
+ // `Buffer`, so the same source runs in the browser and (via Node's global btoa/atob) in tests/SSR.
3
+ //
4
+ // TWO base64 variants, deliberately distinct — mixing them silently breaks the wire:
5
+ // - toBase64Std / fromBase64Std = standard padded base64 (Rust `BASE64_STANDARD`) for OPAQUE blobs
6
+ // relayed browser ↔ relay ↔ sidecar.
7
+ // - toBase64UrlUnpadded = base64url WITHOUT padding (Go `base64.RawURLEncoding`) for the
8
+ // blind index / credential_id.
9
+ /** Standard padded base64 (`BASE64_STANDARD`) — for OPAQUE wire blobs. */
10
+ export function toBase64Std(bytes) {
11
+ let s = '';
12
+ for (const b of bytes)
13
+ s += String.fromCharCode(b);
14
+ return btoa(s);
15
+ }
16
+ export function fromBase64Std(b64) {
17
+ const s = atob(b64);
18
+ const out = new Uint8Array(s.length);
19
+ for (let i = 0; i < s.length; i++)
20
+ out[i] = s.charCodeAt(i);
21
+ return out;
22
+ }
23
+ /** base64url UNPADDED — for the blind index (matches Go's `base64.RawURLEncoding`).
24
+ * No decode direction is provided on purpose: blind-index / credential_id values are write-only in
25
+ * this SDK (sent to the server, never received and decoded here). */
26
+ export function toBase64UrlUnpadded(bytes) {
27
+ return toBase64Std(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
28
+ }
29
+ export function utf8(s) {
30
+ return new TextEncoder().encode(s);
31
+ }
32
+ /**
33
+ * Bridge the TS 5.7+ typed-array generic split at the WebCrypto boundary.
34
+ *
35
+ * TS 5.7 split `Uint8Array` into `Uint8Array<ArrayBufferLike>`, but lib.dom's WebCrypto signatures
36
+ * (`BufferSource`) require the `ArrayBuffer`-backed form (`SharedArrayBuffer` is rejected). Every byte
37
+ * value this SDK feeds to WebCrypto is ArrayBuffer-backed at runtime — fresh `new Uint8Array(n)`,
38
+ * `crypto.getRandomValues`, `TextEncoder`, copies out of WASM, and `@scure/bip39` output — and a
39
+ * `Uint8Array` is always a valid `BufferSource` at runtime regardless. This is therefore a sound,
40
+ * zero-copy re-view that closes a TYPE-ONLY gap; use it only when handing bytes to `crypto.subtle`.
41
+ */
42
+ export function wcView(b) {
43
+ return b;
44
+ }