@scales-baby/nest-bridge 1.0.0

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SCALES
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # nest-bridge
2
+
3
+ A local [MCP](https://modelcontextprotocol.io) bridge so your AI can read and write your end-to-end-encrypted [Nest](https://nest.scales.baby) data (people, companies, tasks, events) while Nest's servers only ever see ciphertext.
4
+
5
+ The bridge runs **on your machine**. It unlocks your encryption key locally from your password, holds it in memory, and decrypts on read / encrypts on write right there. **Nest never receives your password, your key, or your plaintext** - only the already-encrypted blobs and your API key.
6
+
7
+ ```
8
+ your AI <--stdio MCP--> nest-bridge (your machine) <--HTTPS--> nest.scales.baby
9
+ | decrypts on read |
10
+ | encrypts on write | stores ciphertext only
11
+ | holds the key in memory |
12
+ ```
13
+
14
+ ## Security model
15
+
16
+ - Your **API key** and your **encryption password** stay on your machine. You supply them at runtime as environment variables (or, for the Claude Desktop connector, into your OS keychain).
17
+ - The bridge fetches only your **password-wrapped key** (ciphertext plus the public salt) from Nest, derives your key locally with Argon2id, unwraps the key **locally**, and keeps it in memory.
18
+ - **Reads** decrypt locally. **Writes** encrypt locally. The server stores only the encrypted blob with the plaintext columns blanked. Nest never receives the key, the password, or your plaintext.
19
+ - The crypto is the **same WebCrypto + Argon2id (hash-wasm) code the Nest web app uses**, so reads and writes round-trip byte-for-byte with the browser. See `src/crypto/`.
20
+ - **Your AI provider sees what it reads.** "Nest can't read your data" is not the same as "no one but you" once you connect an AI: the model you connect (Claude, OpenAI, etc.) sees the decrypted content the bridge returns to it. That is the point of connecting an AI. Mint a **read-only** key if you do not want it to write.
21
+ - A **read-only** key cannot write; a **full-control** key can. The server enforces this independently of the bridge.
22
+
23
+ This repository contains only the bridge and the client-side crypto. There is no server code, no database, and no secrets here.
24
+
25
+ ## Mint a key in Nest
26
+
27
+ Open **Nest then Settings then Connect your AI** and create a key.
28
+
29
+ - **Read-only** (the default) lets your AI read your data.
30
+ - **Full control** also lets it create and update records.
31
+
32
+ Copy the key when it is shown (it appears once). It looks like `nest_ab12cd34_...`.
33
+
34
+ ## Run it (npx)
35
+
36
+ ```bash
37
+ NEST_API_KEY="nest_ab12cd34_..." \
38
+ NEST_PASSWORD="your-encryption-password" \
39
+ NEST_NON_INTERACTIVE=1 \
40
+ npx @scales-baby/nest-bridge
41
+ ```
42
+
43
+ The bridge fetches your wrapped key, unwraps it locally, and starts an MCP server on stdio. Point any MCP client at the same command.
44
+
45
+ In a real interactive terminal you can omit `NEST_PASSWORD` and `NEST_NON_INTERACTIVE`; the bridge then prompts `Nest encryption password:` with hidden input.
46
+
47
+ ### Configuration (environment variables)
48
+
49
+ | Var | Default | Notes |
50
+ | ---------------------- | -------------------------- | -------------------------------------------------- |
51
+ | `NEST_API_KEY` | (required) | Your scoped key from Nest. |
52
+ | `NEST_PASSWORD` | (prompt) | Set it to run non-interactively; else prompted. |
53
+ | `NEST_API_URL` | `https://nest.scales.baby` | Override only to test against another instance. |
54
+ | `NEST_NON_INTERACTIVE` | (unset) | Set to `1` when launched by an app (no prompt). |
55
+ | `NEST_READ_ONLY` | (unset) | Set to `1` to force read-only locally. |
56
+
57
+ If your account is not end-to-end encrypted, the bridge runs in pass-through mode and no password is needed.
58
+
59
+ ## Claude Desktop (one-click `.mcpb`)
60
+
61
+ Build the connector bundle, then double-click it (or drag it onto Claude Desktop):
62
+
63
+ ```bash
64
+ npm install
65
+ npm run pack:mcpb # produces dist/scales-nest.mcpb
66
+ ```
67
+
68
+ Claude Desktop opens an install panel for "Nest by SCALES" and asks for your **Nest API key** and **Nest encryption password**. Both are marked sensitive, so Claude Desktop stores them in your OS keychain (macOS Keychain / Windows Credential Manager), not in a plain file. Leave **Nest URL** at its default and enable it.
69
+
70
+ ## Per-AI config snippets
71
+
72
+ **Claude Code (CLI):**
73
+
74
+ ```bash
75
+ claude mcp add nest \
76
+ --env NEST_API_KEY=nest_ab12cd34_... \
77
+ --env NEST_PASSWORD=your-encryption-password \
78
+ --env NEST_NON_INTERACTIVE=1 \
79
+ -- npx @scales-baby/nest-bridge
80
+ ```
81
+
82
+ **OpenAI / any stdio MCP client (JSON):**
83
+
84
+ ```json
85
+ {
86
+ "command": "npx",
87
+ "args": ["@scales-baby/nest-bridge"],
88
+ "env": {
89
+ "NEST_API_KEY": "nest_ab12cd34_...",
90
+ "NEST_PASSWORD": "your-encryption-password",
91
+ "NEST_NON_INTERACTIVE": "1"
92
+ }
93
+ }
94
+ ```
95
+
96
+ Note: OpenAI's **remote / hosted** MCP is metadata-only on encrypted accounts. Only the **local** bridge returns decrypted content, because decryption happens on your machine.
97
+
98
+ ## Tools
99
+
100
+ People, Companies, Tasks, Events, each with `list_*`, `get_*`, `search_*`, `create_*`, `update_*`, plus `complete_task` and `get_digest`. 22 tools in all. Notes ride on the person / company / task / event they belong to.
101
+
102
+ - **Reads** fetch ciphertext from Nest, decrypt locally, return plaintext to your AI.
103
+ - **Writes** take your AI's plaintext, encrypt locally, send ciphertext to Nest.
104
+
105
+ If you minted a read-only key, the write tools politely decline (and Nest enforces it on the server too).
106
+
107
+ ## Build from source
108
+
109
+ ```bash
110
+ git clone https://github.com/scales-baby/nest-bridge.git
111
+ cd nest-bridge
112
+ npm install
113
+ npm run build # tsc → dist/
114
+ npm test # crypto round-trip proof
115
+ npm start # run the built bridge (needs the env above)
116
+ ```
117
+
118
+ ## License
119
+
120
+ MIT. See [LICENSE](./LICENSE).
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ // Nest encryption — the canonical encrypted-vs-cleartext field split per CRM
3
+ // model. SINGLE SOURCE OF TRUTH for which fields get bundled into the `enc`
4
+ // blob on write and hydrated back on read.
5
+ //
6
+ // HYBRID RULE: "content" fields (names, free text, handles, "what was said")
7
+ // live ONLY inside the per-doc `enc` blob when the account is `encrypted`. The
8
+ // "scheduling / structured metadata" fields stay CLEARTEXT top-level so the
9
+ // server cron, digests, filters, sort, and pagination keep working without ever
10
+ // holding a key.
11
+ //
12
+ // This module is environment-agnostic (no WebCrypto, no DOM).
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.CONTENT_SCHEMA = void 0;
15
+ exports.encryptedFields = encryptedFields;
16
+ exports.searchKeys = searchKeys;
17
+ // PERSON ----------------------------------------------------------------------
18
+ // Encrypted: identity + relationship content (name/company label/role/how-met/
19
+ // notes/social handles/next-action free text/tags plaintext copy).
20
+ // Clear: scheduling + status + counts + refs (so digest/cron/filters work).
21
+ const person = {
22
+ encrypted: [
23
+ "name",
24
+ "companyName",
25
+ "role",
26
+ "channelMet",
27
+ "notes",
28
+ "linkedin",
29
+ "twitter",
30
+ "telegram",
31
+ "nextAction",
32
+ "tags",
33
+ ],
34
+ searchKeys: ["name", "companyName", "role", "notes", "channelMet"],
35
+ clear: [
36
+ "userId",
37
+ "company", // ObjectId ref (not a name)
38
+ "status",
39
+ "lastContact",
40
+ "nextActionDate",
41
+ "reminderFiredAt",
42
+ "source",
43
+ "tagHashes",
44
+ "createdAt",
45
+ "updatedAt",
46
+ ],
47
+ blankWith: {
48
+ name: "",
49
+ companyName: "",
50
+ role: "",
51
+ channelMet: "",
52
+ notes: "",
53
+ linkedin: "",
54
+ twitter: "",
55
+ telegram: "",
56
+ nextAction: "",
57
+ tags: "[]",
58
+ },
59
+ };
60
+ // TASK ------------------------------------------------------------------------
61
+ // Encrypted: title + body/notes (free text). Clear: due/status/priority/refs.
62
+ const task = {
63
+ encrypted: ["title", "notes"],
64
+ searchKeys: ["title", "notes"],
65
+ clear: [
66
+ "userId",
67
+ "dueDate",
68
+ "status",
69
+ "priority",
70
+ "relatedPerson",
71
+ "relatedEvent",
72
+ "reminderFiredAt",
73
+ "createdAt",
74
+ "updatedAt",
75
+ ],
76
+ blankWith: { title: "", notes: "" },
77
+ };
78
+ // COMPANY / MERCHANT / EVENT / ROUTE -----------------------------------------
79
+ // These carry mostly structured scheduling/operational metadata; the genuinely
80
+ // free-text "notes" (and a couple of note-like fields) are the sensitive
81
+ // content. We encrypt those and keep everything else cleartext so the event
82
+ // calendar and route planning stay server-side.
83
+ const company = {
84
+ encrypted: ["notes", "payrollFriction"],
85
+ searchKeys: ["notes", "payrollFriction"],
86
+ clear: ["name", "city", "category", "contactStatus", "tags", "tagHashes"],
87
+ blankWith: { notes: "", payrollFriction: "" },
88
+ };
89
+ const merchant = {
90
+ encrypted: ["notes", "qrShippingContact", "ownerName", "contactPerson", "contactInfo"],
91
+ searchKeys: ["notes"],
92
+ clear: ["name", "city", "visitStatus", "qrDeliveryStatus", "tags", "tagHashes"],
93
+ blankWith: {
94
+ notes: "",
95
+ qrShippingContact: "",
96
+ ownerName: "",
97
+ contactPerson: "",
98
+ contactInfo: "",
99
+ },
100
+ };
101
+ const event = {
102
+ encrypted: ["notes", "researchNotes"],
103
+ searchKeys: ["notes", "researchNotes"],
104
+ clear: ["title", "date", "city", "status", "tier", "tags", "tagHashes"],
105
+ blankWith: { notes: "", researchNotes: "" },
106
+ };
107
+ const route = {
108
+ // Route "notes" live per-stop; the top-level route has no single notes field,
109
+ // so for now the only top-level free text is none — keep the split declared so
110
+ // the engine can be extended to per-stop notes later without a schema change.
111
+ encrypted: [],
112
+ searchKeys: [],
113
+ clear: ["name", "city", "status", "date", "tags", "tagHashes"],
114
+ blankWith: {},
115
+ };
116
+ exports.CONTENT_SCHEMA = {
117
+ person,
118
+ task,
119
+ company,
120
+ merchant,
121
+ event,
122
+ route,
123
+ };
124
+ // Convenience: the encrypted field list for a model.
125
+ function encryptedFields(model) {
126
+ return exports.CONTENT_SCHEMA[model].encrypted;
127
+ }
128
+ // Convenience: the fuzzy-search keys for a model.
129
+ function searchKeys(model) {
130
+ return exports.CONTENT_SCHEMA[model].searchKeys;
131
+ }
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ // Nest encryption — the CRM data layer.
3
+ //
4
+ // The clean seam between the caller and the network: it hands the caller plain
5
+ // records on read and accepts plain records on write, transparently doing the
6
+ // client-side encrypt-on-write / decrypt-on-read when the account is
7
+ // `encrypted` and unlocked. When the account is `unencrypted` it is a
8
+ // pass-through (plaintext).
9
+ //
10
+ // HARD RULE: encryption/decryption is CLIENT-SIDE only. On an encrypted write
11
+ // we strip the sensitive plaintext fields and send only the `enc` ciphertext
12
+ // blob + cleartext scheduling metadata. The server never receives the DEK or
13
+ // plaintext content.
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.hydrateRecord = hydrateRecord;
16
+ exports.hydrateRecords = hydrateRecords;
17
+ exports.encryptPayload = encryptPayload;
18
+ exports.splitRecord = splitRecord;
19
+ exports.buildWritePayload = buildWritePayload;
20
+ const envelope_1 = require("./envelope");
21
+ const contentSchema_1 = require("./contentSchema");
22
+ // ---------------------------------------------------------------------------
23
+ // READ: hydrate a stored record into a plain record for the caller.
24
+ // ---------------------------------------------------------------------------
25
+ // Decrypt one record's `enc` blob (if any) and merge the recovered sensitive
26
+ // fields back on top of the cleartext metadata. No `enc` blob ⇒ pass-through
27
+ // (the record is plaintext). Throws only if a present blob fails to decrypt
28
+ // (wrong/missing DEK) — callers decide how to surface that.
29
+ async function hydrateRecord(model, dek, record) {
30
+ if (!record.enc)
31
+ return record; // plaintext doc — nothing to do
32
+ if (!dek) {
33
+ // Encrypted doc but we have no key: return the record with the sensitive
34
+ // fields left at their blanked plaintext values rather than throwing, so a
35
+ // locked caller can still show scheduling rows ("3 follow-ups due").
36
+ return record;
37
+ }
38
+ const fields = await (0, envelope_1.decryptContent)(dek, record.enc);
39
+ const out = { ...record };
40
+ for (const key of contentSchema_1.CONTENT_SCHEMA[model].encrypted) {
41
+ if (key in fields)
42
+ out[key] = fields[key];
43
+ }
44
+ return out;
45
+ }
46
+ // Hydrate a whole list. Decrypt failures on individual rows are tolerated (that
47
+ // row stays blanked) so one corrupt blob can't blank the entire view.
48
+ //
49
+ // `T` is intentionally loose (not constrained to StoredRecord) so callers can
50
+ // pass their domain arrays (Person[], Task[], ...) without an index signature.
51
+ // Each record is treated structurally as a StoredRecord internally.
52
+ async function hydrateRecords(model, dek, records) {
53
+ return Promise.all(records.map(async (r) => {
54
+ try {
55
+ return (await hydrateRecord(model, dek, r));
56
+ }
57
+ catch {
58
+ return r;
59
+ }
60
+ }));
61
+ }
62
+ // Encrypt the sensitive subset of a plain payload under the DEK, returning a
63
+ // wire payload with those plaintext fields blanked + an `enc` blob set. Because
64
+ // `enc` is a single per-doc blob, any encrypted write MUST include the full
65
+ // sensitive set — callers pass the merged full record (see buildWritePayload).
66
+ async function encryptPayload(model, dek, fullSensitive, cleartext, dekVersion = 1) {
67
+ const spec = contentSchema_1.CONTENT_SCHEMA[model];
68
+ // Bundle every sensitive field (defaulting missing ones) so the single blob
69
+ // is always complete and self-consistent.
70
+ const bundle = {};
71
+ for (const key of spec.encrypted) {
72
+ bundle[key] = fullSensitive[key];
73
+ }
74
+ const enc = await (0, envelope_1.encryptContent)(dek, bundle, dekVersion);
75
+ // Start from the cleartext scheduling fields the caller wants to write.
76
+ const payload = { ...cleartext };
77
+ // Blank the plaintext columns for every encrypted field so the server stores
78
+ // no plaintext content.
79
+ for (const key of spec.encrypted) {
80
+ const blank = spec.blankWith[key];
81
+ payload[key] = blank === "[]" ? [] : blank ?? "";
82
+ }
83
+ payload.enc = enc;
84
+ payload.encMigrated = true;
85
+ return payload;
86
+ }
87
+ // Split a flat plain record into its sensitive vs cleartext halves by the model
88
+ // schema. Helper for callers that hold one merged object.
89
+ function splitRecord(model, record) {
90
+ const spec = contentSchema_1.CONTENT_SCHEMA[model];
91
+ const encSet = new Set(spec.encrypted);
92
+ const sensitive = {};
93
+ const clear = {};
94
+ for (const [k, v] of Object.entries(record)) {
95
+ if (encSet.has(k))
96
+ sensitive[k] = v;
97
+ else
98
+ clear[k] = v;
99
+ }
100
+ return { sensitive, clear };
101
+ }
102
+ // High-level write helper the data layer calls.
103
+ //
104
+ // `fullRecord` is the COMPLETE plain record (merge of the existing decrypted doc
105
+ // + the user's edits) — used to assemble the complete sensitive blob, since
106
+ // `enc` is one per-doc blob and a partial bundle would drop unchanged sensitive
107
+ // fields.
108
+ //
109
+ // `changes` (optional) is the user's intended field changes only. When provided
110
+ // on an encrypted write, ONLY its CLEARTEXT keys are sent alongside the blob —
111
+ // so we don't echo back populated refs / _id / timestamps from the merged
112
+ // record (which the server's patch schema would reject). When omitted,
113
+ // `fullRecord` itself supplies the cleartext.
114
+ //
115
+ // - encrypted account + dek → encrypt sensitive into `enc`, blank plaintext,
116
+ // send only the cleartext scheduling fields
117
+ // - otherwise → pass the plain payload through unchanged
118
+ async function buildWritePayload(model, opts, fullRecord, changes) {
119
+ if (!opts.encrypted || !opts.dek) {
120
+ // Unencrypted: send the user's payload plaintext exactly as before. Strip
121
+ // any stray enc housekeeping fields.
122
+ const src = changes ?? fullRecord;
123
+ const { enc: _enc, encMigrated: _m, tagHashes: _t, ...rest } = src;
124
+ void _enc;
125
+ void _m;
126
+ void _t;
127
+ return rest;
128
+ }
129
+ // Encrypted: full sensitive set from fullRecord; cleartext from `changes` if
130
+ // given (the user's edits), else from fullRecord.
131
+ const { sensitive } = splitRecord(model, fullRecord);
132
+ const { clear } = splitRecord(model, changes ?? fullRecord);
133
+ // Drop fields the patch schema can't take (refs as populated objects, _id,
134
+ // timestamps). We keep only primitive / id-string / array cleartext values.
135
+ const safeClear = {};
136
+ for (const [k, v] of Object.entries(clear)) {
137
+ if (k === "_id" || k === "createdAt" || k === "updatedAt")
138
+ continue;
139
+ if (v && typeof v === "object" && !Array.isArray(v)) {
140
+ // Populated ref object -> its id string (e.g. company:{_id,name} -> id).
141
+ const id = v._id;
142
+ if (id != null)
143
+ safeClear[k] = String(id);
144
+ // else drop non-id objects (dates arrive as ISO strings, not objects)
145
+ continue;
146
+ }
147
+ safeClear[k] = v;
148
+ }
149
+ return encryptPayload(model, opts.dek, sensitive, safeClear, opts.dekVersion ?? 1);
150
+ }
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ // Nest encryption — envelope wrap/unwrap + content helpers.
3
+ //
4
+ // THE ENVELOPE MODEL: one DEK per user, wrapped under a per-method KEK. This
5
+ // module is method-agnostic — it takes a raw 256-bit KEK (whatever method
6
+ // produced it) and wraps or unwraps the DEK with it. The KEK derivation itself
7
+ // lives in crypto/kek/*.
8
+ //
9
+ // It also exposes the content encrypt/decrypt helpers the DATA LAYER uses to
10
+ // encrypt CRM fields under the DEK.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.wrapDek = wrapDek;
13
+ exports.unwrapDek = unwrapDek;
14
+ exports.encryptContent = encryptContent;
15
+ exports.decryptContent = decryptContent;
16
+ const primitives_1 = require("./primitives");
17
+ // ---------------------------------------------------------------------------
18
+ // DEK wrap / unwrap (the envelope)
19
+ // ---------------------------------------------------------------------------
20
+ // Wrap the DEK under a method's KEK, producing the blob to POST to the server.
21
+ async function wrapDek(kek, dek, meta) {
22
+ const blob = await (0, primitives_1.aesGcmEncrypt)(kek, dek);
23
+ return {
24
+ method: meta.method,
25
+ binding: meta.binding,
26
+ kdfSalt: meta.kdfSalt,
27
+ wrappedKey: blob.ct,
28
+ iv: blob.iv,
29
+ authTag: blob.tag,
30
+ kdf: meta.kdf,
31
+ dekVersion: meta.dekVersion ?? 1,
32
+ label: meta.label,
33
+ };
34
+ }
35
+ // Unwrap the DEK from a stored record using a freshly re-derived KEK. Throws on
36
+ // a bad KEK (GCM tag mismatch) — that's the integrity check.
37
+ async function unwrapDek(kek, record) {
38
+ const blob = {
39
+ iv: record.iv,
40
+ ct: record.wrappedKey,
41
+ tag: record.authTag,
42
+ };
43
+ return (0, primitives_1.aesGcmDecrypt)(kek, blob);
44
+ }
45
+ // Encrypt a CRM document's sensitive fields under the DEK. Caller passes a plain
46
+ // object of the to-be-hidden fields; we JSON-serialise + AES-GCM it into one
47
+ // `enc` blob (per-doc, not per-field, to minimise IV management).
48
+ async function encryptContent(dek, fields, dekVersion = 1) {
49
+ const blob = await (0, primitives_1.aesGcmEncrypt)(dek, (0, primitives_1.utf8)(JSON.stringify(fields)));
50
+ return { v: dekVersion, iv: blob.iv, ct: blob.ct, tag: blob.tag };
51
+ }
52
+ // Decrypt an `enc` envelope back into the original fields object.
53
+ async function decryptContent(dek, env) {
54
+ const bytes = await (0, primitives_1.aesGcmDecrypt)(dek, {
55
+ iv: env.iv,
56
+ ct: env.ct,
57
+ tag: env.tag,
58
+ });
59
+ return JSON.parse((0, primitives_1.fromUtf8)(bytes));
60
+ }
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ // Nest encryption — password-derived KEK.
3
+ //
4
+ // The KEK is derived CLIENT-SIDE with Argon2id via hash-wasm (a portable WASM
5
+ // build), so the password and the KEK never reach the server.
6
+ //
7
+ // KEK = Argon2id(password, salt) (32-byte output)
8
+ //
9
+ // We store ONLY the random salt + the Argon2 params (m/t/p) in the wrap record
10
+ // so the same password re-derives the same KEK on any device. The password and
11
+ // KEK are never persisted or transmitted.
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.ARGON2_PARAMS = void 0;
14
+ exports.derivePasswordKekNew = derivePasswordKekNew;
15
+ exports.derivePasswordKek = derivePasswordKek;
16
+ exports.passwordWrapMeta = passwordWrapMeta;
17
+ const hash_wasm_1 = require("hash-wasm");
18
+ const primitives_1 = require("../primitives");
19
+ // Argon2id params (m=64MB, t=3, p=1). Tunable later via the stored kdf
20
+ // descriptor without breaking old wraps (each row carries its own params).
21
+ exports.ARGON2_PARAMS = {
22
+ // hash-wasm takes memory in KiB; 64 MiB = 65536 KiB.
23
+ memorySizeKiB: 65536,
24
+ iterations: 3,
25
+ parallelism: 1,
26
+ hashLength: 32,
27
+ };
28
+ // Derive a NEW password KEK (registration / set-password). Generates a fresh
29
+ // random 16-byte salt and returns the KEK + the descriptor to persist.
30
+ async function derivePasswordKekNew(password) {
31
+ const salt = crypto.getRandomValues(new Uint8Array(16));
32
+ const kek = await runArgon2(password, salt);
33
+ return {
34
+ kek,
35
+ kdfSalt: (0, primitives_1.bytesToB64u)(salt),
36
+ kdf: {
37
+ alg: "argon2id",
38
+ m: exports.ARGON2_PARAMS.memorySizeKiB,
39
+ t: exports.ARGON2_PARAMS.iterations,
40
+ p: exports.ARGON2_PARAMS.parallelism,
41
+ },
42
+ };
43
+ }
44
+ // Re-derive an EXISTING password KEK at unlock time, using the stored salt
45
+ // (and, if present, the stored params).
46
+ async function derivePasswordKek(password, kdfSalt, kdf) {
47
+ const salt = (0, primitives_1.b64uToBytes)(kdfSalt);
48
+ return runArgon2(password, salt, kdf);
49
+ }
50
+ async function runArgon2(password, salt, kdf) {
51
+ const memorySize = typeof kdf?.m === "number" ? kdf.m : exports.ARGON2_PARAMS.memorySizeKiB;
52
+ const iterations = typeof kdf?.t === "number" ? kdf.t : exports.ARGON2_PARAMS.iterations;
53
+ const parallelism = typeof kdf?.p === "number" ? kdf.p : exports.ARGON2_PARAMS.parallelism;
54
+ const hex = await (0, hash_wasm_1.argon2id)({
55
+ password,
56
+ salt,
57
+ memorySize,
58
+ iterations,
59
+ parallelism,
60
+ hashLength: exports.ARGON2_PARAMS.hashLength,
61
+ outputType: "hex",
62
+ });
63
+ // hex -> bytes
64
+ const out = new Uint8Array(hex.length / 2);
65
+ for (let i = 0; i < out.length; i++) {
66
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
67
+ }
68
+ return out;
69
+ }
70
+ // Binding + KDF descriptor to persist with a password-wrapped DEK. The binding
71
+ // is the "password" sentinel (one password wrap per user).
72
+ function passwordWrapMeta(result) {
73
+ return {
74
+ method: "password",
75
+ binding: "password",
76
+ kdfSalt: result.kdfSalt,
77
+ kdf: result.kdf,
78
+ };
79
+ }