@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/vault.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type VaultKey = CryptoKey & {
|
|
2
|
+
readonly __tesseraVaultKey: unique symbol;
|
|
3
|
+
};
|
|
4
|
+
/** Import raw vault-key material (e.g. the VMK) as a non-extractable HKDF base key. */
|
|
5
|
+
export declare function importVaultKey(raw: Uint8Array): Promise<VaultKey>;
|
|
6
|
+
/** Seal plaintext under a fresh DEK wrapped by a per-context KEK. context is REQUIRED and not stored. */
|
|
7
|
+
export declare function seal(vaultKey: VaultKey, context: string, plaintext: Uint8Array): Promise<Uint8Array>;
|
|
8
|
+
/** Open reverses seal. Same generic error for wrong-key/tamper/short; distinct UnsupportedVersion. */
|
|
9
|
+
export declare function open(vaultKey: VaultKey, context: string, envelope: Uint8Array): Promise<Uint8Array>;
|
package/dist/vault.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// WebCrypto vault: seal/open a record under a per-context key, BYTE-IDENTICAL to tessera-go/vault.go.
|
|
2
|
+
// Envelope v1: [0x01][nonceW 12][AES-256-GCM(KEK, DEK)=48][nonceC 12][AES-256-GCM(DEK, msg)].
|
|
3
|
+
// KEK = HKDF-SHA256(IKM=vaultKey, salt=32 zero bytes, info="tessera/vault/v1/record/"+context, L=32)
|
|
4
|
+
// DEK = random 32 bytes; AAD = [0x01] ‖ utf8(context) on BOTH GCM ops; context REQUIRED, NOT stored.
|
|
5
|
+
import { utf8, wcView } from './encoding.js';
|
|
6
|
+
import { EmptyContextError, EmptyVaultKeyError, MalformedEnvelopeError, UnsupportedVersionError, } from './errors.js';
|
|
7
|
+
const VERSION = 0x01;
|
|
8
|
+
const NONCE_LEN = 12;
|
|
9
|
+
const TAG_LEN = 16;
|
|
10
|
+
const DEK_LEN = 32;
|
|
11
|
+
const WRAPPED_DEK_LEN = DEK_LEN + TAG_LEN; // 48
|
|
12
|
+
const MIN_ENVELOPE = 1 + NONCE_LEN + WRAPPED_DEK_LEN + NONCE_LEN + TAG_LEN; // 89
|
|
13
|
+
const KEK_INFO_BASE = 'tessera/vault/v1/record/';
|
|
14
|
+
// SHA-256 HashLen. Go's hkdf.Key(nil salt) expands to a HashLen-zero salt (RFC 5869 §2.2); we mirror
|
|
15
|
+
// that EXACTLY with an explicit 32-zero salt. If the HKDF hash ever changes, this MUST change with it
|
|
16
|
+
// or KEK derivation silently diverges from Go.
|
|
17
|
+
const HKDF_SALT_LEN = 32;
|
|
18
|
+
const subtle = globalThis.crypto.subtle;
|
|
19
|
+
// AAD = versionByte ‖ utf8(context), bound into BOTH GCM ops (downgrade/substitution resistance).
|
|
20
|
+
function aad(context) {
|
|
21
|
+
const c = utf8(context);
|
|
22
|
+
const out = new Uint8Array(1 + c.length);
|
|
23
|
+
out[0] = VERSION;
|
|
24
|
+
out.set(c, 1);
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
// KEK = HKDF-SHA256(vaultKey, salt=32 zero bytes, info=KEK_INFO_BASE+context, 32B), non-extractable.
|
|
28
|
+
// 32 zero bytes explicitly matches Go's nil-salt → RFC 5869 HashLen-zeros expansion.
|
|
29
|
+
async function deriveKEK(vaultKey, context) {
|
|
30
|
+
return subtle.deriveKey({
|
|
31
|
+
name: 'HKDF',
|
|
32
|
+
hash: 'SHA-256',
|
|
33
|
+
salt: wcView(new Uint8Array(HKDF_SALT_LEN)),
|
|
34
|
+
info: wcView(utf8(KEK_INFO_BASE + context)),
|
|
35
|
+
}, vaultKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
36
|
+
}
|
|
37
|
+
/** Import raw vault-key material (e.g. the VMK) as a non-extractable HKDF base key. */
|
|
38
|
+
export async function importVaultKey(raw) {
|
|
39
|
+
if (raw.length === 0)
|
|
40
|
+
throw new EmptyVaultKeyError();
|
|
41
|
+
return (await subtle.importKey('raw', wcView(raw), 'HKDF', false, ['deriveKey']));
|
|
42
|
+
}
|
|
43
|
+
/** Seal plaintext under a fresh DEK wrapped by a per-context KEK. context is REQUIRED and not stored. */
|
|
44
|
+
export async function seal(vaultKey, context, plaintext) {
|
|
45
|
+
if (!context)
|
|
46
|
+
throw new EmptyContextError();
|
|
47
|
+
const a = aad(context);
|
|
48
|
+
const kek = await deriveKEK(vaultKey, context);
|
|
49
|
+
const dekRaw = crypto.getRandomValues(new Uint8Array(DEK_LEN));
|
|
50
|
+
try {
|
|
51
|
+
const dek = await subtle.importKey('raw', wcView(dekRaw), 'AES-GCM', false, ['encrypt']);
|
|
52
|
+
const nonceW = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
|
|
53
|
+
const wrappedDEK = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv: wcView(nonceW), additionalData: wcView(a) }, kek, wcView(dekRaw)));
|
|
54
|
+
const nonceC = crypto.getRandomValues(new Uint8Array(NONCE_LEN));
|
|
55
|
+
const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv: wcView(nonceC), additionalData: wcView(a) }, dek, wcView(plaintext)));
|
|
56
|
+
const out = new Uint8Array(1 + NONCE_LEN + wrappedDEK.length + NONCE_LEN + ct.length);
|
|
57
|
+
let o = 0;
|
|
58
|
+
out[o++] = VERSION;
|
|
59
|
+
out.set(nonceW, o);
|
|
60
|
+
o += NONCE_LEN;
|
|
61
|
+
out.set(wrappedDEK, o);
|
|
62
|
+
o += wrappedDEK.length;
|
|
63
|
+
out.set(nonceC, o);
|
|
64
|
+
o += NONCE_LEN;
|
|
65
|
+
out.set(ct, o);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
dekRaw.fill(0); // wipe the raw DEK unconditionally (mirrors Go's `defer wipe(dek)`), even on error
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Open reverses seal. Same generic error for wrong-key/tamper/short; distinct UnsupportedVersion. */
|
|
73
|
+
export async function open(vaultKey, context, envelope) {
|
|
74
|
+
if (!context)
|
|
75
|
+
throw new EmptyContextError();
|
|
76
|
+
if (envelope.length < 1)
|
|
77
|
+
throw new MalformedEnvelopeError();
|
|
78
|
+
if (envelope[0] !== VERSION)
|
|
79
|
+
throw new UnsupportedVersionError();
|
|
80
|
+
if (envelope.length < MIN_ENVELOPE)
|
|
81
|
+
throw new MalformedEnvelopeError();
|
|
82
|
+
let o = 1;
|
|
83
|
+
const nonceW = envelope.subarray(o, (o += NONCE_LEN));
|
|
84
|
+
const wrappedDEK = envelope.subarray(o, (o += WRAPPED_DEK_LEN));
|
|
85
|
+
const nonceC = envelope.subarray(o, (o += NONCE_LEN));
|
|
86
|
+
const ct = envelope.subarray(o);
|
|
87
|
+
const a = aad(context);
|
|
88
|
+
const kek = await deriveKEK(vaultKey, context);
|
|
89
|
+
// Nullable (not a sentinel): stays null if the wrap-decrypt throws before the DEK exists, so the
|
|
90
|
+
// finally wipe is a safe no-op in that case.
|
|
91
|
+
let dekRaw = null;
|
|
92
|
+
try {
|
|
93
|
+
dekRaw = new Uint8Array(await subtle.decrypt({ name: 'AES-GCM', iv: wcView(nonceW), additionalData: wcView(a) }, kek, wcView(wrappedDEK)));
|
|
94
|
+
const dek = await subtle.importKey('raw', wcView(dekRaw), 'AES-GCM', false, ['decrypt']);
|
|
95
|
+
// pt is a FRESH allocation (a copy out of WebCrypto), not a view into `envelope`; callers handling
|
|
96
|
+
// sensitive plaintext own this buffer and may zero it after use.
|
|
97
|
+
return new Uint8Array(await subtle.decrypt({ name: 'AES-GCM', iv: wcView(nonceC), additionalData: wcView(a) }, dek, wcView(ct)));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// One generic error for wrong-key / wrong-context / tamper (oracle resistance — matches Go, which
|
|
101
|
+
// returns ErrMalformedEnvelope for ANY Open failure). UnsupportedVersion is handled above, before
|
|
102
|
+
// this block, so it is never collapsed here.
|
|
103
|
+
throw new MalformedEnvelopeError();
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
dekRaw?.fill(0); // wipe the raw DEK unconditionally (mirrors Go's `defer wipe(dek)`)
|
|
107
|
+
}
|
|
108
|
+
}
|
package/dist/vmk.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type VaultKey } from './vault.js';
|
|
2
|
+
export type UnlockMethod = 'opaque' | 'recovery' | 'webauthn';
|
|
3
|
+
/** Encrypt raw VMK bytes under a method secret → versioned envelope. The CALLER owns and must zero
|
|
4
|
+
* both `vmkRaw` and `methodSecret` after use (this fn does not zero its inputs). */
|
|
5
|
+
export declare function wrapVmk(vmkRaw: Uint8Array, methodSecret: Uint8Array, method: UnlockMethod): Promise<Uint8Array>;
|
|
6
|
+
/** Decrypt a VMK envelope → raw VMK bytes. CALLER MUST zero BOTH the returned buffer AND `methodSecret`
|
|
7
|
+
* after use. */
|
|
8
|
+
export declare function unwrapVmkRaw(blob: Uint8Array, methodSecret: Uint8Array, method: UnlockMethod): Promise<Uint8Array>;
|
|
9
|
+
/** Unlock: decrypt the envelope and import the VMK as a NON-extractable VaultKey (raw is zeroed).
|
|
10
|
+
* CALLER must zero `methodSecret` after this returns. */
|
|
11
|
+
export declare function openVaultKey(blob: Uint8Array, methodSecret: Uint8Array, method: UnlockMethod): Promise<VaultKey>;
|
|
12
|
+
/** Generate a fresh VMK. Returns the non-extractable VaultKey (for vault ops) + the wrapped blobs.
|
|
13
|
+
* Raw VMK exists only transiently during setup, then is zeroed. */
|
|
14
|
+
export declare function generateAndWrap(secrets: Partial<Record<UnlockMethod, Uint8Array>>): Promise<{
|
|
15
|
+
vmk: VaultKey;
|
|
16
|
+
wraps: Partial<Record<UnlockMethod, Uint8Array>>;
|
|
17
|
+
}>;
|
|
18
|
+
/** Add/replace an unlock method WITHOUT re-encrypting the vault: re-decrypt the VMK from an existing
|
|
19
|
+
* wrap (using a JUST-RE-AUTHENTICATED secret) and re-wrap it under the new method. Used by
|
|
20
|
+
* enablePasskey and resetPassword. The raw VMK is zeroed before returning. CALLER must zero
|
|
21
|
+
* `existing.secret` and `next.secret` after this returns. */
|
|
22
|
+
export declare function rewrapForMethod(existing: {
|
|
23
|
+
blob: Uint8Array;
|
|
24
|
+
secret: Uint8Array;
|
|
25
|
+
method: UnlockMethod;
|
|
26
|
+
}, next: {
|
|
27
|
+
secret: Uint8Array;
|
|
28
|
+
method: UnlockMethod;
|
|
29
|
+
}): Promise<Uint8Array>;
|
package/dist/vmk.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// VMK (Vault Master Key) hierarchy. The long-lived vault key is a random 32-byte VMK held only as a
|
|
2
|
+
// non-extractable WebCrypto CryptoKey; it is WRAPPED once per unlock method (opaque / recovery /
|
|
3
|
+
// webauthn) so adding or resetting a method only re-wraps the VMK — the vault itself is never
|
|
4
|
+
// re-encrypted. Each wrap is a dedicated single-layer envelope (the payload is exactly the 32-byte
|
|
5
|
+
// VMK; no DEK indirection):
|
|
6
|
+
// [0x01][nonce 12][AES-256-GCM(wrapKEK, VMK)=48] = 61 bytes
|
|
7
|
+
// wrapKEK = HKDF-SHA256(IKM=methodSecret, salt=32 zero bytes, info="tessera/vmk-wrap/v1/"+method, 32)
|
|
8
|
+
// AAD = [0x01] ‖ utf8("tessera/vmk-wrap/v1/"+method) (binds version + method into the GCM tag)
|
|
9
|
+
// Wrap/unwrap is plain AES-GCM encrypt/decrypt (NOT WebCrypto wrapKey/unwrapKey): one universally
|
|
10
|
+
// supported path, and the same KEK works both directions.
|
|
11
|
+
import { utf8, wcView } from './encoding.js';
|
|
12
|
+
import { importVaultKey } from './vault.js';
|
|
13
|
+
import { MalformedEnvelopeError, UnsupportedVersionError } from './errors.js';
|
|
14
|
+
const subtle = globalThis.crypto.subtle;
|
|
15
|
+
const VERSION = 0x01;
|
|
16
|
+
const NONCE_LEN = 12;
|
|
17
|
+
const TAG_LEN = 16;
|
|
18
|
+
const VMK_LEN = 32;
|
|
19
|
+
const WRAPPED_VMK_LEN = VMK_LEN + TAG_LEN; // 48 (AES-GCM ct ‖ tag)
|
|
20
|
+
const ENVELOPE_LEN = 1 + NONCE_LEN + WRAPPED_VMK_LEN; // 61
|
|
21
|
+
function wrapInfo(method) {
|
|
22
|
+
return utf8('tessera/vmk-wrap/v1/' + method);
|
|
23
|
+
}
|
|
24
|
+
function wrapAad(method) {
|
|
25
|
+
const m = wrapInfo(method);
|
|
26
|
+
const out = new Uint8Array(1 + m.length);
|
|
27
|
+
out[0] = VERSION;
|
|
28
|
+
out.set(m, 1);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
// wrapKEK = HKDF-SHA256(methodSecret, salt=32 zeros, info, 32) as a non-extractable AES-GCM key with
|
|
32
|
+
// encrypt+decrypt usage (one KEK, both directions).
|
|
33
|
+
async function deriveWrapKEK(methodSecret, method) {
|
|
34
|
+
const base = await subtle.importKey('raw', wcView(methodSecret), 'HKDF', false, ['deriveKey']);
|
|
35
|
+
return subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: wcView(new Uint8Array(32)), info: wcView(wrapInfo(method)) }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
36
|
+
}
|
|
37
|
+
/** Encrypt raw VMK bytes under a method secret → versioned envelope. The CALLER owns and must zero
|
|
38
|
+
* both `vmkRaw` and `methodSecret` after use (this fn does not zero its inputs). */
|
|
39
|
+
export async function wrapVmk(vmkRaw, methodSecret, method) {
|
|
40
|
+
// Derived OUTSIDE a catch on purpose: a bad secret at wrap (setup) time is a programming error that
|
|
41
|
+
// should surface loudly, not an attacker-observable oracle (contrast unwrapVmkRaw below).
|
|
42
|
+
const kek = await deriveWrapKEK(methodSecret, method);
|
|
43
|
+
const nonce = globalThis.crypto.getRandomValues(new Uint8Array(NONCE_LEN));
|
|
44
|
+
const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv: wcView(nonce), additionalData: wcView(wrapAad(method)) }, kek, wcView(vmkRaw)));
|
|
45
|
+
const out = new Uint8Array(ENVELOPE_LEN);
|
|
46
|
+
out[0] = VERSION;
|
|
47
|
+
out.set(nonce, 1);
|
|
48
|
+
out.set(ct, 1 + NONCE_LEN);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** Decrypt a VMK envelope → raw VMK bytes. CALLER MUST zero BOTH the returned buffer AND `methodSecret`
|
|
52
|
+
* after use. */
|
|
53
|
+
export async function unwrapVmkRaw(blob, methodSecret, method) {
|
|
54
|
+
if (blob.length !== ENVELOPE_LEN)
|
|
55
|
+
throw new MalformedEnvelopeError();
|
|
56
|
+
// Unknown version is a DISTINCT, non-secret rejection (forward-compat) — matches vault.open rather
|
|
57
|
+
// than collapsing into MalformedEnvelopeError. The version byte carries no secret, so this is no oracle.
|
|
58
|
+
if (blob[0] !== VERSION)
|
|
59
|
+
throw new UnsupportedVersionError();
|
|
60
|
+
const nonce = blob.subarray(1, 1 + NONCE_LEN);
|
|
61
|
+
const ct = blob.subarray(1 + NONCE_LEN);
|
|
62
|
+
try {
|
|
63
|
+
// Derive INSIDE the try so a bad methodSecret (e.g. zero-length → HKDF importKey DataError) also
|
|
64
|
+
// collapses to MalformedEnvelope rather than leaking a raw DOMException (oracle resistance — any
|
|
65
|
+
// unlock failure is indistinguishable, matching vault.open).
|
|
66
|
+
const kek = await deriveWrapKEK(methodSecret, method);
|
|
67
|
+
return new Uint8Array(await subtle.decrypt({ name: 'AES-GCM', iv: wcView(nonce), additionalData: wcView(wrapAad(method)) }, kek, wcView(ct)));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new MalformedEnvelopeError(); // wrong secret / wrong method / tamper — never distinguished
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Unlock: decrypt the envelope and import the VMK as a NON-extractable VaultKey (raw is zeroed).
|
|
74
|
+
* CALLER must zero `methodSecret` after this returns. */
|
|
75
|
+
export async function openVaultKey(blob, methodSecret, method) {
|
|
76
|
+
const raw = await unwrapVmkRaw(blob, methodSecret, method);
|
|
77
|
+
try {
|
|
78
|
+
return await importVaultKey(raw);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
raw.fill(0);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Generate a fresh VMK. Returns the non-extractable VaultKey (for vault ops) + the wrapped blobs.
|
|
85
|
+
* Raw VMK exists only transiently during setup, then is zeroed. */
|
|
86
|
+
export async function generateAndWrap(secrets) {
|
|
87
|
+
// No silent footgun: an empty map would mint an IRRECOVERABLE VMK (no wrap can ever unlock it).
|
|
88
|
+
if (Object.keys(secrets).length === 0) {
|
|
89
|
+
throw new Error('tessera: generateAndWrap requires at least one unlock method');
|
|
90
|
+
}
|
|
91
|
+
const raw = globalThis.crypto.getRandomValues(new Uint8Array(VMK_LEN));
|
|
92
|
+
try {
|
|
93
|
+
const wraps = {};
|
|
94
|
+
for (const method of Object.keys(secrets)) {
|
|
95
|
+
// `method` came from Object.keys(secrets), so secrets[method] is defined (the `!` is sound).
|
|
96
|
+
wraps[method] = await wrapVmk(raw, secrets[method], method);
|
|
97
|
+
}
|
|
98
|
+
const vmk = await importVaultKey(raw);
|
|
99
|
+
return { vmk, wraps };
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
raw.fill(0);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Add/replace an unlock method WITHOUT re-encrypting the vault: re-decrypt the VMK from an existing
|
|
106
|
+
* wrap (using a JUST-RE-AUTHENTICATED secret) and re-wrap it under the new method. Used by
|
|
107
|
+
* enablePasskey and resetPassword. The raw VMK is zeroed before returning. CALLER must zero
|
|
108
|
+
* `existing.secret` and `next.secret` after this returns. */
|
|
109
|
+
export async function rewrapForMethod(existing, next) {
|
|
110
|
+
const raw = await unwrapVmkRaw(existing.blob, existing.secret, existing.method);
|
|
111
|
+
try {
|
|
112
|
+
return await wrapVmk(raw, next.secret, next.method);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
raw.fill(0);
|
|
116
|
+
}
|
|
117
|
+
}
|
package/dist/wasm.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RegistrationHandle, LoginHandle } from '../wasm/web/tessera.js';
|
|
2
|
+
export type { RegistrationHandle, LoginHandle };
|
|
3
|
+
/** Idempotent: load + initialize the WASM module. Await once before any WASM-backed API. */
|
|
4
|
+
export declare function init(): Promise<void>;
|
|
5
|
+
/** Raw 32-byte blind index from an email (normalization + Argon2id happen inside the WASM core). */
|
|
6
|
+
export declare function blindIndexBytes(email: string): Uint8Array;
|
|
7
|
+
/** Construct an OPAQUE registration handle (single-use; see the WASM binding). */
|
|
8
|
+
export declare function createRegistrationHandle(password: Uint8Array): RegistrationHandle;
|
|
9
|
+
/** Construct an OPAQUE login handle (single-use). */
|
|
10
|
+
export declare function createLoginHandle(password: Uint8Array): LoginHandle;
|
package/dist/wasm.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
let mod = null;
|
|
2
|
+
let loading = null;
|
|
3
|
+
// Detect Node WITHOUT a global `process` type — cast globalThis so this stays browser-SDK type-clean
|
|
4
|
+
// (no @types/node, which would let browser code reference Node APIs unchecked).
|
|
5
|
+
function isNode() {
|
|
6
|
+
const g = globalThis;
|
|
7
|
+
return typeof g.process?.versions?.node === 'string';
|
|
8
|
+
}
|
|
9
|
+
async function load() {
|
|
10
|
+
if (mod)
|
|
11
|
+
return mod;
|
|
12
|
+
if (!loading) {
|
|
13
|
+
loading = (async () => {
|
|
14
|
+
if (isNode()) {
|
|
15
|
+
// nodejs target — CommonJS, auto-initialized on import. NO init() call. Read the full
|
|
16
|
+
// module.exports via the dynamic-import `default` (wasm-bindgen CJS named exports are not
|
|
17
|
+
// reliably hoisted by the ESM↔CJS interop, but `default` is the whole exports object).
|
|
18
|
+
const ns = (await import('../wasm/node/tessera.js'));
|
|
19
|
+
mod = ns.default ?? ns;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
// web target — ESM whose default export is the async init() that loads the .wasm.
|
|
23
|
+
const web = (await import('../wasm/web/tessera.js'));
|
|
24
|
+
await web.default();
|
|
25
|
+
mod = web;
|
|
26
|
+
}
|
|
27
|
+
return mod; // assigned in both branches above
|
|
28
|
+
})();
|
|
29
|
+
}
|
|
30
|
+
return loading;
|
|
31
|
+
}
|
|
32
|
+
/** Idempotent: load + initialize the WASM module. Await once before any WASM-backed API. */
|
|
33
|
+
export async function init() {
|
|
34
|
+
await load();
|
|
35
|
+
}
|
|
36
|
+
function loaded() {
|
|
37
|
+
if (!mod)
|
|
38
|
+
throw new Error('tessera: WASM not initialized — await init() first');
|
|
39
|
+
return mod;
|
|
40
|
+
}
|
|
41
|
+
/** Raw 32-byte blind index from an email (normalization + Argon2id happen inside the WASM core). */
|
|
42
|
+
export function blindIndexBytes(email) {
|
|
43
|
+
return loaded().blindIndex(email);
|
|
44
|
+
}
|
|
45
|
+
/** Construct an OPAQUE registration handle (single-use; see the WASM binding). */
|
|
46
|
+
export function createRegistrationHandle(password) {
|
|
47
|
+
return new (loaded().RegistrationHandle)(password);
|
|
48
|
+
}
|
|
49
|
+
/** Construct an OPAQUE login handle (single-use). */
|
|
50
|
+
export function createLoginHandle(password) {
|
|
51
|
+
return new (loaded().LoginHandle)(password);
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ciphera-net/tessera",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"description": "Tessera browser SDK — OPAQUE auth, blind index, vault, BIP-39 recovery, WebAuthn-PRF (WASM + WebCrypto).",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"registry": "https://registry.npmjs.org",
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"wasm"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build:wasm": "bash scripts/build-wasm.sh",
|
|
24
|
+
"build:ts": "tsc",
|
|
25
|
+
"build": "npm run build:wasm && npm run build:ts",
|
|
26
|
+
"prepack": "rm -f wasm/web/.gitignore wasm/node/.gitignore",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:browser": "playwright test"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@scure/bip39": "^1.3.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@playwright/test": "^1.48.0",
|
|
35
|
+
"tsx": "^4.22.4",
|
|
36
|
+
"typescript": "^5.6.0",
|
|
37
|
+
"vitest": "^2.1.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* tslint:disable */
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
|
|
4
|
+
export class LoginFinish {
|
|
5
|
+
private constructor();
|
|
6
|
+
free(): void;
|
|
7
|
+
[Symbol.dispose](): void;
|
|
8
|
+
readonly exportKey: Uint8Array;
|
|
9
|
+
readonly finalization: Uint8Array;
|
|
10
|
+
readonly sessionKey: Uint8Array;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class LoginHandle {
|
|
14
|
+
free(): void;
|
|
15
|
+
[Symbol.dispose](): void;
|
|
16
|
+
/**
|
|
17
|
+
* Finish login (single-use). Returns finalization (relay), session_key, export_key (CLIENT-ONLY).
|
|
18
|
+
*/
|
|
19
|
+
finish(password: Uint8Array, response: Uint8Array): LoginFinish;
|
|
20
|
+
constructor(password: Uint8Array);
|
|
21
|
+
readonly request: Uint8Array;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class RegistrationFinish {
|
|
25
|
+
private constructor();
|
|
26
|
+
free(): void;
|
|
27
|
+
[Symbol.dispose](): void;
|
|
28
|
+
readonly exportKey: Uint8Array;
|
|
29
|
+
readonly upload: Uint8Array;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class RegistrationHandle {
|
|
33
|
+
free(): void;
|
|
34
|
+
[Symbol.dispose](): void;
|
|
35
|
+
/**
|
|
36
|
+
* Finish registration. Consumes the handle's state (single-use; a second call errors). Returns
|
|
37
|
+
* the upload to relay and the 64-byte export_key (CLIENT-ONLY — caller must never transmit it).
|
|
38
|
+
*/
|
|
39
|
+
finish(password: Uint8Array, response: Uint8Array): RegistrationFinish;
|
|
40
|
+
/**
|
|
41
|
+
* Start registration from a password. `request` carries the RegistrationRequest to relay.
|
|
42
|
+
*/
|
|
43
|
+
constructor(password: Uint8Array);
|
|
44
|
+
readonly request: Uint8Array;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Derive the 32-byte blind index from an email. The params/normalization/salt live in the core
|
|
49
|
+
* crate (`tessera::blind_index`) as the single source of truth; this is a thin binding.
|
|
50
|
+
*/
|
|
51
|
+
export function blindIndex(email: string): Uint8Array;
|