@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 +202 -0
- package/README.md +380 -0
- package/dist/blindIndex.d.ts +2 -0
- package/dist/blindIndex.js +10 -0
- package/dist/encoding.d.ts +19 -0
- package/dist/encoding.js +44 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +27 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +7 -0
- package/dist/opaque.d.ts +16 -0
- package/dist/opaque.js +69 -0
- package/dist/passkey.d.ts +30 -0
- package/dist/passkey.js +75 -0
- package/dist/recovery.d.ts +7 -0
- package/dist/recovery.js +19 -0
- package/dist/tessera.d.ts +109 -0
- package/dist/tessera.js +228 -0
- package/dist/transport.d.ts +39 -0
- package/dist/transport.js +1 -0
- package/dist/vault.d.ts +9 -0
- package/dist/vault.js +108 -0
- package/dist/vmk.d.ts +29 -0
- package/dist/vmk.js +117 -0
- package/dist/wasm.d.ts +10 -0
- package/dist/wasm.js +52 -0
- package/package.json +39 -0
- package/wasm/node/package.json +12 -0
- package/wasm/node/tessera.d.ts +51 -0
- package/wasm/node/tessera.js +463 -0
- package/wasm/node/tessera_bg.wasm +0 -0
- package/wasm/node/tessera_bg.wasm.d.ts +27 -0
- package/wasm/web/package.json +16 -0
- package/wasm/web/tessera.d.ts +103 -0
- package/wasm/web/tessera.js +552 -0
- package/wasm/web/tessera_bg.wasm +0 -0
- package/wasm/web/tessera_bg.wasm.d.ts +27 -0
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,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>;
|
package/dist/encoding.js
ADDED
|
@@ -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
|
+
}
|