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/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(JSON.stringify({ exp: Date.now() + SESSION_TTL_MS }), 'utf8');
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
- function loadSession(projectRoot) {
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
- return open(blob, sessionKey);
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
- async function runUnlock({ projectRoot, config, K, removeStubs = true, log = () => {} }) {
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
- if (isAlsoSealed && fs.existsSync(abs)) {
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 manifest.files) {
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
- const plain = open(blob, K);
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
- writeFileEnsuringDir(target, plain, entry.m);
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 { count: manifest.files.length, sealedAt: manifest.sealedAt };
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
- const bytes = fs.readFileSync(abs);
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
- m: stat.mode & 0o777,
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
+ };