sealcode 0.3.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -3
- package/package.json +1 -1
- package/src/api.js +14 -1
- package/src/cli-auth.js +26 -0
- package/src/cli-grants.js +409 -17
- package/src/cli-guard.js +117 -0
- package/src/cli-link.js +10 -1
- package/src/cli-service.js +230 -0
- package/src/cli-watch.js +659 -85
- package/src/cli.js +154 -3
- package/src/device.js +163 -0
- package/src/grant-policy.js +119 -0
- package/src/keypair.js +159 -0
- package/src/keystore.js +76 -6
- package/src/open.js +69 -6
- package/src/seal.js +68 -3
- package/src/share-crypto.js +155 -0
- package/src/watermark.js +212 -0
package/src/keystore.js
CHANGED
|
@@ -158,14 +158,37 @@ async function rotatePassphrase(projectRoot, lockedDir, oldPassphrase, newPassph
|
|
|
158
158
|
* Save unwrapped K to a short-lived session file so subsequent commands skip
|
|
159
159
|
* scrypt. Session is encrypted by a host-binding key derived from machine
|
|
160
160
|
* details + project id, so copying the file to another machine doesn't help.
|
|
161
|
+
*
|
|
162
|
+
* `extraMeta` (sealcode@1.0.0+, optional, forward-compatible): merged into
|
|
163
|
+
* the session meta JSON. Older CLIs that only read `meta.exp` ignore the
|
|
164
|
+
* extra fields silently. Recognized fields used by sealcode@1.0.0+:
|
|
165
|
+
*
|
|
166
|
+
* source: "passphrase" | "recovery" | "grant"
|
|
167
|
+
* grantId: server-side grant id (when source === "grant")
|
|
168
|
+
* grantCodeHash: sha-256 hex of the grant code (NEVER the plaintext)
|
|
169
|
+
* grantExpiresAt: ISO-8601 wall-clock expiry
|
|
170
|
+
* deviceFingerprint: opaque device id at unlock time
|
|
171
|
+
* watchPidFile: absolute path to the watcher PID heartbeat file
|
|
172
|
+
* strictWatch: true → cli-guard hard-locks on dead watcher
|
|
173
|
+
*
|
|
174
|
+
* None of these are secrets — they're operational hints that let cli-guard,
|
|
175
|
+
* cli-watch, and the system-service installer coordinate.
|
|
161
176
|
*/
|
|
162
|
-
function saveSession(projectRoot, K) {
|
|
177
|
+
function saveSession(projectRoot, K, extraMeta = null) {
|
|
163
178
|
ensureDir(SESSION_DIR);
|
|
164
179
|
const id = projectId(projectRoot);
|
|
165
180
|
const hostBind = sha256Hex(`${os.hostname()}|${os.userInfo().username}|${id}`);
|
|
166
181
|
const sessionKey = Buffer.from(hostBind.slice(0, 64), 'hex');
|
|
167
182
|
const blob = seal(K, sessionKey);
|
|
168
|
-
const meta = Buffer.from(
|
|
183
|
+
const meta = Buffer.from(
|
|
184
|
+
JSON.stringify({ exp: Date.now() + SESSION_TTL_MS, ...(extraMeta || {}) }),
|
|
185
|
+
'utf8',
|
|
186
|
+
);
|
|
187
|
+
if (meta.length > 0xffff) {
|
|
188
|
+
// Two-byte length field cap. If we ever blow past this we'd need a
|
|
189
|
+
// larger header, but 64 KB of metadata is more than we'd ever want.
|
|
190
|
+
throw new Error('session meta is unreasonably large');
|
|
191
|
+
}
|
|
169
192
|
const wrapped = Buffer.concat([
|
|
170
193
|
Buffer.from([meta.length & 0xff, (meta.length >> 8) & 0xff]),
|
|
171
194
|
meta,
|
|
@@ -174,12 +197,15 @@ function saveSession(projectRoot, K) {
|
|
|
174
197
|
fs.writeFileSync(path.join(SESSION_DIR, id), wrapped, { mode: 0o600 });
|
|
175
198
|
}
|
|
176
199
|
|
|
177
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Internal: load + decrypt the session file, returning { K, meta }. Returns
|
|
202
|
+
* null if missing / expired / tampered / can't unwrap. Used by both the
|
|
203
|
+
* legacy `loadSession()` (returns K only) and the new `loadSessionMeta()`.
|
|
204
|
+
*/
|
|
205
|
+
function loadSessionRaw(projectRoot) {
|
|
178
206
|
const id = projectId(projectRoot);
|
|
179
207
|
let p = path.join(SESSION_DIR, id);
|
|
180
208
|
if (!fs.existsSync(p)) {
|
|
181
|
-
// Fall back to the legacy ~/.vaultline/sessions location for users
|
|
182
|
-
// upgrading from vaultline 1.x.
|
|
183
209
|
const legacy = path.join(LEGACY_SESSION_DIR, id);
|
|
184
210
|
if (!fs.existsSync(legacy)) return null;
|
|
185
211
|
p = legacy;
|
|
@@ -209,12 +235,53 @@ function loadSession(projectRoot) {
|
|
|
209
235
|
const hostBind = sha256Hex(`${os.hostname()}|${os.userInfo().username}|${id}`);
|
|
210
236
|
const sessionKey = Buffer.from(hostBind.slice(0, 64), 'hex');
|
|
211
237
|
try {
|
|
212
|
-
|
|
238
|
+
const K = open(blob, sessionKey);
|
|
239
|
+
return { K, meta, path: p };
|
|
213
240
|
} catch (_) {
|
|
214
241
|
return null;
|
|
215
242
|
}
|
|
216
243
|
}
|
|
217
244
|
|
|
245
|
+
function loadSession(projectRoot) {
|
|
246
|
+
const r = loadSessionRaw(projectRoot);
|
|
247
|
+
return r ? r.K : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* sealcode@1.0.0: structured access to the session metadata. Returns null
|
|
252
|
+
* if there is no live session. Callers MUST handle `meta.source` being
|
|
253
|
+
* undefined (legacy session files from 0.x are valid "passphrase" sessions
|
|
254
|
+
* without any source tag).
|
|
255
|
+
*/
|
|
256
|
+
function loadSessionMeta(projectRoot) {
|
|
257
|
+
const r = loadSessionRaw(projectRoot);
|
|
258
|
+
if (!r) return null;
|
|
259
|
+
return {
|
|
260
|
+
K: r.K,
|
|
261
|
+
meta: {
|
|
262
|
+
source: 'passphrase',
|
|
263
|
+
...r.meta,
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* sealcode@1.0.0: re-write just the metadata of an existing session
|
|
270
|
+
* (preserving K). Used after spawning the watcher so we can record its
|
|
271
|
+
* pid file path back into the session.
|
|
272
|
+
*/
|
|
273
|
+
function updateSessionMeta(projectRoot, patch) {
|
|
274
|
+
const r = loadSessionRaw(projectRoot);
|
|
275
|
+
if (!r) return false;
|
|
276
|
+
const next = { ...r.meta, ...patch };
|
|
277
|
+
// Re-call saveSession with the merged extras. We pass `next` minus `exp`
|
|
278
|
+
// so saveSession's own TTL extension still wins (every meta update is
|
|
279
|
+
// also an activity ping → fresh 8h TTL).
|
|
280
|
+
const { exp: _exp, ...extras } = next;
|
|
281
|
+
saveSession(projectRoot, r.K, extras);
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
218
285
|
function clearSession(projectRoot) {
|
|
219
286
|
const id = projectId(projectRoot);
|
|
220
287
|
for (const dir of [SESSION_DIR, LEGACY_SESSION_DIR]) {
|
|
@@ -236,12 +303,15 @@ module.exports = {
|
|
|
236
303
|
WRAP_PASS_NAME,
|
|
237
304
|
WRAP_RECOVERY_NAME,
|
|
238
305
|
MANIFEST_NAME,
|
|
306
|
+
SESSION_DIR,
|
|
239
307
|
bootstrap,
|
|
240
308
|
persistBootstrap,
|
|
241
309
|
unwrap,
|
|
242
310
|
rotatePassphrase,
|
|
243
311
|
saveSession,
|
|
244
312
|
loadSession,
|
|
313
|
+
loadSessionMeta,
|
|
314
|
+
updateSessionMeta,
|
|
245
315
|
clearSession,
|
|
246
316
|
isInitialized,
|
|
247
317
|
manifestBlobPath,
|
package/src/open.js
CHANGED
|
@@ -5,16 +5,57 @@ const path = require('path');
|
|
|
5
5
|
const { open } = require('./crypto');
|
|
6
6
|
const { readManifest } = require('./manifest');
|
|
7
7
|
const { ensureDir, writeFileEnsuringDir } = require('./util');
|
|
8
|
+
const watermark = require('./watermark');
|
|
9
|
+
const grantPolicy = require('./grant-policy');
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Unlock the vault into plaintext.
|
|
13
|
+
*
|
|
14
|
+
* sealcode@1.1.0 additions (all optional, default to "no policy"):
|
|
15
|
+
*
|
|
16
|
+
* policy.allowedPaths — only decrypt entries whose path matches one
|
|
17
|
+
* of these POSIX-style prefixes. Other files
|
|
18
|
+
* stay as ciphertext blobs on disk and are
|
|
19
|
+
* simply not materialized.
|
|
20
|
+
* policy.mode — 'ro' chmods every unlocked file to 0444 and
|
|
21
|
+
* (in combination with the watcher) re-locks
|
|
22
|
+
* the project the moment any file is modified.
|
|
23
|
+
* policy.watermark — template string injected as a per-filetype
|
|
24
|
+
* comment at the top of each unlocked file
|
|
25
|
+
* so leaks are traceable back to a grant.
|
|
26
|
+
* policy.watermarkCtx — substitutions for the template
|
|
27
|
+
* ({email}, {grantId}, …).
|
|
28
|
+
*
|
|
29
|
+
* Callers that don't pass `policy` get the legacy behavior unchanged.
|
|
30
|
+
*/
|
|
31
|
+
async function runUnlock({
|
|
32
|
+
projectRoot,
|
|
33
|
+
config,
|
|
34
|
+
K,
|
|
35
|
+
removeStubs = true,
|
|
36
|
+
log = () => {},
|
|
37
|
+
policy = null,
|
|
38
|
+
watermarkCtx = null,
|
|
39
|
+
}) {
|
|
40
|
+
const p = grantPolicy.normalize(policy || {});
|
|
10
41
|
const manifest = readManifest(projectRoot, config.lockedDir, K);
|
|
11
42
|
const lockedRoot = path.join(projectRoot, config.lockedDir);
|
|
12
43
|
|
|
44
|
+
const filesToUnlock = grantPolicy.filterManifestFiles(
|
|
45
|
+
manifest.files,
|
|
46
|
+
p.allowedPaths,
|
|
47
|
+
);
|
|
48
|
+
const allowedSet = new Set(filesToUnlock.map((f) => f.p));
|
|
49
|
+
const skippedCount = manifest.files.length - filesToUnlock.length;
|
|
50
|
+
|
|
13
51
|
if (removeStubs && manifest.stubs) {
|
|
14
52
|
for (const stubPath of Object.keys(manifest.stubs)) {
|
|
15
53
|
const abs = path.join(projectRoot, stubPath);
|
|
16
54
|
const isAlsoSealed = manifest.files.some((f) => f.p === stubPath);
|
|
17
|
-
|
|
55
|
+
// If the stub corresponds to a file we're NOT going to unlock
|
|
56
|
+
// (because of allowedPaths), leave the stub in place — that way
|
|
57
|
+
// the contractor still sees a placeholder, not a missing file.
|
|
58
|
+
if (isAlsoSealed && allowedSet.has(stubPath) && fs.existsSync(abs)) {
|
|
18
59
|
try {
|
|
19
60
|
fs.unlinkSync(abs);
|
|
20
61
|
} catch (_) {
|
|
@@ -24,20 +65,42 @@ async function runUnlock({ projectRoot, config, K, removeStubs = true, log = ()
|
|
|
24
65
|
}
|
|
25
66
|
}
|
|
26
67
|
|
|
27
|
-
for (const entry of
|
|
68
|
+
for (const entry of filesToUnlock) {
|
|
28
69
|
const lockedAbs = path.join(lockedRoot, entry.l);
|
|
29
70
|
if (!fs.existsSync(lockedAbs)) {
|
|
30
71
|
throw new Error(`unlock: missing locked blob for ${entry.p} (expected ${entry.l})`);
|
|
31
72
|
}
|
|
32
73
|
const blob = fs.readFileSync(lockedAbs);
|
|
33
|
-
|
|
74
|
+
let plain = open(blob, K);
|
|
75
|
+
|
|
76
|
+
// sealcode@1.1.0 — inject a per-grant watermark before writing the
|
|
77
|
+
// plaintext to disk. The watermark module is filetype-aware and
|
|
78
|
+
// skips binaries / unknown syntaxes silently.
|
|
79
|
+
if (p.watermark) {
|
|
80
|
+
plain = watermark.applyWatermark(plain, entry.p, p.watermark, watermarkCtx || {});
|
|
81
|
+
}
|
|
82
|
+
|
|
34
83
|
const target = path.join(projectRoot, entry.p);
|
|
35
84
|
ensureDir(path.dirname(target));
|
|
36
|
-
|
|
85
|
+
// Apply RO mode by overriding the manifest's recorded mode with 0444.
|
|
86
|
+
// (We still pass entry.m for rw to preserve executable bits.)
|
|
87
|
+
const effectiveMode = p.mode === 'ro' ? 0o444 : entry.m;
|
|
88
|
+
writeFileEnsuringDir(target, plain, effectiveMode);
|
|
89
|
+
|
|
90
|
+
// Even though writeFileEnsuringDir set the mode, some filesystems
|
|
91
|
+
// strip the bits on a fresh create — re-apply to be sure.
|
|
92
|
+
if (p.mode === 'ro') {
|
|
93
|
+
try { fs.chmodSync(target, 0o444); } catch (_) { /* best-effort */ }
|
|
94
|
+
}
|
|
37
95
|
log(` unlocked ${entry.p}`);
|
|
38
96
|
}
|
|
39
97
|
|
|
40
|
-
return {
|
|
98
|
+
return {
|
|
99
|
+
count: filesToUnlock.length,
|
|
100
|
+
skipped: skippedCount,
|
|
101
|
+
sealedAt: manifest.sealedAt,
|
|
102
|
+
policy: p,
|
|
103
|
+
};
|
|
41
104
|
}
|
|
42
105
|
|
|
43
106
|
module.exports = { runUnlock };
|
package/src/seal.js
CHANGED
|
@@ -20,6 +20,8 @@ const {
|
|
|
20
20
|
nowIso,
|
|
21
21
|
normPath,
|
|
22
22
|
} = require('./util');
|
|
23
|
+
const watermark = require('./watermark');
|
|
24
|
+
const { readManifest } = require('./manifest');
|
|
23
25
|
|
|
24
26
|
async function collectFiles(projectRoot, cfg) {
|
|
25
27
|
const matches = await fg(cfg.include, {
|
|
@@ -71,12 +73,32 @@ async function runLock({
|
|
|
71
73
|
bootstrapOutput,
|
|
72
74
|
keepOriginals = false,
|
|
73
75
|
log = () => {},
|
|
76
|
+
// sealcode@1.1.0 — when a contractor's session was scoped (only a
|
|
77
|
+
// subset of files were ever decrypted), re-locking would otherwise
|
|
78
|
+
// WIPE the out-of-scope ciphertext blobs because they have no
|
|
79
|
+
// corresponding plaintext on disk. `preserveUnseen` carries the
|
|
80
|
+
// previous manifest's blobs forward for any path that isn't currently
|
|
81
|
+
// on disk as plaintext. The watcher's `finalLock` opts into this
|
|
82
|
+
// automatically for grant-derived sessions.
|
|
83
|
+
preserveUnseen = false,
|
|
74
84
|
}) {
|
|
75
85
|
const lockedDir = config.lockedDir;
|
|
76
86
|
const lockedRoot = path.join(projectRoot, lockedDir);
|
|
77
87
|
|
|
88
|
+
// Snapshot the existing manifest BEFORE we wipe lockedRoot, so we can
|
|
89
|
+
// copy unseen-but-still-relevant blobs forward.
|
|
90
|
+
let prevManifest = null;
|
|
91
|
+
if (preserveUnseen) {
|
|
92
|
+
try {
|
|
93
|
+
prevManifest = readManifest(projectRoot, lockedDir, K);
|
|
94
|
+
} catch (_) {
|
|
95
|
+
// No manifest yet (first init) or unreadable — fall through.
|
|
96
|
+
prevManifest = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
const files = await collectFiles(projectRoot, config);
|
|
79
|
-
if (files.length === 0) {
|
|
101
|
+
if (files.length === 0 && !prevManifest) {
|
|
80
102
|
throw new Error('SEALCODE_NOTHING_TO_LOCK');
|
|
81
103
|
}
|
|
82
104
|
|
|
@@ -93,6 +115,23 @@ async function runLock({
|
|
|
93
115
|
if (existingRecoveryWrap)
|
|
94
116
|
saved[WRAP_RECOVERY_NAME] = fs.readFileSync(path.join(lockedRoot, WRAP_RECOVERY_NAME));
|
|
95
117
|
|
|
118
|
+
// Carry forward unseen blobs (sealcode@1.1.0). We hold the BYTES in
|
|
119
|
+
// memory so we can wipe lockedRoot and re-stage cleanly. For typical
|
|
120
|
+
// projects this is fine; for very large repos with many GB of unseen
|
|
121
|
+
// blobs the watcher should switch to a copy-then-rename strategy.
|
|
122
|
+
const preservedBlobs = []; // [{ entry, bytes }]
|
|
123
|
+
if (prevManifest && prevManifest.files) {
|
|
124
|
+
const plaintextSet = new Set(files);
|
|
125
|
+
for (const entry of prevManifest.files) {
|
|
126
|
+
if (plaintextSet.has(entry.p)) continue; // we have plaintext for it
|
|
127
|
+
const oldBlobPath = path.join(lockedRoot, entry.l);
|
|
128
|
+
if (!fs.existsSync(oldBlobPath)) continue;
|
|
129
|
+
try {
|
|
130
|
+
preservedBlobs.push({ entry, bytes: fs.readFileSync(oldBlobPath) });
|
|
131
|
+
} catch (_) { /* skip */ }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
96
135
|
rmIfExists(lockedRoot);
|
|
97
136
|
ensureDir(lockedRoot);
|
|
98
137
|
|
|
@@ -113,7 +152,18 @@ async function runLock({
|
|
|
113
152
|
for (const rel of files) {
|
|
114
153
|
const abs = path.join(projectRoot, rel);
|
|
115
154
|
const stat = fs.statSync(abs);
|
|
116
|
-
|
|
155
|
+
// sealcode@1.1.0 — any watermark we injected on unlock is stripped
|
|
156
|
+
// here so the encrypted blob is identical to what the owner saw
|
|
157
|
+
// pre-share. This is what keeps repeat lock/unlock cycles from
|
|
158
|
+
// accumulating stacked watermarks AND keeps the lock idempotent
|
|
159
|
+
// (a recipient who modifies and re-locks doesn't change the blob
|
|
160
|
+
// for files they didn't actually touch).
|
|
161
|
+
const raw = fs.readFileSync(abs);
|
|
162
|
+
const bytes = watermark.stripWatermark(raw, rel);
|
|
163
|
+
// If RO mode set 0444, fs.writeFileSync above already failed for the
|
|
164
|
+
// recipient — they couldn't have modified. For OWNER side this is a
|
|
165
|
+
// no-op because watermark.stripWatermark returns the input untouched
|
|
166
|
+
// when no sentinel is found.
|
|
117
167
|
|
|
118
168
|
const lockedRel = opaqueName(rel, K);
|
|
119
169
|
if (reservedNames.has(lockedRel)) {
|
|
@@ -135,12 +185,27 @@ async function runLock({
|
|
|
135
185
|
p: rel,
|
|
136
186
|
l: lockedRel,
|
|
137
187
|
h: sha256Hex(bytes),
|
|
138
|
-
|
|
188
|
+
// Persist the OWNER's intended mode (we strip any "RO grant" 0444
|
|
189
|
+
// that the recipient's CLI applied at unlock time — that's a local
|
|
190
|
+
// recipient-side enforcement decision, not a property of the file).
|
|
191
|
+
m: (stat.mode & 0o777) | 0o200, // ensure owner-writable, otherwise
|
|
192
|
+
// a re-lock by the owner would
|
|
193
|
+
// baked-in 0444 forever
|
|
139
194
|
s: bytes.length,
|
|
140
195
|
});
|
|
141
196
|
log(` locked ${rel}`);
|
|
142
197
|
}
|
|
143
198
|
|
|
199
|
+
// Stage the preserved (unseen) blobs back into lockedRoot and append
|
|
200
|
+
// their original manifest entries. This is what keeps a scoped-grant
|
|
201
|
+
// re-lock from destroying the project for everyone else.
|
|
202
|
+
for (const { entry, bytes } of preservedBlobs) {
|
|
203
|
+
const lockedAbs = path.join(lockedRoot, entry.l);
|
|
204
|
+
ensureDir(path.dirname(lockedAbs));
|
|
205
|
+
fs.writeFileSync(lockedAbs, bytes);
|
|
206
|
+
manifestFiles.push(entry);
|
|
207
|
+
}
|
|
208
|
+
|
|
144
209
|
const stubsApplied = applyStubs(projectRoot, config.stubs);
|
|
145
210
|
|
|
146
211
|
const manifest = {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrap / unwrap the project master key K for a specific recipient.
|
|
5
|
+
*
|
|
6
|
+
* This is the cryptographic core of "team key sharing": the owner runs
|
|
7
|
+
* `sealcode share --to alice@x.com`, the CLI fetches Alice's pubkey from
|
|
8
|
+
* the server, wraps K to it, and uploads the opaque blob alongside the
|
|
9
|
+
* access code. When Alice runs `sealcode redeem <code>` on her machine,
|
|
10
|
+
* her CLI downloads the blob, unwraps it with her local privkey, and
|
|
11
|
+
* caches K — no passphrase ever leaves either machine, and the server
|
|
12
|
+
* never sees K or either party's privkey.
|
|
13
|
+
*
|
|
14
|
+
* Construction (lightly inspired by NaCl/libsodium `crypto_box_seal`):
|
|
15
|
+
*
|
|
16
|
+
* 1. Sender generates an EPHEMERAL X25519 keypair (eph_priv, eph_pub).
|
|
17
|
+
* 2. Compute X25519 ECDH: shared = dh(eph_priv, recipient_pub).
|
|
18
|
+
* 3. Compute kdf_salt = sha256(eph_pub || recipient_pub).
|
|
19
|
+
* (Inputs both pubkeys so the AES key is bound to *this* exchange,
|
|
20
|
+
* preventing key reuse attacks across grants.)
|
|
21
|
+
* 4. wrap_key = HKDF-SHA256(shared, salt = kdf_salt, info = "sealcode-share-v1", L = 32).
|
|
22
|
+
* 5. nonce = 12 random bytes.
|
|
23
|
+
* 6. ct = AES-256-GCM(plaintext = K, key = wrap_key, iv = nonce, aad = "sealcode-share-v1").
|
|
24
|
+
* 7. Output: { v:1, algo, ephPub, nonce, ct } — eph_priv is discarded.
|
|
25
|
+
*
|
|
26
|
+
* Recipient does the same DH (their_priv, eph_pub) and decrypts.
|
|
27
|
+
*
|
|
28
|
+
* Properties:
|
|
29
|
+
* - Forward secrecy WRT the sender (ephemeral key destroyed).
|
|
30
|
+
* - Server can't decrypt (it has neither privkey).
|
|
31
|
+
* - Bound to (eph_pub, recipient_pub) — replaying the same blob to a
|
|
32
|
+
* different recipient is computationally infeasible.
|
|
33
|
+
* - No new dependencies (Node 18+ stdlib only).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const crypto = require('crypto');
|
|
37
|
+
const { _rawPublic, _publicFromRaw, _privateFromRaw } = require('./keypair');
|
|
38
|
+
|
|
39
|
+
const VERSION = 1;
|
|
40
|
+
const ALGO = 'x25519-hkdf-sha256-aes256gcm-v1';
|
|
41
|
+
const AAD = Buffer.from('sealcode-share-v1', 'utf8');
|
|
42
|
+
const INFO = Buffer.from('sealcode-share-v1', 'utf8');
|
|
43
|
+
|
|
44
|
+
function hkdf(ikm, salt, info, length) {
|
|
45
|
+
// Node has a built-in HKDF; use it for clarity. Returns ArrayBuffer.
|
|
46
|
+
return Buffer.from(crypto.hkdfSync('sha256', ikm, salt, info, length));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sha256(buf) {
|
|
50
|
+
return crypto.createHash('sha256').update(buf).digest();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wrap K to a recipient identified only by their published X25519 pubkey.
|
|
55
|
+
*
|
|
56
|
+
* @param {Buffer} K 32 bytes — the project master key
|
|
57
|
+
* @param {string} recipientPubB64 base64(raw 32B X25519 pubkey)
|
|
58
|
+
* @returns {{ v:number, algo:string, ephPub:string, nonce:string, ct:string }}
|
|
59
|
+
*/
|
|
60
|
+
function wrapForRecipient(K, recipientPubB64) {
|
|
61
|
+
if (!Buffer.isBuffer(K) || K.length !== 32) {
|
|
62
|
+
throw new Error('share-crypto: K must be a 32-byte Buffer');
|
|
63
|
+
}
|
|
64
|
+
const recipientPubRaw = Buffer.from(recipientPubB64, 'base64');
|
|
65
|
+
if (recipientPubRaw.length !== 32) {
|
|
66
|
+
throw new Error('share-crypto: recipient pubkey must be 32 bytes (base64)');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 1. Ephemeral X25519 keypair (discarded after wrap).
|
|
70
|
+
const { publicKey: ephPubKey, privateKey: ephPrivKey } =
|
|
71
|
+
crypto.generateKeyPairSync('x25519');
|
|
72
|
+
const ephPubRaw = _rawPublic(ephPubKey); // 32B
|
|
73
|
+
|
|
74
|
+
// 2. ECDH
|
|
75
|
+
const recipientPubKey = _publicFromRaw(recipientPubRaw);
|
|
76
|
+
const shared = crypto.diffieHellman({
|
|
77
|
+
privateKey: ephPrivKey,
|
|
78
|
+
publicKey: recipientPubKey,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// 3. KDF salt binds to BOTH pubkeys
|
|
82
|
+
const salt = sha256(Buffer.concat([ephPubRaw, recipientPubRaw]));
|
|
83
|
+
|
|
84
|
+
// 4. Derive AES key
|
|
85
|
+
const wrapKey = hkdf(shared, salt, INFO, 32);
|
|
86
|
+
|
|
87
|
+
// 5+6. AES-GCM
|
|
88
|
+
const nonce = crypto.randomBytes(12);
|
|
89
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', wrapKey, nonce);
|
|
90
|
+
cipher.setAAD(AAD);
|
|
91
|
+
const enc = Buffer.concat([cipher.update(K), cipher.final()]);
|
|
92
|
+
const tag = cipher.getAuthTag();
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
v: VERSION,
|
|
96
|
+
algo: ALGO,
|
|
97
|
+
ephPub: ephPubRaw.toString('base64'),
|
|
98
|
+
nonce: nonce.toString('base64'),
|
|
99
|
+
ct: Buffer.concat([enc, tag]).toString('base64'),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Unwrap a blob produced by wrapForRecipient using the recipient's
|
|
105
|
+
* private key. Throws on tamper / wrong recipient / version mismatch.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} blob { v, algo, ephPub, nonce, ct }
|
|
108
|
+
* @param {string} recipientPrivB64 base64(raw 32B X25519 privkey)
|
|
109
|
+
* @param {string} recipientPubB64 base64(raw 32B X25519 pubkey)
|
|
110
|
+
* @returns {Buffer} 32-byte K
|
|
111
|
+
*/
|
|
112
|
+
function unwrapWithRecipient(blob, recipientPrivB64, recipientPubB64) {
|
|
113
|
+
if (!blob || blob.v !== VERSION || blob.algo !== ALGO) {
|
|
114
|
+
throw new Error(`share-crypto: unsupported wrapped_key envelope (v=${blob && blob.v}, algo=${blob && blob.algo})`);
|
|
115
|
+
}
|
|
116
|
+
const ephPubRaw = Buffer.from(blob.ephPub, 'base64');
|
|
117
|
+
const nonce = Buffer.from(blob.nonce, 'base64');
|
|
118
|
+
const ctTag = Buffer.from(blob.ct, 'base64');
|
|
119
|
+
if (ephPubRaw.length !== 32 || nonce.length !== 12 || ctTag.length < 17) {
|
|
120
|
+
throw new Error('share-crypto: malformed wrapped_key fields');
|
|
121
|
+
}
|
|
122
|
+
const recipientPubRaw = Buffer.from(recipientPubB64, 'base64');
|
|
123
|
+
if (recipientPubRaw.length !== 32) {
|
|
124
|
+
throw new Error('share-crypto: malformed recipient pubkey');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Same DH but from recipient side.
|
|
128
|
+
const ourPriv = _privateFromRaw(Buffer.from(recipientPrivB64, 'base64'));
|
|
129
|
+
const theirEphPub = _publicFromRaw(ephPubRaw);
|
|
130
|
+
const shared = crypto.diffieHellman({
|
|
131
|
+
privateKey: ourPriv,
|
|
132
|
+
publicKey: theirEphPub,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const salt = sha256(Buffer.concat([ephPubRaw, recipientPubRaw]));
|
|
136
|
+
const wrapKey = hkdf(shared, salt, INFO, 32);
|
|
137
|
+
|
|
138
|
+
const ct = ctTag.subarray(0, ctTag.length - 16);
|
|
139
|
+
const tag = ctTag.subarray(ctTag.length - 16);
|
|
140
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', wrapKey, nonce);
|
|
141
|
+
decipher.setAAD(AAD);
|
|
142
|
+
decipher.setAuthTag(tag);
|
|
143
|
+
const K = Buffer.concat([decipher.update(ct), decipher.final()]);
|
|
144
|
+
if (K.length !== 32) {
|
|
145
|
+
throw new Error('share-crypto: unwrapped key is not 32 bytes');
|
|
146
|
+
}
|
|
147
|
+
return K;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
VERSION,
|
|
152
|
+
ALGO,
|
|
153
|
+
wrapForRecipient,
|
|
154
|
+
unwrapWithRecipient,
|
|
155
|
+
};
|