@safekb/mcp 0.6.0 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +18 -6
  2. package/dist/safekb-mcp.js +1031 -641
  3. package/package.json +2 -2
@@ -2359,7 +2359,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
2359
2359
  // package.json
2360
2360
  var package_default = {
2361
2361
  name: "@safekb/mcp",
2362
- version: "0.6.0",
2362
+ version: "0.7.0",
2363
2363
  description: "SafeKB local MCP client: proxies the hosted SafeKB tools and adds zero-knowledge sealed documents.",
2364
2364
  license: "SEE LICENSE IN LICENSE",
2365
2365
  author: "Mustafa Öztürk",
@@ -6622,8 +6622,8 @@ async function authorize(request) {
6622
6622
  }
6623
6623
 
6624
6624
  // src/backup/console.ts
6625
- import { createReadStream, existsSync as existsSync2, rmSync } from "node:fs";
6626
- import { open as open3, stat as stat2 } from "node:fs/promises";
6625
+ import { createReadStream as createReadStream2, existsSync as existsSync2, rmSync } from "node:fs";
6626
+ import { open as open4, readFile, stat as stat3 } from "node:fs/promises";
6627
6627
 
6628
6628
  // src/printable.ts
6629
6629
  var UNPRINTABLE2 = /[\p{Cc}\p{Cf}]/gu;
@@ -6636,6 +6636,87 @@ function printableLines(text) {
6636
6636
  `);
6637
6637
  }
6638
6638
 
6639
+ // src/transfer/keyBundle.ts
6640
+ var KEY_BUNDLE_KIND = "safekb-key-export";
6641
+ var KEY_BUNDLE_VERSION = 1;
6642
+ var KEY_INFO = "safekb/key-export/v1";
6643
+ var EXPORT_KDF = MIN_KDF_PARAMS;
6644
+ var MAX_KDF = { m: 256 * 1024, t: 8, p: 4 };
6645
+ var OBJECT_ID = /^[0-9a-f]{24}$/;
6646
+ function malformed(why) {
6647
+ return new ClientError(`Not a SafeKB key export: ${why}`);
6648
+ }
6649
+ function aadOf(salt, kdf) {
6650
+ return `${KEY_INFO}|${salt}|${kdf.m}|${kdf.t}|${kdf.p}`;
6651
+ }
6652
+ async function bundleKey(passphrase, salt, kdf) {
6653
+ return deriveKey(await derivePassphraseIkm(passphrase, salt, kdf), KEY_INFO);
6654
+ }
6655
+ function assertContent(value) {
6656
+ const raw = value;
6657
+ const names = raw?.names;
6658
+ if (!Array.isArray(raw?.keys) || typeof names !== "object" || names === null || Array.isArray(names) || !Object.entries(names).every(([rootId, name]) => OBJECT_ID.test(rootId) && typeof name === "string" && name.length > 0)) {
6659
+ throw malformed("its content is not keys and the names of their roots");
6660
+ }
6661
+ return { keys: raw.keys.map(assertKeyFile), names: { ...names } };
6662
+ }
6663
+ function parseBundle(text) {
6664
+ let value;
6665
+ try {
6666
+ value = JSON.parse(text);
6667
+ } catch {
6668
+ throw malformed("it is not valid JSON");
6669
+ }
6670
+ const raw = value;
6671
+ if (raw?.kind !== KEY_BUNDLE_KIND) {
6672
+ throw malformed(`its kind is not '${KEY_BUNDLE_KIND}'`);
6673
+ }
6674
+ if (raw.v !== KEY_BUNDLE_VERSION) {
6675
+ throw malformed(`its version is not ${KEY_BUNDLE_VERSION}; use the version of @safekb/mcp that wrote it`);
6676
+ }
6677
+ const { kdf, salt } = raw;
6678
+ if (typeof kdf !== "object" || kdf === null || typeof salt !== "string") {
6679
+ throw malformed("it has no KDF parameters and salt");
6680
+ }
6681
+ const params = assertKdfParams(kdf);
6682
+ if (params.m > MAX_KDF.m || params.t > MAX_KDF.t || params.p > MAX_KDF.p) {
6683
+ throw malformed("its KDF parameters are beyond what this client runs");
6684
+ }
6685
+ return {
6686
+ v: raw.v,
6687
+ kind: raw.kind,
6688
+ kdf: params,
6689
+ salt,
6690
+ sealed: assertEnvelope(raw.sealed)
6691
+ };
6692
+ }
6693
+ async function sealKeyBundle(content, passphrase) {
6694
+ const saltBytes = randomBytes(SALT_BYTES);
6695
+ const salt = toBase64(saltBytes);
6696
+ const bundle = {
6697
+ v: KEY_BUNDLE_VERSION,
6698
+ kind: KEY_BUNDLE_KIND,
6699
+ kdf: { ...EXPORT_KDF },
6700
+ salt,
6701
+ sealed: await sealEnvelope(await bundleKey(passphrase, saltBytes, EXPORT_KDF), utf8Encode(JSON.stringify(assertContent(content))), aadOf(salt, EXPORT_KDF))
6702
+ };
6703
+ return `${JSON.stringify(bundle, null, 2)}
6704
+ `;
6705
+ }
6706
+ async function openKeyBundle(text, passphrase) {
6707
+ const bundle = parseBundle(text);
6708
+ const key = await bundleKey(passphrase, fromBase64(bundle.salt), bundle.kdf);
6709
+ let plaintext;
6710
+ try {
6711
+ plaintext = await openEnvelope(key, bundle.sealed, aadOf(bundle.salt, bundle.kdf));
6712
+ } catch (error) {
6713
+ if (!(error instanceof DecryptionError))
6714
+ throw error;
6715
+ throw new ClientError("Wrong passphrase, or the export file was changed or damaged");
6716
+ }
6717
+ return assertContent(JSON.parse(utf8Decode(plaintext)));
6718
+ }
6719
+
6639
6720
  // src/transfer/passphrase.ts
6640
6721
  var RECOMMENDED_EXPORT_PASSPHRASE_LENGTH = 12;
6641
6722
  async function readNewPassphrase(reader, guarded) {
@@ -6657,10 +6738,9 @@ async function readNewPassphrase(reader, guarded) {
6657
6738
  `);
6658
6739
  }
6659
6740
  }
6660
- function readPassphrase(reader) {
6661
- return reader.read("Passphrase of the export file (not shown): ", {
6662
- hidden: true
6663
- });
6741
+ var EXPORT_FILE = "the export file";
6742
+ function readPassphrase(reader, of) {
6743
+ return reader.read(`Passphrase of ${of} (not shown): `, { hidden: true });
6664
6744
  }
6665
6745
 
6666
6746
  // src/backup/archive/container.ts
@@ -6767,9 +6847,9 @@ function readUint32(bytes) {
6767
6847
  var ARCHIVE_KIND = "safekb-export";
6768
6848
  var ARCHIVE_VERSION = 1;
6769
6849
  var COMPRESSIONS = ["none", "gzip"];
6770
- var KEY_INFO = "safekb/export/v1";
6850
+ var KEY_INFO2 = "safekb/export/v1";
6771
6851
  var ARCHIVE_KDF = MIN_KDF_PARAMS;
6772
- var MAX_KDF = { m: 256 * 1024, t: 8, p: 4 };
6852
+ var MAX_KDF2 = { m: 256 * 1024, t: 8, p: 4 };
6773
6853
  var PART_BYTES = 1024 * 1024;
6774
6854
  var TAG_BYTES2 = 16;
6775
6855
  var NONCE_PREFIX_BYTES2 = 8;
@@ -6777,12 +6857,12 @@ var MAX_PARTS = 2 ** 32;
6777
6857
  var MAX_HEADER_BYTES = 4096;
6778
6858
  var NEWLINE = 10;
6779
6859
  var CUT_SHORT = "The export file is cut short or damaged";
6780
- function malformed(why) {
6860
+ function malformed2(why) {
6781
6861
  return new ClientError(`Not a SafeKB export: ${why}`);
6782
6862
  }
6783
6863
  async function archiveKey(passphrase, header) {
6784
6864
  const ikm = await derivePassphraseIkm(passphrase, fromBase64(header.salt), header.kdf);
6785
- return deriveKey(ikm, KEY_INFO);
6865
+ return deriveKey(ikm, KEY_INFO2);
6786
6866
  }
6787
6867
  function partNonce(prefix, index) {
6788
6868
  if (index >= MAX_PARTS)
@@ -6790,7 +6870,7 @@ function partNonce(prefix, index) {
6790
6870
  return concatBytes(prefix, uint32(index));
6791
6871
  }
6792
6872
  function partAad(headerDigest, part, isLast) {
6793
- return utf8Encode(`${KEY_INFO}|${headerDigest}|${part}|${isLast ? 1 : 0}`);
6873
+ return utf8Encode(`${KEY_INFO2}|${headerDigest}|${part}|${isLast ? 1 : 0}`);
6794
6874
  }
6795
6875
  async function* inParts(source, size) {
6796
6876
  let pieces = [];
@@ -6841,25 +6921,25 @@ function parseHeader(line) {
6841
6921
  try {
6842
6922
  value = JSON.parse(utf8Decode(line));
6843
6923
  } catch {
6844
- throw malformed("it does not start with a header");
6924
+ throw malformed2("it does not start with a header");
6845
6925
  }
6846
6926
  const raw = value;
6847
6927
  if (raw?.kind !== ARCHIVE_KIND) {
6848
- throw malformed(`its kind is not '${ARCHIVE_KIND}'`);
6928
+ throw malformed2(`its kind is not '${ARCHIVE_KIND}'`);
6849
6929
  }
6850
6930
  if (raw.v !== ARCHIVE_VERSION) {
6851
- throw malformed(`its version is not ${ARCHIVE_VERSION}; use the version of @safekb/mcp that wrote it`);
6931
+ throw malformed2(`its version is not ${ARCHIVE_VERSION}; use the version of @safekb/mcp that wrote it`);
6852
6932
  }
6853
6933
  const { kdf, salt, nonce_prefix, compression } = raw;
6854
6934
  if (typeof kdf !== "object" || kdf === null || typeof salt !== "string" || typeof nonce_prefix !== "string" || !COMPRESSIONS.includes(compression)) {
6855
- throw malformed("its header is incomplete");
6935
+ throw malformed2("its header is incomplete");
6856
6936
  }
6857
6937
  const params = assertKdfParams(kdf);
6858
- if (params.m > MAX_KDF.m || params.t > MAX_KDF.t || params.p > MAX_KDF.p) {
6859
- throw malformed("its KDF parameters are beyond what this client runs");
6938
+ if (params.m > MAX_KDF2.m || params.t > MAX_KDF2.t || params.p > MAX_KDF2.p) {
6939
+ throw malformed2("its KDF parameters are beyond what this client runs");
6860
6940
  }
6861
6941
  if (fromBase64(nonce_prefix).length !== NONCE_PREFIX_BYTES2) {
6862
- throw malformed("its header is incomplete");
6942
+ throw malformed2("its header is incomplete");
6863
6943
  }
6864
6944
  return {
6865
6945
  kind: raw.kind,
@@ -6896,7 +6976,7 @@ async function* openArchive(file, passphrase) {
6896
6976
  const reader = new ByteReader(file, CUT_SHORT);
6897
6977
  const line = await reader.readUntil(NEWLINE, MAX_HEADER_BYTES);
6898
6978
  if (line === null)
6899
- throw malformed("it does not start with a header");
6979
+ throw malformed2("it does not start with a header");
6900
6980
  const header = parseHeader(line);
6901
6981
  const key = await archiveKey(passphrase, header);
6902
6982
  const parts = openParts(reader, key, header, await sha256Hex(line));
@@ -6906,6 +6986,12 @@ async function* openArchive(file, passphrase) {
6906
6986
  // src/backup/archive/entries.ts
6907
6987
  var MAX_RECORD_BYTES = 512 * 1024 * 1024;
6908
6988
  var CUT_SHORT2 = "The export ends in the middle of a record";
6989
+ function carriesBlob(record) {
6990
+ return record.kind === "attachment" || record.kind === "raw-sealed-attachment";
6991
+ }
6992
+ function nameOf(record) {
6993
+ return record.kind === "attachment" ? `Attachment ${record.id} ('${record.file_name}')` : `Sealed attachment ${record.id}`;
6994
+ }
6909
6995
  async function* blobOf(record, blob) {
6910
6996
  let written = 0;
6911
6997
  for await (const piece of blob) {
@@ -6913,7 +6999,7 @@ async function* blobOf(record, blob) {
6913
6999
  yield piece;
6914
7000
  }
6915
7001
  if (written !== record.blob_bytes) {
6916
- throw new ClientError(`Attachment ${record.id} ('${record.file_name}') is ${written} bytes, not the ${record.blob_bytes} the server reports for it`);
7002
+ throw new ClientError(`${nameOf(record)} is ${written} bytes, not the ${record.blob_bytes} the server reports for it`);
6917
7003
  }
6918
7004
  }
6919
7005
  async function* encodeEntries(entries) {
@@ -6947,7 +7033,7 @@ async function* decodeEntries(content) {
6947
7033
  if (size > MAX_RECORD_BYTES)
6948
7034
  throw new ClientError(CUT_SHORT2);
6949
7035
  const record = parseRecord(await reader.read(size));
6950
- if (record.kind !== "attachment") {
7036
+ if (!carriesBlob(record)) {
6951
7037
  yield { record };
6952
7038
  continue;
6953
7039
  }
@@ -6966,7 +7052,8 @@ async function* decodeEntries(content) {
6966
7052
  }
6967
7053
 
6968
7054
  // src/backup/archive/records.ts
6969
- var RECORD_FORMAT = 1;
7055
+ var RECORD_FORMAT = 2;
7056
+ var READABLE_FORMATS = [1, RECORD_FORMAT];
6970
7057
  var ENTITY_TYPES = ["doc", "task", "project", "decision"];
6971
7058
 
6972
7059
  // src/backup/import/labels.ts
@@ -7180,7 +7267,7 @@ class DatabaseImporter {
7180
7267
  }
7181
7268
 
7182
7269
  // src/backup/import/idMap.ts
7183
- var OBJECT_ID = /\b[0-9a-f]{24}\b/g;
7270
+ var OBJECT_ID2 = /\b[0-9a-f]{24}\b/g;
7184
7271
  var TASK_LINK = "taskId";
7185
7272
 
7186
7273
  class IdMap {
@@ -7195,13 +7282,13 @@ class IdMap {
7195
7282
  return exported === null ? null : this.of(kind).get(exported) ?? null;
7196
7283
  }
7197
7284
  rewrite(text) {
7198
- return text.replace(OBJECT_ID, (id) => this.all.get(id) ?? id);
7285
+ return text.replace(OBJECT_ID2, (id) => this.all.get(id) ?? id);
7199
7286
  }
7200
7287
  mentionsUnknown(text) {
7201
- return (text.match(OBJECT_ID) ?? []).some((id) => !this.all.has(id));
7288
+ return (text.match(OBJECT_ID2) ?? []).some((id) => !this.all.has(id));
7202
7289
  }
7203
7290
  static mentionsId(text) {
7204
- return text.search(OBJECT_ID) >= 0;
7291
+ return text.search(OBJECT_ID2) >= 0;
7205
7292
  }
7206
7293
  rewriteJson(value) {
7207
7294
  if (typeof value === "string")
@@ -7395,11 +7482,428 @@ class NodeImporter {
7395
7482
  }
7396
7483
  }
7397
7484
 
7485
+ // src/sealed/attachmentService.ts
7486
+ import { createReadStream } from "node:fs";
7487
+ import { open as open3, stat as stat2, unlink } from "node:fs/promises";
7488
+ import { basename as basename2 } from "node:path";
7489
+
7490
+ // src/sealed/nodeAccess.ts
7491
+ function assertSeq(node, expectedSeq) {
7492
+ if (node.seal_seq !== expectedSeq) {
7493
+ throw new ClientError(`${node.id} changed: its seal_seq is ${node.seal_seq}, not ${expectedSeq}. Read it again before writing.`);
7494
+ }
7495
+ }
7496
+ function assertAccepted(node) {
7497
+ if (node.deposited) {
7498
+ throw new ClientError(`${node.id} is a pending deposit; accept_sealed_deposit or reject_sealed_deposit it first`);
7499
+ }
7500
+ }
7501
+ function writeResult(node) {
7502
+ return {
7503
+ id: node.id,
7504
+ seal_root_id: node.seal_root_id,
7505
+ seal_seq: node.seal_seq
7506
+ };
7507
+ }
7508
+
7509
+ class NodeAccess {
7510
+ ctx;
7511
+ constructor(ctx) {
7512
+ this.ctx = ctx;
7513
+ }
7514
+ nodeContext(nodeId) {
7515
+ return { tenant: this.ctx.tenant, node: nodeId };
7516
+ }
7517
+ async dekOf(node) {
7518
+ const { keys } = this.ctx.session.require(node.seal_root_id);
7519
+ if (node.enc_dek !== null) {
7520
+ return unwrapDek(keys, node.id, parseEnvelope(node.enc_dek));
7521
+ }
7522
+ if (node.box_dek !== null) {
7523
+ return openBoxDek(keys, node.id, parseBoxDek(node.box_dek));
7524
+ }
7525
+ throw new ClientError(`Sealed node ${node.id} has no wrapped key`);
7526
+ }
7527
+ async open(nodeId) {
7528
+ return this.openNode(await this.ctx.api.getNode(nodeId));
7529
+ }
7530
+ async openNode(node) {
7531
+ const root = this.ctx.session.require(node.seal_root_id);
7532
+ const dek = await this.dekOf(node);
7533
+ const meta = await decryptMeta(dek, this.nodeContext(node.id), parseEnvelope(node.enc_meta));
7534
+ return { node, root, dek, meta };
7535
+ }
7536
+ async sign(message, signerRootIds) {
7537
+ const signers = signerRootIds.map((rootId) => this.ctx.session.require(rootId).keys);
7538
+ return signMessage(await buildMessage(message), signers);
7539
+ }
7540
+ async signPlain(op, node) {
7541
+ const sigs = await this.sign({
7542
+ op,
7543
+ tenant: this.ctx.tenant,
7544
+ node: node.id,
7545
+ root: node.seal_root_id,
7546
+ baseSeq: node.seal_seq
7547
+ }, [node.seal_root_id]);
7548
+ return { base_seq: node.seal_seq, sigs };
7549
+ }
7550
+ }
7551
+
7552
+ // src/sealed/attachmentService.ts
7553
+ var DOWNLOAD_FILE_MODE = 384;
7554
+ var DEFAULT_MIME_TYPE = "application/octet-stream";
7555
+ async function* fileBytes(path) {
7556
+ try {
7557
+ for await (const chunk of createReadStream(path))
7558
+ yield chunk;
7559
+ } catch (error) {
7560
+ throw fileError(error);
7561
+ }
7562
+ }
7563
+ async function* verified(plaintext, meta) {
7564
+ const hash = await createStreamingSha256();
7565
+ let size = 0;
7566
+ for await (const chunk of plaintext) {
7567
+ hash.update(chunk);
7568
+ size += chunk.length;
7569
+ yield chunk;
7570
+ }
7571
+ if (size !== meta.size || hash.digestHex() !== meta.sha256_plain) {
7572
+ throw new ClientError("Downloaded attachment does not match its sealed size and hash");
7573
+ }
7574
+ }
7575
+
7576
+ class AttachmentService {
7577
+ ctx;
7578
+ access;
7579
+ constructor(ctx, access) {
7580
+ this.ctx = ctx;
7581
+ this.access = access;
7582
+ }
7583
+ async list(documentId) {
7584
+ const opened = await this.access.open(documentId);
7585
+ const attachments = [];
7586
+ for (const record of await this.ctx.api.listAttachments(documentId)) {
7587
+ const meta = await this.metaOf(opened, record);
7588
+ attachments.push({
7589
+ id: record.id,
7590
+ filename: meta.filename,
7591
+ mime: meta.mime,
7592
+ size: meta.size
7593
+ });
7594
+ }
7595
+ return {
7596
+ document_id: documentId,
7597
+ seal_seq: opened.node.seal_seq,
7598
+ attachments
7599
+ };
7600
+ }
7601
+ async upload(args) {
7602
+ const opened = await this.access.open(args.document_id);
7603
+ assertSeq(opened.node, args.expected_seq);
7604
+ assertAccepted(opened.node);
7605
+ const info = await stat2(args.file_path).catch((error) => {
7606
+ throw fileError(error);
7607
+ });
7608
+ if (!info.isFile()) {
7609
+ throw new ClientError(`${args.file_path} is not a regular file; only regular files can be attached`);
7610
+ }
7611
+ await this.assertWithinLimit(args.file_path, info.size);
7612
+ return this.attachTo(opened, {
7613
+ filename: basename2(args.file_path),
7614
+ mime: args.mime_type,
7615
+ bytes: fileBytes(args.file_path)
7616
+ });
7617
+ }
7618
+ async attach(args) {
7619
+ const opened = await this.access.open(args.document_id);
7620
+ assertSeq(opened.node, args.expected_seq);
7621
+ assertAccepted(opened.node);
7622
+ await this.assertWithinLimit(`'${args.file.filename}'`, args.size);
7623
+ return this.attachTo(opened, args.file);
7624
+ }
7625
+ async attachTo(opened, plain) {
7626
+ const { node, dek } = opened;
7627
+ const ctx = this.attachmentContext(node.id, newObjectId());
7628
+ const file = await this.encrypt(dek, ctx, plain);
7629
+ const attEncMeta = serializeEnvelope(await encryptAttachmentMeta(dek, ctx, file.meta));
7630
+ const sigs = await this.access.sign({
7631
+ op: "attach",
7632
+ tenant: this.ctx.tenant,
7633
+ node: node.id,
7634
+ root: node.seal_root_id,
7635
+ baseSeq: node.seal_seq,
7636
+ attId: ctx.attId,
7637
+ blobCtSha256: file.blobSha256,
7638
+ attEncMeta
7639
+ }, [node.seal_root_id]);
7640
+ const stored = await this.ctx.api.attach(node.id, {
7641
+ base_seq: node.seal_seq,
7642
+ att_id: ctx.attId,
7643
+ att_enc_meta: attEncMeta,
7644
+ blob_sha256: file.blobSha256,
7645
+ ciphertext: file.ciphertext,
7646
+ sigs
7647
+ });
7648
+ return {
7649
+ attachment_id: stored.id,
7650
+ document_id: node.id,
7651
+ seal_seq: stored.seal_seq,
7652
+ size: file.meta.size
7653
+ };
7654
+ }
7655
+ async files(documentId) {
7656
+ const opened = await this.access.open(documentId);
7657
+ const files = [];
7658
+ for (const record of await this.ctx.api.listAttachments(documentId)) {
7659
+ const meta = await this.metaOf(opened, record);
7660
+ files.push({
7661
+ id: record.id,
7662
+ meta,
7663
+ bytes: () => this.plaintextOf(opened, record.id, meta)
7664
+ });
7665
+ }
7666
+ return files;
7667
+ }
7668
+ async* plaintextOf(opened, attachmentId, meta) {
7669
+ const ciphertext = await this.ctx.api.downloadAttachment(opened.node.id, attachmentId);
7670
+ yield* verified(decryptAttachment(opened.dek, this.attachmentContext(opened.node.id, attachmentId), ciphertext), meta);
7671
+ }
7672
+ async download(args) {
7673
+ const opened = await this.access.open(args.document_id);
7674
+ const record = (await this.ctx.api.listAttachments(args.document_id)).find((candidate) => candidate.id === args.attachment_id);
7675
+ if (record === undefined) {
7676
+ throw new ClientError(`${args.document_id} has no attachment ${args.attachment_id}`);
7677
+ }
7678
+ const meta = await this.metaOf(opened, record);
7679
+ await this.write(args.out_path, this.plaintextOf(opened, args.attachment_id, meta));
7680
+ return {
7681
+ attachment_id: args.attachment_id,
7682
+ out_path: args.out_path,
7683
+ filename: meta.filename,
7684
+ size: meta.size
7685
+ };
7686
+ }
7687
+ async delete(args) {
7688
+ const node = await this.ctx.api.getNode(args.document_id);
7689
+ assertSeq(node, args.expected_seq);
7690
+ const sigs = await this.access.sign({
7691
+ op: "detach",
7692
+ tenant: this.ctx.tenant,
7693
+ node: node.id,
7694
+ root: node.seal_root_id,
7695
+ baseSeq: node.seal_seq,
7696
+ attId: args.attachment_id
7697
+ }, [node.seal_root_id]);
7698
+ const changed = await this.ctx.api.detach(node.id, {
7699
+ base_seq: node.seal_seq,
7700
+ att_id: args.attachment_id,
7701
+ sigs
7702
+ });
7703
+ return { document_id: node.id, seal_seq: changed.seal_seq, deleted: true };
7704
+ }
7705
+ attachmentContext(nodeId, attId) {
7706
+ return { ...this.access.nodeContext(nodeId), attId };
7707
+ }
7708
+ metaOf(opened, record) {
7709
+ if (!record.sealed || record.att_enc_meta === null) {
7710
+ throw new ClientError(`Attachment ${record.id} is not sealed`);
7711
+ }
7712
+ return decryptAttachmentMeta(opened.dek, this.attachmentContext(opened.node.id, record.id), parseEnvelope(record.att_enc_meta));
7713
+ }
7714
+ async encrypt(dek, ctx, file) {
7715
+ const plainHash = await createStreamingSha256();
7716
+ const cipherHash = await createStreamingSha256();
7717
+ let size = 0;
7718
+ async function* plaintext() {
7719
+ for await (const bytes of file.bytes) {
7720
+ plainHash.update(bytes);
7721
+ size += bytes.length;
7722
+ yield bytes;
7723
+ }
7724
+ }
7725
+ const parts = [];
7726
+ for await (const part of encryptAttachment(dek, ctx, plaintext())) {
7727
+ cipherHash.update(part);
7728
+ parts.push(part);
7729
+ }
7730
+ return {
7731
+ ciphertext: new Blob(parts),
7732
+ blobSha256: cipherHash.digestHex(),
7733
+ meta: {
7734
+ filename: file.filename,
7735
+ mime: file.mime,
7736
+ size,
7737
+ sha256_plain: plainHash.digestHex()
7738
+ }
7739
+ };
7740
+ }
7741
+ async assertWithinLimit(what, size) {
7742
+ const { max_file_size_bytes: limit } = await this.ctx.api.limits();
7743
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
7744
+ throw new ClientError("The server did not report a usable upload limit (max_file_size_bytes)");
7745
+ }
7746
+ const sealedSize = attachmentCiphertextSize(size);
7747
+ if (sealedSize > limit) {
7748
+ throw new ClientError(`${what} is too large to attach: it is ${size} bytes, ${sealedSize} bytes once sealed, and the server accepts at most ${limit} bytes`);
7749
+ }
7750
+ }
7751
+ async write(path, plaintext) {
7752
+ let handle;
7753
+ try {
7754
+ handle = await open3(path, "wx", DOWNLOAD_FILE_MODE);
7755
+ } catch (error) {
7756
+ if (error.code === "EEXIST") {
7757
+ throw new ClientError(`${path} already exists; choose a new out_path`);
7758
+ }
7759
+ throw fileError(error);
7760
+ }
7761
+ let complete = false;
7762
+ try {
7763
+ for await (const chunk of plaintext)
7764
+ await handle.write(chunk);
7765
+ complete = true;
7766
+ } finally {
7767
+ await handle.close();
7768
+ if (!complete)
7769
+ await unlink(path);
7770
+ }
7771
+ }
7772
+ }
7773
+
7774
+ // src/backup/import/rawSealed.ts
7775
+ var LEFT_OUT = "sealed item(s) left out: their seal root could not be opened (see below)";
7776
+
7777
+ class RawSealedImporter {
7778
+ ctx;
7779
+ sink;
7780
+ keys;
7781
+ opened = new Map;
7782
+ unopened = new Set;
7783
+ deks = new Map;
7784
+ constructor(ctx, sink, keys) {
7785
+ this.ctx = ctx;
7786
+ this.sink = sink;
7787
+ this.keys = keys;
7788
+ }
7789
+ async root(record) {
7790
+ const opening = await this.keys.open(this.sourceTenant(), record.root_id, record.seal);
7791
+ if ("keys" in opening) {
7792
+ this.opened.set(record.root_id, opening.keys);
7793
+ return;
7794
+ }
7795
+ this.unopened.add(record.root_id);
7796
+ this.ctx.report.unopenedRoots.push({
7797
+ id: record.root_id,
7798
+ name: record.name,
7799
+ reason: opening.reason
7800
+ });
7801
+ }
7802
+ async node(record, spaceId) {
7803
+ const keys = this.keysOf(record.seal_root_id);
7804
+ if (keys === null)
7805
+ return;
7806
+ const ctx = this.nodeContext(record.id);
7807
+ const dek = await unwrapDek(keys, record.id, parseEnvelope(record.enc_dek));
7808
+ const meta = await decryptMeta(dek, ctx, parseEnvelope(record.enc_meta));
7809
+ const placed = {
7810
+ id: record.id,
7811
+ space_id: record.space_id,
7812
+ parent_id: record.parent_id,
7813
+ title: meta.title,
7814
+ position: record.position,
7815
+ archived: record.archived,
7816
+ seal_root_id: record.seal_root_id
7817
+ };
7818
+ if (record.is_folder) {
7819
+ return this.sink.folder({ kind: "sealed-folder", ...placed }, spaceId);
7820
+ }
7821
+ const { enc_body: encBody, enc_index: encIndex } = record;
7822
+ if (encBody === null || encIndex === null) {
7823
+ throw new ClientError(`Sealed document ${record.id} has no body or index entry`);
7824
+ }
7825
+ const index = await decryptIndexEntry(dek, ctx, parseEnvelope(encIndex));
7826
+ this.deks.set(record.id, dek);
7827
+ await this.sink.document({
7828
+ kind: "sealed-document",
7829
+ ...placed,
7830
+ tags: meta.tags,
7831
+ body: await decryptBody(dek, ctx, parseEnvelope(encBody)),
7832
+ summary: index.summary,
7833
+ summary_short: meta.summary_short,
7834
+ keywords: index.keywords
7835
+ }, spaceId);
7836
+ }
7837
+ async attachment(entry) {
7838
+ const { record } = entry;
7839
+ const dek = this.deks.get(record.document_id);
7840
+ if (dek === undefined) {
7841
+ this.ctx.report.note(LEFT_OUT);
7842
+ return;
7843
+ }
7844
+ const ctx = { ...this.nodeContext(record.document_id), attId: record.id };
7845
+ const meta = await decryptAttachmentMeta(dek, ctx, parseEnvelope(record.att_enc_meta));
7846
+ await this.sink.attachment({
7847
+ record: {
7848
+ kind: "attachment",
7849
+ id: record.id,
7850
+ owner: "sealed-document",
7851
+ owner_id: record.document_id,
7852
+ file_name: meta.filename,
7853
+ mime_type: meta.mime,
7854
+ blob_bytes: meta.size
7855
+ },
7856
+ blob: verified(decryptAttachment(dek, ctx, entry.blob), meta)
7857
+ });
7858
+ }
7859
+ async credential(record, spaceId) {
7860
+ const keys = this.keysOf(record.seal_root_id);
7861
+ if (keys === null)
7862
+ return;
7863
+ const dek = await unwrapDek(keys, record.id, parseEnvelope(record.enc_dek));
7864
+ const values = await unsealCredentialFields(dek, { tenant: this.sourceTenant(), credential: record.id }, record.enc_fields);
7865
+ const {
7866
+ kind: _kind,
7867
+ enc_fields: _fields,
7868
+ enc_dek: _dek,
7869
+ ...visible
7870
+ } = record;
7871
+ await this.sink.credential({
7872
+ ...visible,
7873
+ kind: "credential",
7874
+ password: values.password,
7875
+ totp: values.totp,
7876
+ sealed_notes: values.sealed_notes,
7877
+ custom: [
7878
+ ...record.custom.map((field) => ({ ...field, secret: false })),
7879
+ ...values.custom.map((field) => ({ ...field, secret: true }))
7880
+ ]
7881
+ }, spaceId);
7882
+ }
7883
+ keysOf(rootId) {
7884
+ const keys = this.opened.get(rootId);
7885
+ if (keys !== undefined)
7886
+ return keys;
7887
+ if (!this.unopened.has(rootId)) {
7888
+ throw new ClientError(`The export holds sealed items of seal root ${rootId} without the root's seal`);
7889
+ }
7890
+ this.ctx.report.note(LEFT_OUT);
7891
+ return null;
7892
+ }
7893
+ nodeContext(nodeId) {
7894
+ return { tenant: this.sourceTenant(), node: nodeId };
7895
+ }
7896
+ sourceTenant() {
7897
+ return this.ctx.sourceTenantId;
7898
+ }
7899
+ }
7900
+
7398
7901
  // src/backup/import/report.ts
7399
7902
  class ImportReport {
7400
7903
  created = {};
7401
7904
  notes = new Map;
7402
7905
  sealRoots = [];
7906
+ unopenedRoots = [];
7403
7907
  count(kind, by = 1) {
7404
7908
  this.created[kind] = (this.created[kind] ?? 0) + by;
7405
7909
  }
@@ -7422,7 +7926,7 @@ function passphraseNote(seal2) {
7422
7926
  // src/backup/import/sealed.ts
7423
7927
  var POSITION_DECIMALS = 6;
7424
7928
  var IMPORTED = "imported";
7425
- var OBJECT_ID2 = /^[0-9a-f]{24}$/;
7929
+ var OBJECT_ID3 = /^[0-9a-f]{24}$/;
7426
7930
 
7427
7931
  class SealedImporter {
7428
7932
  ctx;
@@ -7536,7 +8040,7 @@ class SealedImporter {
7536
8040
  }
7537
8041
  async rootName(title) {
7538
8042
  const taken = new Set((await this.sealed.names.all()).values());
7539
- const usable = (name) => !OBJECT_ID2.test(name) && !taken.has(name);
8043
+ const usable = (name) => !OBJECT_ID3.test(name) && !taken.has(name);
7540
8044
  if (usable(title))
7541
8045
  return title;
7542
8046
  for (let copy = 1;; copy++) {
@@ -7739,6 +8243,7 @@ class Importer {
7739
8243
  ctx;
7740
8244
  nodes;
7741
8245
  sealed;
8246
+ raw;
7742
8247
  databases;
7743
8248
  work;
7744
8249
  progress;
@@ -7756,6 +8261,7 @@ class Importer {
7756
8261
  };
7757
8262
  this.nodes = new NodeImporter(this.ctx);
7758
8263
  this.sealed = new SealedImporter(this.ctx, targets.sealed, targets.services);
8264
+ this.raw = new RawSealedImporter(this.ctx, this.sealed, targets.rawRootKeys);
7759
8265
  this.databases = new DatabaseImporter(this.ctx);
7760
8266
  this.work = new WorkImporter(this.ctx);
7761
8267
  this.progress = progress;
@@ -7817,11 +8323,14 @@ ${WHAT_STAYS}`, this.ctx.report);
7817
8323
  const { record } = entry;
7818
8324
  switch (record.kind) {
7819
8325
  case "manifest":
7820
- if (record.format !== RECORD_FORMAT) {
7821
- throw new ClientError(`The export has record format ${record.format}, this client reads ${RECORD_FORMAT}; use the version of @safekb/mcp that wrote it`);
8326
+ if (!READABLE_FORMATS.includes(record.format)) {
8327
+ throw new ClientError(`The export has record format ${record.format}, this client reads ${READABLE_FORMATS.join(" and ")}; use the version of @safekb/mcp that wrote it`);
7822
8328
  }
7823
8329
  this.ctx.sourceTenantId = record.source.tenant_id;
7824
8330
  return;
8331
+ case "raw-seal-root":
8332
+ this.announce("Seal roots the export holds as stored");
8333
+ return this.raw.root(record);
7825
8334
  case "space":
7826
8335
  this.announce("Spaces");
7827
8336
  return this.nodes.space(record);
@@ -7842,6 +8351,9 @@ ${WHAT_STAYS}`, this.ctx.report);
7842
8351
  case "sealed-document":
7843
8352
  this.announce("Documents and folders");
7844
8353
  return this.sealed.document(record, this.nodes.spaceOf(record.space_id));
8354
+ case "raw-sealed-node":
8355
+ this.announce("Documents and folders");
8356
+ return this.raw.node(record, this.nodes.spaceOf(record.space_id));
7845
8357
  case "database":
7846
8358
  this.announce("Documents and folders");
7847
8359
  return this.databases.database(record, this.nodes.spaceOf(record.space_id));
@@ -7849,11 +8361,16 @@ ${WHAT_STAYS}`, this.ctx.report);
7849
8361
  const file = entry;
7850
8362
  return record.owner === "sealed-document" ? this.sealed.attachment(file) : this.nodes.attachment(file);
7851
8363
  }
8364
+ case "raw-sealed-attachment":
8365
+ return this.raw.attachment(entry);
7852
8366
  case "credential": {
7853
8367
  this.announce("Credentials");
7854
8368
  const spaceId = this.nodes.spaceOf(record.space_id);
7855
8369
  return record.seal_root_id === null ? this.work.credential(record, spaceId) : this.sealed.credential(record, spaceId);
7856
8370
  }
8371
+ case "raw-sealed-credential":
8372
+ this.announce("Credentials");
8373
+ return this.raw.credential(record, this.nodes.spaceOf(record.space_id));
7857
8374
  case "project":
7858
8375
  this.announce("Projects, tasks and decisions");
7859
8376
  return this.work.project(record);
@@ -7908,6 +8425,13 @@ var LABELS = [
7908
8425
  ],
7909
8426
  ["sealed-folder", "sealed folder(s)"],
7910
8427
  ["sealed-document", "sealed document(s)"],
8428
+ ["raw-seal-root", "seal root(s) this machine cannot open, written as stored"],
8429
+ [
8430
+ "raw-sealed-node",
8431
+ "sealed folder(s) and document(s) of those, still sealed"
8432
+ ],
8433
+ ["raw-sealed-attachment", "attachment(s) of those, still sealed"],
8434
+ ["raw-sealed-credential", "sealed credential(s) of those, still sealed"],
7911
8435
  ["database", "database(s)"],
7912
8436
  ["rows", "database row(s)"],
7913
8437
  ["view", "database view(s)"],
@@ -7929,7 +8453,7 @@ function alreadyThere(path) {
7929
8453
  }
7930
8454
  async function* bytesOf(path) {
7931
8455
  try {
7932
- for await (const chunk of createReadStream(path))
8456
+ for await (const chunk of createReadStream2(path))
7933
8457
  yield chunk;
7934
8458
  } catch (error) {
7935
8459
  throw fileError(error);
@@ -7950,7 +8474,7 @@ class BackupConsole {
7950
8474
  throw alreadyThere(path);
7951
8475
  const prepared = await exporter.prepare();
7952
8476
  const passphrase = await readNewPassphrase(this.reader, "everything in the file");
7953
- const file = await open3(path, "wx", EXPORT_FILE_MODE).catch((error) => {
8477
+ const file = await open4(path, "wx", EXPORT_FILE_MODE).catch((error) => {
7954
8478
  throw error.code === "EEXIST" ? alreadyThere(path) : fileError(error);
7955
8479
  });
7956
8480
  let complete = false;
@@ -7979,11 +8503,27 @@ ${countLines(counts)}`);
7979
8503
  The file opens only with the passphrase you typed: keep both, and keep them apart.
7980
8504
  `);
7981
8505
  }
8506
+ async keysFrom(paths) {
8507
+ const keys = [];
8508
+ for (const path of paths) {
8509
+ const text = await readFile(path, "utf8").catch((error) => {
8510
+ throw fileError(error);
8511
+ });
8512
+ const passphrase = await readPassphrase(this.reader, `key export ${path}`);
8513
+ const bundle = await openKeyBundle(text, passphrase).catch((error) => {
8514
+ if (!(error instanceof ClientError))
8515
+ throw error;
8516
+ throw new ClientError(`${path}: ${error.message}`);
8517
+ });
8518
+ keys.push(...bundle.keys);
8519
+ }
8520
+ return keys;
8521
+ }
7982
8522
  async import(importer, path) {
7983
- await stat2(path).catch((error) => {
8523
+ await stat3(path).catch((error) => {
7984
8524
  throw fileError(error);
7985
8525
  });
7986
- const passphrase = await readPassphrase(this.reader);
8526
+ const passphrase = await readPassphrase(this.reader, EXPORT_FILE);
7987
8527
  const entries = decodeEntries(openArchive(bytesOf(path), passphrase));
7988
8528
  let report;
7989
8529
  try {
@@ -8001,6 +8541,7 @@ The file opens only with the passphrase you typed: keep both, and keep them apar
8001
8541
  this.reader.write(`${heading}:
8002
8542
  ${countLines(report.created)}`);
8003
8543
  this.writeNotes("Notes", [...report.notes].map(([reason, times]) => `${times} ${reason}`));
8544
+ this.writeNotes("Seal roots the export holds as stored that had no key here, so everything in them was left out (to restore them, import again with --keys <their key export>; that creates everything else again too)", report.unopenedRoots.map(({ id, name, reason }) => `${name === null ? id : `"${name}" (${id})`}: ${reason}`));
8004
8545
  if (report.sealRoots.length === 0)
8005
8546
  return;
8006
8547
  this.reader.write(`New seal roots, each with a newly generated key. Back the keys up (safekb-mcp --export-keys): a lost key cannot be recovered.
@@ -8083,68 +8624,6 @@ class CredentialAccess {
8083
8624
  }
8084
8625
  }
8085
8626
 
8086
- // src/sealed/nodeAccess.ts
8087
- function assertSeq(node, expectedSeq) {
8088
- if (node.seal_seq !== expectedSeq) {
8089
- throw new ClientError(`${node.id} changed: its seal_seq is ${node.seal_seq}, not ${expectedSeq}. Read it again before writing.`);
8090
- }
8091
- }
8092
- function assertAccepted(node) {
8093
- if (node.deposited) {
8094
- throw new ClientError(`${node.id} is a pending deposit; accept_sealed_deposit or reject_sealed_deposit it first`);
8095
- }
8096
- }
8097
- function writeResult(node) {
8098
- return {
8099
- id: node.id,
8100
- seal_root_id: node.seal_root_id,
8101
- seal_seq: node.seal_seq
8102
- };
8103
- }
8104
-
8105
- class NodeAccess {
8106
- ctx;
8107
- constructor(ctx) {
8108
- this.ctx = ctx;
8109
- }
8110
- nodeContext(nodeId) {
8111
- return { tenant: this.ctx.tenant, node: nodeId };
8112
- }
8113
- async dekOf(node) {
8114
- const { keys } = this.ctx.session.require(node.seal_root_id);
8115
- if (node.enc_dek !== null) {
8116
- return unwrapDek(keys, node.id, parseEnvelope(node.enc_dek));
8117
- }
8118
- if (node.box_dek !== null) {
8119
- return openBoxDek(keys, node.id, parseBoxDek(node.box_dek));
8120
- }
8121
- throw new ClientError(`Sealed node ${node.id} has no wrapped key`);
8122
- }
8123
- async open(nodeId) {
8124
- return this.openNode(await this.ctx.api.getNode(nodeId));
8125
- }
8126
- async openNode(node) {
8127
- const root = this.ctx.session.require(node.seal_root_id);
8128
- const dek = await this.dekOf(node);
8129
- const meta = await decryptMeta(dek, this.nodeContext(node.id), parseEnvelope(node.enc_meta));
8130
- return { node, root, dek, meta };
8131
- }
8132
- async sign(message, signerRootIds) {
8133
- const signers = signerRootIds.map((rootId) => this.ctx.session.require(rootId).keys);
8134
- return signMessage(await buildMessage(message), signers);
8135
- }
8136
- async signPlain(op, node) {
8137
- const sigs = await this.sign({
8138
- op,
8139
- tenant: this.ctx.tenant,
8140
- node: node.id,
8141
- root: node.seal_root_id,
8142
- baseSeq: node.seal_seq
8143
- }, [node.seal_root_id]);
8144
- return { base_seq: node.seal_seq, sigs };
8145
- }
8146
- }
8147
-
8148
8627
  // src/backup/export/credentials.ts
8149
8628
  var CUSTOM_FIELD = "custom:";
8150
8629
 
@@ -8152,18 +8631,27 @@ class CredentialExporter {
8152
8631
  kb;
8153
8632
  sealed;
8154
8633
  roots;
8634
+ raw;
8155
8635
  exported;
8156
- constructor(kb, sealed, roots, exported) {
8636
+ constructor(kb, sealed, roots, raw, exported) {
8157
8637
  this.kb = kb;
8158
8638
  this.sealed = sealed;
8159
8639
  this.roots = roots;
8640
+ this.raw = raw;
8160
8641
  this.exported = exported;
8161
8642
  }
8162
8643
  async* entries(credentials, sealRoots) {
8163
8644
  for (const credential2 of credentials) {
8164
8645
  const sealRootId = sealRoots.get(credential2.id) ?? null;
8165
- if (sealRootId !== null && this.roots.isLocked(sealRootId))
8646
+ const treatment = sealRootId === null ? "open" : this.roots.treatmentOf(sealRootId);
8647
+ if (treatment === "left-out")
8166
8648
  continue;
8649
+ if (treatment === "raw") {
8650
+ yield {
8651
+ record: await this.raw.credential(credential2, sealRootId)
8652
+ };
8653
+ continue;
8654
+ }
8167
8655
  const secrets = sealRootId === null ? await this.revealed(credential2) : await this.unsealed(credential2.id);
8168
8656
  this.exported.count("credential");
8169
8657
  yield { record: this.record(credential2, sealRootId, secrets) };
@@ -8420,9 +8908,11 @@ class NodeExporter {
8420
8908
  }
8421
8909
  async* entries(planned) {
8422
8910
  if (planned.sealRootId !== null) {
8423
- if (!this.from.roots.isLocked(planned.sealRootId)) {
8911
+ const treatment = this.from.roots.treatmentOf(planned.sealRootId);
8912
+ if (treatment === "open")
8424
8913
  yield* this.sealed(planned);
8425
- }
8914
+ if (treatment === "raw")
8915
+ yield* this.from.raw.node(planned);
8426
8916
  return;
8427
8917
  }
8428
8918
  const document = await this.from.kb.document(planned.id);
@@ -8523,8 +9013,8 @@ class NodeExporter {
8523
9013
  }
8524
9014
  }
8525
9015
  async* sealed(planned) {
8526
- const open4 = await this.from.access.openNode(toSealedNode(await this.from.sealed.api.getDocument(planned.id)));
8527
- const { node, meta } = open4;
9016
+ const open5 = await this.from.access.openNode(toSealedNode(await this.from.sealed.api.getDocument(planned.id)));
9017
+ const { node, meta } = open5;
8528
9018
  const placed = {
8529
9019
  id: node.id,
8530
9020
  space_id: node.space_id,
@@ -8544,7 +9034,7 @@ class NodeExporter {
8544
9034
  kind: "sealed-document",
8545
9035
  ...placed,
8546
9036
  tags: meta.tags,
8547
- body: (await this.from.services.documents.storedBody(open4)).body,
9037
+ body: (await this.from.services.documents.storedBody(open5)).body,
8548
9038
  summary: indexed?.summary ?? "",
8549
9039
  summary_short: meta.summary_short,
8550
9040
  keywords: indexed?.keywords ?? []
@@ -8659,6 +9149,202 @@ class ExportPlanner {
8659
9149
  }
8660
9150
  }
8661
9151
 
9152
+ // src/backup/export/rawSealed.ts
9153
+ function changedWhileRead(id) {
9154
+ return new ClientError(`${id} changed while it was exported; try again`);
9155
+ }
9156
+
9157
+ class RawSealedExporter {
9158
+ ctx;
9159
+ exported;
9160
+ constructor(ctx, exported) {
9161
+ this.ctx = ctx;
9162
+ this.exported = exported;
9163
+ }
9164
+ async root(rootId) {
9165
+ const root = toSealedNode(await this.ctx.api.getDocument(rootId));
9166
+ if (root.seal === null) {
9167
+ throw new ClientError(`${rootId} is not a seal root`);
9168
+ }
9169
+ this.exported.count("raw-seal-root");
9170
+ return {
9171
+ kind: "raw-seal-root",
9172
+ root_id: rootId,
9173
+ seal: root.seal,
9174
+ name: await this.ctx.names.nameOf(rootId)
9175
+ };
9176
+ }
9177
+ async* node(planned) {
9178
+ const node = toSealedNode(await this.ctx.api.getDocument(planned.id));
9179
+ if (node.deposited) {
9180
+ this.exported.leftOut.push(`pending deposit ${node.id} of a seal root that could not be opened`);
9181
+ return;
9182
+ }
9183
+ if (node.enc_dek === null) {
9184
+ throw new ClientError(`Sealed node ${node.id} has no wrapped key`);
9185
+ }
9186
+ const record = {
9187
+ kind: "raw-sealed-node",
9188
+ id: node.id,
9189
+ space_id: node.space_id,
9190
+ parent_id: planned.parentId,
9191
+ position: node.position,
9192
+ archived: node.archived,
9193
+ is_folder: node.is_folder,
9194
+ seal_root_id: node.seal_root_id,
9195
+ enc_dek: node.enc_dek,
9196
+ enc_meta: node.enc_meta,
9197
+ ...node.is_folder ? { enc_index: null, enc_body: null } : await this.content(node)
9198
+ };
9199
+ this.exported.count("raw-sealed-node");
9200
+ yield { record };
9201
+ if (!node.is_folder)
9202
+ yield* this.attachments(node.id);
9203
+ }
9204
+ async credential(credential2, sealRootId) {
9205
+ const stored = await this.ctx.api.fetchSealedCredential(credential2.id);
9206
+ if (stored.seal_root_id !== sealRootId)
9207
+ throw changedWhileRead(stored.id);
9208
+ this.exported.count("raw-sealed-credential");
9209
+ const visible = visibleOf(stored);
9210
+ return {
9211
+ kind: "raw-sealed-credential",
9212
+ id: credential2.id,
9213
+ space_id: credential2.space_id,
9214
+ folder_id: credential2.folder_id,
9215
+ seal_root_id: sealRootId,
9216
+ title: visible.title,
9217
+ username: visible.username,
9218
+ url: visible.url,
9219
+ tags: visible.tags,
9220
+ icon: credential2.icon,
9221
+ color: credential2.color,
9222
+ favorite: credential2.favorite,
9223
+ expires_at: credential2.expires_at,
9224
+ external_refs: credential2.external_refs,
9225
+ archived: credential2.archived,
9226
+ notes: visible.notes,
9227
+ custom: visible.custom,
9228
+ enc_fields: stored.enc_fields,
9229
+ enc_dek: stored.enc_dek
9230
+ };
9231
+ }
9232
+ async content(node) {
9233
+ const body = await this.ctx.api.getBody(node.id);
9234
+ const [index] = await this.ctx.api.indexEntries(node.seal_root_id, [
9235
+ node.id
9236
+ ]);
9237
+ if (body.seal_seq !== node.seal_seq || index === undefined || index.seal_seq !== node.seal_seq) {
9238
+ throw changedWhileRead(node.id);
9239
+ }
9240
+ return { enc_index: index.enc_index, enc_body: body.body };
9241
+ }
9242
+ async* attachments(documentId) {
9243
+ for (const stored of await this.ctx.api.listAttachments(documentId)) {
9244
+ if (!stored.sealed || stored.att_enc_meta === null) {
9245
+ throw new ClientError(`Attachment ${stored.id} is not sealed`);
9246
+ }
9247
+ this.exported.count("raw-sealed-attachment");
9248
+ yield {
9249
+ record: {
9250
+ kind: "raw-sealed-attachment",
9251
+ id: stored.id,
9252
+ document_id: documentId,
9253
+ att_enc_meta: stored.att_enc_meta,
9254
+ blob_bytes: stored.size_bytes
9255
+ },
9256
+ blob: await this.ctx.api.downloadAttachment(documentId, stored.id)
9257
+ };
9258
+ }
9259
+ }
9260
+ }
9261
+
9262
+ // src/stores/secretStores.ts
9263
+ var HOW_TO_ENABLE = {
9264
+ system: "The system keyring is not set up for this client; run: safekb-mcp --configure",
9265
+ file: "No key file directory is set up; start the client with --keyring <dir>, or run: safekb-mcp --configure",
9266
+ sidecar: "The sidecar is not available on this platform"
9267
+ };
9268
+
9269
+ class SecretLookupError extends ClientError {
9270
+ kind;
9271
+ problem;
9272
+ constructor(kind, problem, toolAdvice) {
9273
+ super(`${problem}${toolAdvice}`);
9274
+ this.kind = kind;
9275
+ this.problem = problem;
9276
+ }
9277
+ }
9278
+
9279
+ class SecretStores {
9280
+ set;
9281
+ constructor(set) {
9282
+ this.set = set;
9283
+ }
9284
+ async holds(rootId, kid) {
9285
+ for (const store of this.enabled()) {
9286
+ if (await store.holds(rootId, kid))
9287
+ return true;
9288
+ }
9289
+ return false;
9290
+ }
9291
+ async find(seal2, only) {
9292
+ const stores = only === undefined ? this.enabled() : [this.require(only)];
9293
+ if (stores.length === 0) {
9294
+ throw new SecretLookupError("no-store", "No secret given and no secret store is set up", ": pass secret_file, run safekb-mcp --configure, or start the client with --keyring <dir>");
9295
+ }
9296
+ const found = [];
9297
+ const notes = [];
9298
+ for (const store of stores) {
9299
+ const { secrets, note } = await store.lookup(seal2);
9300
+ if (secrets.length > 0)
9301
+ found.push({ store: store.name, secrets });
9302
+ else
9303
+ notes.push(note);
9304
+ }
9305
+ const [first, ...others] = found;
9306
+ if (first === undefined) {
9307
+ throw new SecretLookupError("not-found", `No secret for seal root ${seal2.rootId} was found: ${[...new Set(notes)].join("; ")}`, ". Pass secret_file, or enter the secret at the sidecar (safekb-mcp --sidecar).");
9308
+ }
9309
+ if (others.length > 0) {
9310
+ throw new SecretLookupError("ambiguous", `Seal root ${seal2.rootId} has a secret in more than one store (${found.map(({ store }) => store).join(", ")})`, "; pass store to say which one to use");
9311
+ }
9312
+ return first;
9313
+ }
9314
+ keyStore(name) {
9315
+ if (name !== "default")
9316
+ return this.require(name);
9317
+ const { defaultKeyStore } = this.set;
9318
+ if (defaultKeyStore === null) {
9319
+ throw new SecretLookupError("no-default", "No default store is set up for new keys", "; run: safekb-mcp --configure. Or pass store (system, file, sidecar), key_out_path or passphrase.");
9320
+ }
9321
+ return this.require(defaultKeyStore);
9322
+ }
9323
+ noticeFor(store) {
9324
+ return store === "file" ? this.set.keyFilesNotice ?? null : null;
9325
+ }
9326
+ keyStores() {
9327
+ const { system, file } = this.set;
9328
+ return [system, file].filter((store) => store !== null);
9329
+ }
9330
+ sidecar() {
9331
+ return this.require("sidecar");
9332
+ }
9333
+ enabled() {
9334
+ const { system, file, sidecar } = this.set;
9335
+ return [system, file, sidecar].filter((store) => store !== null);
9336
+ }
9337
+ require(name) {
9338
+ const store = this.set[name];
9339
+ if (store === null)
9340
+ throw new ClientError(HOW_TO_ENABLE[name]);
9341
+ return store;
9342
+ }
9343
+ }
9344
+ function isKeyStoreName(name) {
9345
+ return KEY_STORE_NAMES.includes(name);
9346
+ }
9347
+
8662
9348
  // src/backup/export/sealedRoots.ts
8663
9349
  function unanswered(error) {
8664
9350
  return error instanceof NetworkError || error instanceof ApiError;
@@ -8667,35 +9353,38 @@ function unanswered(error) {
8667
9353
  class SealedRoots {
8668
9354
  ctx;
8669
9355
  services;
8670
- locked = new Set;
8671
- constructor(ctx, services) {
9356
+ skipLocked;
9357
+ locked = new Map;
9358
+ constructor(ctx, services, skipLocked) {
8672
9359
  this.ctx = ctx;
8673
9360
  this.services = services;
9361
+ this.skipLocked = skipLocked;
8674
9362
  }
8675
- async unlock(rootIds, skipLocked) {
8676
- const refused = [];
9363
+ async unlock(rootIds) {
8677
9364
  for (const rootId of rootIds) {
8678
9365
  try {
8679
9366
  await this.services.roots.unlock({ root_id: rootId });
8680
9367
  } catch (error) {
8681
9368
  if (!(error instanceof ClientError) || unanswered(error))
8682
9369
  throw error;
8683
- this.locked.add(rootId);
8684
- refused.push(` ${printable2(await this.ctx.names.describe(rootId))}: ${error.message}`);
9370
+ this.locked.set(rootId, error instanceof SecretLookupError ? error.problem : error.message);
8685
9371
  }
8686
9372
  }
8687
- if (refused.length > 0 && !skipLocked) {
8688
- throw new ClientError(`${refused.length} seal root(s) cannot be opened, so the export would be incomplete:
8689
- ${refused.join(`
8690
- `)}
8691
- Put their secrets where this machine finds them (a passphrase: safekb-mcp --sidecar, then 'add <name or id>'), or pass --skip-locked to export without them.`);
8692
- }
8693
9373
  }
8694
- isLocked(rootId) {
8695
- return this.locked.has(rootId);
9374
+ treatmentOf(rootId) {
9375
+ if (!this.locked.has(rootId))
9376
+ return "open";
9377
+ return this.skipLocked ? "left-out" : "raw";
8696
9378
  }
8697
9379
  lockedIds() {
8698
- return [...this.locked];
9380
+ return [...this.locked.keys()];
9381
+ }
9382
+ async describeLocked() {
9383
+ const described = [];
9384
+ for (const [rootId, reason] of this.locked) {
9385
+ described.push(`${printable2(await this.ctx.names.describe(rootId))}: ${reason}`);
9386
+ }
9387
+ return described;
8699
9388
  }
8700
9389
  }
8701
9390
 
@@ -8816,11 +9505,13 @@ class Exporter {
8816
9505
  progress;
8817
9506
  exported = new Exported;
8818
9507
  roots;
9508
+ raw;
8819
9509
  constructor(from, request, progress) {
8820
9510
  this.from = from;
8821
9511
  this.request = request;
8822
9512
  this.progress = progress;
8823
- this.roots = new SealedRoots(from.sealed, from.services);
9513
+ this.roots = new SealedRoots(from.sealed, from.services, request.skipLocked);
9514
+ this.raw = new RawSealedExporter(from.sealed, this.exported);
8824
9515
  }
8825
9516
  async prepare() {
8826
9517
  const { kb } = this.from;
@@ -8840,13 +9531,19 @@ class Exporter {
8840
9531
  if (node.sealRootId !== null)
8841
9532
  needed.add(node.sealRootId);
8842
9533
  }
8843
- await this.roots.unlock(needed, this.request.skipLocked);
9534
+ await this.roots.unlock(needed);
9535
+ await this.announceLocked();
8844
9536
  return { plan, credentials, sealRoots };
8845
9537
  }
8846
9538
  async* entries(prepared) {
8847
9539
  const { kb, sealed, services } = this.from;
8848
9540
  const { plan } = prepared;
8849
9541
  yield { record: this.manifest() };
9542
+ for (const rootId of this.roots.lockedIds()) {
9543
+ if (this.roots.treatmentOf(rootId) === "raw") {
9544
+ yield { record: await this.raw.root(rootId) };
9545
+ }
9546
+ }
8850
9547
  for (const space of plan.spaces) {
8851
9548
  this.exported.count("space");
8852
9549
  yield { record: spaceRecord(space) };
@@ -8857,12 +9554,13 @@ class Exporter {
8857
9554
  sealed,
8858
9555
  services,
8859
9556
  access: new NodeAccess(sealed),
8860
- roots: this.roots
9557
+ roots: this.roots,
9558
+ raw: this.raw
8861
9559
  }, this.exported);
8862
9560
  for (const node of plan.nodes)
8863
9561
  yield* nodes.entries(node);
8864
9562
  this.progress(`Credentials: ${prepared.credentials.length}`);
8865
- yield* new CredentialExporter(kb, new CredentialAccess(sealed), this.roots, this.exported).entries(prepared.credentials, prepared.sealRoots);
9563
+ yield* new CredentialExporter(kb, new CredentialAccess(sealed), this.roots, this.raw, this.exported).entries(prepared.credentials, prepared.sealRoots);
8866
9564
  this.progress("Projects, tasks and decisions");
8867
9565
  yield* new WorkExporter(kb, this.exported).entries(plan);
8868
9566
  this.progress("Canvas blocks and database rows");
@@ -8888,13 +9586,22 @@ class Exporter {
8888
9586
  selection: this.request.selection
8889
9587
  };
8890
9588
  }
9589
+ async announceLocked() {
9590
+ const locked = await this.roots.describeLocked();
9591
+ if (locked.length === 0)
9592
+ return;
9593
+ const how = this.request.skipLocked ? "left out (--skip-locked)" : "exported as stored, still sealed: an import opens them with their keys (--keys)";
9594
+ this.progress(`${locked.length} seal root(s) cannot be opened here and are ${how}: ${locked.join("; ")}`);
9595
+ }
8891
9596
  async end() {
8892
- const locked = [];
8893
- for (const rootId of this.roots.lockedIds()) {
8894
- locked.push(await this.from.sealed.names.describe(rootId));
8895
- }
8896
- if (locked.length > 0) {
8897
- this.exported.leftOut.push(`everything of ${locked.length} seal root(s) that could not be opened: ${locked.join(", ")}`);
9597
+ if (this.request.skipLocked) {
9598
+ const locked = [];
9599
+ for (const rootId of this.roots.lockedIds()) {
9600
+ locked.push(await this.from.sealed.names.describe(rootId));
9601
+ }
9602
+ if (locked.length > 0) {
9603
+ this.exported.leftOut.push(`everything of ${locked.length} seal root(s) that could not be opened: ${locked.join(", ")}`);
9604
+ }
8898
9605
  }
8899
9606
  return {
8900
9607
  kind: "end",
@@ -8936,24 +9643,85 @@ class Exporter {
8936
9643
  }
8937
9644
  }
8938
9645
  }
8939
- function spaceRecord(space) {
8940
- return {
8941
- kind: "space",
8942
- id: space.id,
8943
- name: space.name,
8944
- icon: space.icon,
8945
- color: space.color,
8946
- description: space.description,
8947
- access_mode: space.access_mode,
8948
- workflow_states: space.workflow_states,
8949
- allow_client_uploads: space.allow_client_uploads,
8950
- encryption_policy: space.encryption_policy
8951
- };
8952
- }
9646
+ function spaceRecord(space) {
9647
+ return {
9648
+ kind: "space",
9649
+ id: space.id,
9650
+ name: space.name,
9651
+ icon: space.icon,
9652
+ color: space.color,
9653
+ description: space.description,
9654
+ access_mode: space.access_mode,
9655
+ workflow_states: space.workflow_states,
9656
+ allow_client_uploads: space.allow_client_uploads,
9657
+ encryption_policy: space.encryption_policy
9658
+ };
9659
+ }
9660
+
9661
+ // src/backup/import/rawRootKeys.ts
9662
+ var NOT_KEPT = [
9663
+ "not-found",
9664
+ "no-store"
9665
+ ];
9666
+
9667
+ class RawRootKeys {
9668
+ given;
9669
+ storesOf;
9670
+ stores = new Map;
9671
+ constructor(given, storesOf) {
9672
+ this.given = given;
9673
+ this.storesOf = storesOf;
9674
+ }
9675
+ async open(tenant, rootId, sealValue) {
9676
+ const seal2 = assertSeal(sealValue);
9677
+ const ctx = { tenant, root: rootId };
9678
+ const forRoot = this.given.filter((file) => file.root_id === rootId);
9679
+ const fitting = forRoot.find((file) => file.tenant_id === tenant && file.kid === seal2.kid);
9680
+ if (fitting !== undefined) {
9681
+ return { keys: await unlockSealRoot(ctx, seal2, fitting.key) };
9682
+ }
9683
+ const kept = await this.kept(tenant, rootId, seal2);
9684
+ if ("keys" in kept || forRoot.length === 0)
9685
+ return kept;
9686
+ const why = forRoot.some((file) => file.tenant_id === tenant) ? "the key given for it is outdated: its secret was changed since" : "the key given for it belongs to another account";
9687
+ return { reason: `${why}; ${kept.reason}` };
9688
+ }
9689
+ async kept(tenant, rootId, seal2) {
9690
+ const stores = await this.storesFor(tenant);
9691
+ let secrets;
9692
+ try {
9693
+ ({ secrets } = await stores.find({ rootId, kid: seal2.kid, secretKind: seal2.secret_kind }, undefined));
9694
+ } catch (error) {
9695
+ if (error instanceof SecretLookupError && NOT_KEPT.includes(error.kind)) {
9696
+ return { reason: error.problem };
9697
+ }
9698
+ throw error;
9699
+ }
9700
+ for (const secret2 of secrets) {
9701
+ try {
9702
+ return {
9703
+ keys: await unlockSealRoot({ tenant, root: rootId }, seal2, secret2)
9704
+ };
9705
+ } catch (error) {
9706
+ if (!(error instanceof WrongSecretError))
9707
+ throw error;
9708
+ }
9709
+ }
9710
+ return { reason: "the secret this machine keeps for it does not open it" };
9711
+ }
9712
+ storesFor(tenant) {
9713
+ let stores = this.stores.get(tenant);
9714
+ if (stores === undefined) {
9715
+ stores = this.storesOf(tenant);
9716
+ this.stores.set(tenant, stores);
9717
+ }
9718
+ return stores;
9719
+ }
9720
+ }
8953
9721
 
8954
9722
  // src/claudeCode.ts
8955
9723
  import { spawn as spawn2 } from "node:child_process";
8956
- import { basename as basename2 } from "node:path";
9724
+ import { basename as basename3 } from "node:path";
8957
9725
  var SERVER_NAME = "safekb";
8958
9726
  var PACKAGE_NAME = "@safekb/mcp";
8959
9727
  var BIN_NAME = "safekb-mcp";
@@ -9026,7 +9794,7 @@ function words(registration) {
9026
9794
  return [registration.command, ...registration.args.split(/\s+/)];
9027
9795
  }
9028
9796
  function startsThisClient(registration) {
9029
- return words(registration).some((word) => word === PACKAGE_NAME || word.startsWith(`${PACKAGE_NAME}@`) || [BIN_NAME, `${BIN_NAME}.js`].includes(basename2(word)));
9797
+ return words(registration).some((word) => word === PACKAGE_NAME || word.startsWith(`${PACKAGE_NAME}@`) || [BIN_NAME, `${BIN_NAME}.js`].includes(basename3(word)));
9030
9798
  }
9031
9799
  function pinnedBeforeServeFlag(registration) {
9032
9800
  for (const word of words(registration)) {
@@ -22939,7 +23707,7 @@ class HostedProxy {
22939
23707
  }
22940
23708
 
22941
23709
  // src/keyring.ts
22942
- import { readdir, stat as stat3 } from "node:fs/promises";
23710
+ import { readdir, stat as stat4 } from "node:fs/promises";
22943
23711
  import { join as join4 } from "node:path";
22944
23712
  var KEY_FILE_SUFFIX = ".json";
22945
23713
  async function readEntry(path) {
@@ -22968,7 +23736,7 @@ class Keyring {
22968
23736
  this.dir = dir;
22969
23737
  }
22970
23738
  async load() {
22971
- const info = await stat3(this.dir).catch(() => null);
23739
+ const info = await stat4(this.dir).catch(() => null);
22972
23740
  if (info === null || !info.isDirectory()) {
22973
23741
  throw new ClientError(`Keyring ${this.dir} is not a directory`);
22974
23742
  }
@@ -23012,92 +23780,6 @@ class Keyring {
23012
23780
  }
23013
23781
  }
23014
23782
 
23015
- // src/stores/secretStores.ts
23016
- var HOW_TO_ENABLE = {
23017
- system: "The system keyring is not set up for this client; run: safekb-mcp --configure",
23018
- file: "No key file directory is set up; start the client with --keyring <dir>, or run: safekb-mcp --configure",
23019
- sidecar: "The sidecar is not available on this platform"
23020
- };
23021
-
23022
- class SecretLookupError extends ClientError {
23023
- kind;
23024
- problem;
23025
- constructor(kind, problem, toolAdvice) {
23026
- super(`${problem}${toolAdvice}`);
23027
- this.kind = kind;
23028
- this.problem = problem;
23029
- }
23030
- }
23031
-
23032
- class SecretStores {
23033
- set;
23034
- constructor(set2) {
23035
- this.set = set2;
23036
- }
23037
- async holds(rootId, kid) {
23038
- for (const store of this.enabled()) {
23039
- if (await store.holds(rootId, kid))
23040
- return true;
23041
- }
23042
- return false;
23043
- }
23044
- async find(seal2, only) {
23045
- const stores = only === undefined ? this.enabled() : [this.require(only)];
23046
- if (stores.length === 0) {
23047
- throw new SecretLookupError("no-store", "No secret given and no secret store is set up", ": pass secret_file, run safekb-mcp --configure, or start the client with --keyring <dir>");
23048
- }
23049
- const found = [];
23050
- const notes = [];
23051
- for (const store of stores) {
23052
- const { secrets, note } = await store.lookup(seal2);
23053
- if (secrets.length > 0)
23054
- found.push({ store: store.name, secrets });
23055
- else
23056
- notes.push(note);
23057
- }
23058
- const [first, ...others] = found;
23059
- if (first === undefined) {
23060
- throw new SecretLookupError("not-found", `No secret for seal root ${seal2.rootId} was found: ${[...new Set(notes)].join("; ")}`, ". Pass secret_file, or enter the secret at the sidecar (safekb-mcp --sidecar).");
23061
- }
23062
- if (others.length > 0) {
23063
- throw new SecretLookupError("ambiguous", `Seal root ${seal2.rootId} has a secret in more than one store (${found.map(({ store }) => store).join(", ")})`, "; pass store to say which one to use");
23064
- }
23065
- return first;
23066
- }
23067
- keyStore(name) {
23068
- if (name !== "default")
23069
- return this.require(name);
23070
- const { defaultKeyStore } = this.set;
23071
- if (defaultKeyStore === null) {
23072
- throw new SecretLookupError("no-default", "No default store is set up for new keys", "; run: safekb-mcp --configure. Or pass store (system, file, sidecar), key_out_path or passphrase.");
23073
- }
23074
- return this.require(defaultKeyStore);
23075
- }
23076
- noticeFor(store) {
23077
- return store === "file" ? this.set.keyFilesNotice ?? null : null;
23078
- }
23079
- keyStores() {
23080
- const { system, file: file2 } = this.set;
23081
- return [system, file2].filter((store) => store !== null);
23082
- }
23083
- sidecar() {
23084
- return this.require("sidecar");
23085
- }
23086
- enabled() {
23087
- const { system, file: file2, sidecar } = this.set;
23088
- return [system, file2, sidecar].filter((store) => store !== null);
23089
- }
23090
- require(name) {
23091
- const store = this.set[name];
23092
- if (store === null)
23093
- throw new ClientError(HOW_TO_ENABLE[name]);
23094
- return store;
23095
- }
23096
- }
23097
- function isKeyStoreName(name) {
23098
- return KEY_STORE_NAMES.includes(name);
23099
- }
23100
-
23101
23783
  // src/keys/heldKeys.ts
23102
23784
  async function heldKeys(stores) {
23103
23785
  const held = [];
@@ -23414,6 +24096,7 @@ Usage:
23414
24096
  safekb-mcp --export <file> [--space <space id>]... [--doc <document id>]... [--compress]
23415
24097
  [--skip-locked]
23416
24098
  safekb-mcp --import <file> [--into-space <space id>] [--store system|file]
24099
+ [--keys <key export>]...
23417
24100
 
23418
24101
  Without arguments this text is printed.
23419
24102
 
@@ -23463,13 +24146,15 @@ Options:
23463
24146
  --export <file> Write what this token reaches to a new file, encrypted under a passphrase you
23464
24147
  type: spaces, folders, documents, attachments, databases, credentials,
23465
24148
  projects, tasks, decisions and links. Sealed content and credential secrets
23466
- are decrypted on this machine and are in the file, so every seal root in
23467
- the selection must be openable with a secret this machine keeps or its
23468
- sidecar holds. Without --space and --doc everything is exported. Needs a
23469
- terminal.
24149
+ are decrypted on this machine and are in the file. A seal root that no
24150
+ secret this machine keeps or its sidecar holds opens is written as the
24151
+ server stores it, still sealed. Without --space and --doc everything is
24152
+ exported. Needs a terminal.
23470
24153
  --import <file> Create what such a file holds, as new things with new ids, in the account
23471
24154
  of this token: nothing that exists is changed. Each exported space becomes
23472
- a new space, each seal root a new seal root with a newly generated key.
24155
+ a new space, each seal root a new seal root with a newly generated key. A
24156
+ seal root the file holds as stored is opened with a key from --keys or
24157
+ one this machine keeps; without one, everything in it is left out.
23473
24158
  Needs a terminal.
23474
24159
  --space <space id> With --create-key: the space of the new seal root. With --export: export
23475
24160
  this space with everything in it; may be repeated.
@@ -23477,7 +24162,10 @@ Options:
23477
24162
  it; may be repeated, and combined with --space.
23478
24163
  --compress With --export: compress (gzip) before encrypting.
23479
24164
  --skip-locked With --export: leave out what belongs to a seal root that cannot be opened,
23480
- instead of stopping.
24165
+ instead of writing it as stored.
24166
+ --keys <file> With --import: a key export (--export-keys) whose keys open the seal roots
24167
+ the file holds as stored. Its passphrase is asked; its keys are used for
24168
+ this import only and are not kept. May be repeated.
23481
24169
  --into-space <space id> With --import: put everything into this one existing space instead of
23482
24170
  creating the exported spaces again.
23483
24171
  --name <new name> With --rename-key: the new name.
@@ -23561,7 +24249,13 @@ var MODIFIERS = {
23561
24249
  "compress",
23562
24250
  "skip-locked"
23563
24251
  ],
23564
- import: [...KEY_MODE_MODIFIERS, "into-space", "store"]
24252
+ import: [
24253
+ ...KEY_MODE_MODIFIERS,
24254
+ "sidecar-socket",
24255
+ "into-space",
24256
+ "store",
24257
+ "keys"
24258
+ ]
23565
24259
  };
23566
24260
  var A_SEAL_ROOT = "the name or the id of a seal root";
23567
24261
  var MODE_VALUES = {
@@ -23666,6 +24360,7 @@ ${USAGE}`);
23666
24360
  exportDocumentIds: several(values.doc),
23667
24361
  compress: values.compress === true,
23668
24362
  skipLocked: values["skip-locked"] === true,
24363
+ keyExports: several(values.keys),
23669
24364
  intoSpaceId: nonEmpty(values["into-space"]),
23670
24365
  store: keyStoreOf(values.store),
23671
24366
  confirm: values.confirm === true,
@@ -23707,6 +24402,7 @@ function parse5(argv) {
23707
24402
  compress: { type: "boolean" },
23708
24403
  "skip-locked": { type: "boolean" },
23709
24404
  "into-space": { type: "string" },
24405
+ keys: { type: "string", multiple: true },
23710
24406
  name: { type: "string" },
23711
24407
  store: { type: "string" },
23712
24408
  help: { type: "boolean" },
@@ -23716,329 +24412,104 @@ function parse5(argv) {
23716
24412
  }
23717
24413
 
23718
24414
  // src/rootNames.ts
23719
- import { existsSync as existsSync3 } from "node:fs";
23720
- import { dirname as dirname3, join as join5 } from "node:path";
23721
- var ROOT_NAMES_FILE = "roots.json";
23722
- var ROOT_NAMES_VERSION = 1;
23723
- var OBJECT_ID3 = /^[0-9a-f]{24}$/;
23724
- function rootNamesPath(tokenConfigPath) {
23725
- return join5(dirname3(tokenConfigPath), ROOT_NAMES_FILE);
23726
- }
23727
- function assertNames(value, path) {
23728
- const raw = value;
23729
- const names = raw?.names;
23730
- if (raw?.v !== ROOT_NAMES_VERSION || typeof names !== "object" || names === null || Array.isArray(names) || !Object.entries(names).every(([id, name]) => OBJECT_ID3.test(id) && typeof name === "string" && name.length > 0)) {
23731
- throw new ClientError(`Seal root names ${path} are malformed`);
23732
- }
23733
- return new Map(Object.entries(names));
23734
- }
23735
-
23736
- class RootNames {
23737
- path;
23738
- changes = Promise.resolve();
23739
- constructor(path) {
23740
- this.path = path;
23741
- }
23742
- async all() {
23743
- if (!existsSync3(this.path))
23744
- return new Map;
23745
- const text = await readPrivateFile(this.path, "Seal root names");
23746
- let parsed;
23747
- try {
23748
- parsed = JSON.parse(text);
23749
- } catch {
23750
- throw new ClientError(`Seal root names ${this.path} are not valid JSON`);
23751
- }
23752
- return assertNames(parsed, this.path);
23753
- }
23754
- async nameOf(rootId) {
23755
- return (await this.all()).get(rootId) ?? null;
23756
- }
23757
- async describe(rootId) {
23758
- const name = await this.nameOf(rootId);
23759
- return name === null ? rootId : `"${name}" (${rootId})`;
23760
- }
23761
- remember(rootId, name) {
23762
- if (name.length === 0 || OBJECT_ID3.test(name))
23763
- return this.forget(rootId);
23764
- return this.change(async () => {
23765
- const names = await this.all();
23766
- if (names.get(rootId) === name)
23767
- return;
23768
- names.set(rootId, name);
23769
- await this.save(names);
23770
- });
23771
- }
23772
- forget(rootId) {
23773
- return this.change(async () => {
23774
- const names = await this.all();
23775
- if (names.delete(rootId))
23776
- await this.save(names);
23777
- });
23778
- }
23779
- change(apply) {
23780
- const done = this.changes.then(apply);
23781
- this.changes = done.catch(() => {});
23782
- return done;
23783
- }
23784
- follow(done, change) {
23785
- return followUp(done, "its name could not be updated on this machine", change);
23786
- }
23787
- async assertUsable(name, rootId) {
23788
- if (OBJECT_ID3.test(name)) {
23789
- throw new ClientError(`"${name}" has the shape of an id, so it cannot be the name of a seal root`);
23790
- }
23791
- for (const [id, taken] of await this.all()) {
23792
- if (taken === name && id !== rootId) {
23793
- throw new ClientError(`Seal root ${id} is already named "${name}" on this machine; choose another name`);
23794
- }
23795
- }
23796
- }
23797
- async isName(reference) {
23798
- return [...(await this.all()).values()].includes(reference);
23799
- }
23800
- async resolve(reference) {
23801
- if (OBJECT_ID3.test(reference))
23802
- return reference;
23803
- const ids = [...await this.all()].filter(([, name]) => name === reference).map(([id]) => id);
23804
- const [first, ...others] = ids;
23805
- if (first === undefined) {
23806
- throw new ClientError(`No seal root is known as "${reference}"; use its id`);
23807
- }
23808
- if (others.length > 0) {
23809
- throw new ClientError(`${ids.length} seal roots are named "${reference}" (${ids.join(", ")}); use the id`);
23810
- }
23811
- return first;
23812
- }
23813
- save(names) {
23814
- const sorted = Object.fromEntries([...names].sort(([a], [b]) => a.localeCompare(b)));
23815
- return writePrivateFile(this.path, `${JSON.stringify({ v: ROOT_NAMES_VERSION, names: sorted }, null, 2)}
23816
- `);
23817
- }
23818
- }
23819
-
23820
- // src/sealed/attachmentService.ts
23821
- import { createReadStream as createReadStream2 } from "node:fs";
23822
- import { open as open4, stat as stat4, unlink } from "node:fs/promises";
23823
- import { basename as basename3 } from "node:path";
23824
- var DOWNLOAD_FILE_MODE = 384;
23825
- var DEFAULT_MIME_TYPE = "application/octet-stream";
23826
- async function* fileBytes(path) {
23827
- try {
23828
- for await (const chunk of createReadStream2(path))
23829
- yield chunk;
23830
- } catch (error48) {
23831
- throw fileError(error48);
23832
- }
23833
- }
23834
- async function* verified(plaintext, meta3) {
23835
- const hash2 = await createStreamingSha256();
23836
- let size = 0;
23837
- for await (const chunk of plaintext) {
23838
- hash2.update(chunk);
23839
- size += chunk.length;
23840
- yield chunk;
23841
- }
23842
- if (size !== meta3.size || hash2.digestHex() !== meta3.sha256_plain) {
23843
- throw new ClientError("Downloaded attachment does not match its sealed size and hash");
23844
- }
23845
- }
23846
-
23847
- class AttachmentService {
23848
- ctx;
23849
- access;
23850
- constructor(ctx, access) {
23851
- this.ctx = ctx;
23852
- this.access = access;
23853
- }
23854
- async list(documentId) {
23855
- const opened = await this.access.open(documentId);
23856
- const attachments = [];
23857
- for (const record2 of await this.ctx.api.listAttachments(documentId)) {
23858
- const meta3 = await this.metaOf(opened, record2);
23859
- attachments.push({
23860
- id: record2.id,
23861
- filename: meta3.filename,
23862
- mime: meta3.mime,
23863
- size: meta3.size
23864
- });
23865
- }
23866
- return {
23867
- document_id: documentId,
23868
- seal_seq: opened.node.seal_seq,
23869
- attachments
23870
- };
23871
- }
23872
- async upload(args) {
23873
- const opened = await this.access.open(args.document_id);
23874
- assertSeq(opened.node, args.expected_seq);
23875
- assertAccepted(opened.node);
23876
- const info = await stat4(args.file_path).catch((error48) => {
23877
- throw fileError(error48);
23878
- });
23879
- if (!info.isFile()) {
23880
- throw new ClientError(`${args.file_path} is not a regular file; only regular files can be attached`);
23881
- }
23882
- await this.assertWithinLimit(args.file_path, info.size);
23883
- return this.attachTo(opened, {
23884
- filename: basename3(args.file_path),
23885
- mime: args.mime_type,
23886
- bytes: fileBytes(args.file_path)
23887
- });
23888
- }
23889
- async attach(args) {
23890
- const opened = await this.access.open(args.document_id);
23891
- assertSeq(opened.node, args.expected_seq);
23892
- assertAccepted(opened.node);
23893
- await this.assertWithinLimit(`'${args.file.filename}'`, args.size);
23894
- return this.attachTo(opened, args.file);
24415
+ import { existsSync as existsSync3 } from "node:fs";
24416
+ import { dirname as dirname3, join as join5 } from "node:path";
24417
+ var ROOT_NAMES_FILE = "roots.json";
24418
+ var ROOT_NAMES_VERSION = 1;
24419
+ var OBJECT_ID4 = /^[0-9a-f]{24}$/;
24420
+ function rootNamesPath(tokenConfigPath) {
24421
+ return join5(dirname3(tokenConfigPath), ROOT_NAMES_FILE);
24422
+ }
24423
+ function assertNames(value, path) {
24424
+ const raw = value;
24425
+ const names = raw?.names;
24426
+ if (raw?.v !== ROOT_NAMES_VERSION || typeof names !== "object" || names === null || Array.isArray(names) || !Object.entries(names).every(([id, name]) => OBJECT_ID4.test(id) && typeof name === "string" && name.length > 0)) {
24427
+ throw new ClientError(`Seal root names ${path} are malformed`);
23895
24428
  }
23896
- async attachTo(opened, plain) {
23897
- const { node, dek } = opened;
23898
- const ctx = this.attachmentContext(node.id, newObjectId());
23899
- const file2 = await this.encrypt(dek, ctx, plain);
23900
- const attEncMeta = serializeEnvelope(await encryptAttachmentMeta(dek, ctx, file2.meta));
23901
- const sigs = await this.access.sign({
23902
- op: "attach",
23903
- tenant: this.ctx.tenant,
23904
- node: node.id,
23905
- root: node.seal_root_id,
23906
- baseSeq: node.seal_seq,
23907
- attId: ctx.attId,
23908
- blobCtSha256: file2.blobSha256,
23909
- attEncMeta
23910
- }, [node.seal_root_id]);
23911
- const stored = await this.ctx.api.attach(node.id, {
23912
- base_seq: node.seal_seq,
23913
- att_id: ctx.attId,
23914
- att_enc_meta: attEncMeta,
23915
- blob_sha256: file2.blobSha256,
23916
- ciphertext: file2.ciphertext,
23917
- sigs
23918
- });
23919
- return {
23920
- attachment_id: stored.id,
23921
- document_id: node.id,
23922
- seal_seq: stored.seal_seq,
23923
- size: file2.meta.size
23924
- };
24429
+ return new Map(Object.entries(names));
24430
+ }
24431
+
24432
+ class RootNames {
24433
+ path;
24434
+ changes = Promise.resolve();
24435
+ constructor(path) {
24436
+ this.path = path;
23925
24437
  }
23926
- async files(documentId) {
23927
- const opened = await this.access.open(documentId);
23928
- const files = [];
23929
- for (const record2 of await this.ctx.api.listAttachments(documentId)) {
23930
- const meta3 = await this.metaOf(opened, record2);
23931
- files.push({
23932
- id: record2.id,
23933
- meta: meta3,
23934
- bytes: () => this.plaintextOf(opened, record2.id, meta3)
23935
- });
24438
+ async all() {
24439
+ if (!existsSync3(this.path))
24440
+ return new Map;
24441
+ const text = await readPrivateFile(this.path, "Seal root names");
24442
+ let parsed;
24443
+ try {
24444
+ parsed = JSON.parse(text);
24445
+ } catch {
24446
+ throw new ClientError(`Seal root names ${this.path} are not valid JSON`);
23936
24447
  }
23937
- return files;
24448
+ return assertNames(parsed, this.path);
23938
24449
  }
23939
- async* plaintextOf(opened, attachmentId, meta3) {
23940
- const ciphertext = await this.ctx.api.downloadAttachment(opened.node.id, attachmentId);
23941
- yield* verified(decryptAttachment(opened.dek, this.attachmentContext(opened.node.id, attachmentId), ciphertext), meta3);
24450
+ async nameOf(rootId) {
24451
+ return (await this.all()).get(rootId) ?? null;
23942
24452
  }
23943
- async download(args) {
23944
- const opened = await this.access.open(args.document_id);
23945
- const record2 = (await this.ctx.api.listAttachments(args.document_id)).find((candidate) => candidate.id === args.attachment_id);
23946
- if (record2 === undefined) {
23947
- throw new ClientError(`${args.document_id} has no attachment ${args.attachment_id}`);
23948
- }
23949
- const meta3 = await this.metaOf(opened, record2);
23950
- await this.write(args.out_path, this.plaintextOf(opened, args.attachment_id, meta3));
23951
- return {
23952
- attachment_id: args.attachment_id,
23953
- out_path: args.out_path,
23954
- filename: meta3.filename,
23955
- size: meta3.size
23956
- };
24453
+ async describe(rootId) {
24454
+ const name = await this.nameOf(rootId);
24455
+ return name === null ? rootId : `"${name}" (${rootId})`;
23957
24456
  }
23958
- async delete(args) {
23959
- const node = await this.ctx.api.getNode(args.document_id);
23960
- assertSeq(node, args.expected_seq);
23961
- const sigs = await this.access.sign({
23962
- op: "detach",
23963
- tenant: this.ctx.tenant,
23964
- node: node.id,
23965
- root: node.seal_root_id,
23966
- baseSeq: node.seal_seq,
23967
- attId: args.attachment_id
23968
- }, [node.seal_root_id]);
23969
- const changed = await this.ctx.api.detach(node.id, {
23970
- base_seq: node.seal_seq,
23971
- att_id: args.attachment_id,
23972
- sigs
24457
+ remember(rootId, name) {
24458
+ if (name.length === 0 || OBJECT_ID4.test(name))
24459
+ return this.forget(rootId);
24460
+ return this.change(async () => {
24461
+ const names = await this.all();
24462
+ if (names.get(rootId) === name)
24463
+ return;
24464
+ names.set(rootId, name);
24465
+ await this.save(names);
23973
24466
  });
23974
- return { document_id: node.id, seal_seq: changed.seal_seq, deleted: true };
23975
24467
  }
23976
- attachmentContext(nodeId, attId) {
23977
- return { ...this.access.nodeContext(nodeId), attId };
24468
+ forget(rootId) {
24469
+ return this.change(async () => {
24470
+ const names = await this.all();
24471
+ if (names.delete(rootId))
24472
+ await this.save(names);
24473
+ });
23978
24474
  }
23979
- metaOf(opened, record2) {
23980
- if (!record2.sealed || record2.att_enc_meta === null) {
23981
- throw new ClientError(`Attachment ${record2.id} is not sealed`);
23982
- }
23983
- return decryptAttachmentMeta(opened.dek, this.attachmentContext(opened.node.id, record2.id), parseEnvelope(record2.att_enc_meta));
24475
+ change(apply) {
24476
+ const done = this.changes.then(apply);
24477
+ this.changes = done.catch(() => {});
24478
+ return done;
23984
24479
  }
23985
- async encrypt(dek, ctx, file2) {
23986
- const plainHash = await createStreamingSha256();
23987
- const cipherHash = await createStreamingSha256();
23988
- let size = 0;
23989
- async function* plaintext() {
23990
- for await (const bytes of file2.bytes) {
23991
- plainHash.update(bytes);
23992
- size += bytes.length;
23993
- yield bytes;
23994
- }
23995
- }
23996
- const parts = [];
23997
- for await (const part of encryptAttachment(dek, ctx, plaintext())) {
23998
- cipherHash.update(part);
23999
- parts.push(part);
24000
- }
24001
- return {
24002
- ciphertext: new Blob(parts),
24003
- blobSha256: cipherHash.digestHex(),
24004
- meta: {
24005
- filename: file2.filename,
24006
- mime: file2.mime,
24007
- size,
24008
- sha256_plain: plainHash.digestHex()
24009
- }
24010
- };
24480
+ follow(done, change) {
24481
+ return followUp(done, "its name could not be updated on this machine", change);
24011
24482
  }
24012
- async assertWithinLimit(what, size) {
24013
- const { max_file_size_bytes: limit } = await this.ctx.api.limits();
24014
- if (!Number.isSafeInteger(limit) || limit <= 0) {
24015
- throw new ClientError("The server did not report a usable upload limit (max_file_size_bytes)");
24483
+ async assertUsable(name, rootId) {
24484
+ if (OBJECT_ID4.test(name)) {
24485
+ throw new ClientError(`"${name}" has the shape of an id, so it cannot be the name of a seal root`);
24016
24486
  }
24017
- const sealedSize = attachmentCiphertextSize(size);
24018
- if (sealedSize > limit) {
24019
- throw new ClientError(`${what} is too large to attach: it is ${size} bytes, ${sealedSize} bytes once sealed, and the server accepts at most ${limit} bytes`);
24487
+ for (const [id, taken] of await this.all()) {
24488
+ if (taken === name && id !== rootId) {
24489
+ throw new ClientError(`Seal root ${id} is already named "${name}" on this machine; choose another name`);
24490
+ }
24020
24491
  }
24021
24492
  }
24022
- async write(path, plaintext) {
24023
- let handle;
24024
- try {
24025
- handle = await open4(path, "wx", DOWNLOAD_FILE_MODE);
24026
- } catch (error48) {
24027
- if (error48.code === "EEXIST") {
24028
- throw new ClientError(`${path} already exists; choose a new out_path`);
24029
- }
24030
- throw fileError(error48);
24493
+ async isName(reference) {
24494
+ return [...(await this.all()).values()].includes(reference);
24495
+ }
24496
+ async resolve(reference) {
24497
+ if (OBJECT_ID4.test(reference))
24498
+ return reference;
24499
+ const ids = [...await this.all()].filter(([, name]) => name === reference).map(([id]) => id);
24500
+ const [first, ...others] = ids;
24501
+ if (first === undefined) {
24502
+ throw new ClientError(`No seal root is known as "${reference}"; use its id`);
24031
24503
  }
24032
- let complete = false;
24033
- try {
24034
- for await (const chunk of plaintext)
24035
- await handle.write(chunk);
24036
- complete = true;
24037
- } finally {
24038
- await handle.close();
24039
- if (!complete)
24040
- await unlink(path);
24504
+ if (others.length > 0) {
24505
+ throw new ClientError(`${ids.length} seal roots are named "${reference}" (${ids.join(", ")}); use the id`);
24041
24506
  }
24507
+ return first;
24508
+ }
24509
+ save(names) {
24510
+ const sorted = Object.fromEntries([...names].sort(([a], [b]) => a.localeCompare(b)));
24511
+ return writePrivateFile(this.path, `${JSON.stringify({ v: ROOT_NAMES_VERSION, names: sorted }, null, 2)}
24512
+ `);
24042
24513
  }
24043
24514
  }
24044
24515
 
@@ -26457,7 +26928,7 @@ class SecretHolder {
26457
26928
  import { chmod, lstat, rm as rm3 } from "node:fs/promises";
26458
26929
  import { connect as connect2, createServer } from "node:net";
26459
26930
  var SOCKET_MODE = 384;
26460
- var OBJECT_ID4 = /^[0-9a-f]{24}$/;
26931
+ var OBJECT_ID5 = /^[0-9a-f]{24}$/;
26461
26932
  var MAX_REQUEST_BYTES = 4096;
26462
26933
  var CLIENT_GONE = "The client that asked is gone";
26463
26934
  var MAX_SOCKET_PATH_BYTES = 103;
@@ -26488,7 +26959,7 @@ async function clearSocketPath(socketPath) {
26488
26959
  await rm3(socketPath);
26489
26960
  }
26490
26961
  function requireRootId2(value) {
26491
- if (typeof value !== "string" || !OBJECT_ID4.test(value)) {
26962
+ if (typeof value !== "string" || !OBJECT_ID5.test(value)) {
26492
26963
  throw new ClientError("root_id must be a 24-character id");
26493
26964
  }
26494
26965
  return value;
@@ -27398,7 +27869,7 @@ class TransferConsole {
27398
27869
  `);
27399
27870
  }
27400
27871
  async import(path) {
27401
- const passphrase = await readPassphrase(this.reader);
27872
+ const passphrase = await readPassphrase(this.reader, EXPORT_FILE);
27402
27873
  const { imported, store, present } = await this.transfer.import(path, passphrase);
27403
27874
  if (store !== null) {
27404
27875
  this.reader.write(`Imported ${imported.length} key(s) into ${KEY_STORE_LABELS[store]}:
@@ -27420,90 +27891,7 @@ class TransferConsole {
27420
27891
  }
27421
27892
 
27422
27893
  // src/transfer/keyTransfer.ts
27423
- import { readFile } from "node:fs/promises";
27424
-
27425
- // src/transfer/keyBundle.ts
27426
- var KEY_BUNDLE_KIND = "safekb-key-export";
27427
- var KEY_BUNDLE_VERSION = 1;
27428
- var KEY_INFO2 = "safekb/key-export/v1";
27429
- var EXPORT_KDF = MIN_KDF_PARAMS;
27430
- var MAX_KDF2 = { m: 256 * 1024, t: 8, p: 4 };
27431
- var OBJECT_ID5 = /^[0-9a-f]{24}$/;
27432
- function malformed2(why) {
27433
- return new ClientError(`Not a SafeKB key export: ${why}`);
27434
- }
27435
- function aadOf(salt, kdf) {
27436
- return `${KEY_INFO2}|${salt}|${kdf.m}|${kdf.t}|${kdf.p}`;
27437
- }
27438
- async function bundleKey(passphrase, salt, kdf) {
27439
- return deriveKey(await derivePassphraseIkm(passphrase, salt, kdf), KEY_INFO2);
27440
- }
27441
- function assertContent(value) {
27442
- const raw = value;
27443
- const names = raw?.names;
27444
- if (!Array.isArray(raw?.keys) || typeof names !== "object" || names === null || Array.isArray(names) || !Object.entries(names).every(([rootId, name]) => OBJECT_ID5.test(rootId) && typeof name === "string" && name.length > 0)) {
27445
- throw malformed2("its content is not keys and the names of their roots");
27446
- }
27447
- return { keys: raw.keys.map(assertKeyFile), names: { ...names } };
27448
- }
27449
- function parseBundle(text) {
27450
- let value;
27451
- try {
27452
- value = JSON.parse(text);
27453
- } catch {
27454
- throw malformed2("it is not valid JSON");
27455
- }
27456
- const raw = value;
27457
- if (raw?.kind !== KEY_BUNDLE_KIND) {
27458
- throw malformed2(`its kind is not '${KEY_BUNDLE_KIND}'`);
27459
- }
27460
- if (raw.v !== KEY_BUNDLE_VERSION) {
27461
- throw malformed2(`its version is not ${KEY_BUNDLE_VERSION}; use the version of @safekb/mcp that wrote it`);
27462
- }
27463
- const { kdf, salt } = raw;
27464
- if (typeof kdf !== "object" || kdf === null || typeof salt !== "string") {
27465
- throw malformed2("it has no KDF parameters and salt");
27466
- }
27467
- const params = assertKdfParams(kdf);
27468
- if (params.m > MAX_KDF2.m || params.t > MAX_KDF2.t || params.p > MAX_KDF2.p) {
27469
- throw malformed2("its KDF parameters are beyond what this client runs");
27470
- }
27471
- return {
27472
- v: raw.v,
27473
- kind: raw.kind,
27474
- kdf: params,
27475
- salt,
27476
- sealed: assertEnvelope(raw.sealed)
27477
- };
27478
- }
27479
- async function sealKeyBundle(content, passphrase) {
27480
- const saltBytes = randomBytes(SALT_BYTES);
27481
- const salt = toBase64(saltBytes);
27482
- const bundle = {
27483
- v: KEY_BUNDLE_VERSION,
27484
- kind: KEY_BUNDLE_KIND,
27485
- kdf: { ...EXPORT_KDF },
27486
- salt,
27487
- sealed: await sealEnvelope(await bundleKey(passphrase, saltBytes, EXPORT_KDF), utf8Encode(JSON.stringify(assertContent(content))), aadOf(salt, EXPORT_KDF))
27488
- };
27489
- return `${JSON.stringify(bundle, null, 2)}
27490
- `;
27491
- }
27492
- async function openKeyBundle(text, passphrase) {
27493
- const bundle = parseBundle(text);
27494
- const key = await bundleKey(passphrase, fromBase64(bundle.salt), bundle.kdf);
27495
- let plaintext;
27496
- try {
27497
- plaintext = await openEnvelope(key, bundle.sealed, aadOf(bundle.salt, bundle.kdf));
27498
- } catch (error48) {
27499
- if (!(error48 instanceof DecryptionError))
27500
- throw error48;
27501
- throw new ClientError("Wrong passphrase, or the export file was changed or damaged");
27502
- }
27503
- return assertContent(JSON.parse(utf8Decode(plaintext)));
27504
- }
27505
-
27506
- // src/transfer/keyTransfer.ts
27894
+ import { readFile as readFile2 } from "node:fs/promises";
27507
27895
  function idOf(file2) {
27508
27896
  return `${file2.tenant_id}/${file2.root_id}/${file2.kid}`;
27509
27897
  }
@@ -27533,7 +27921,7 @@ class KeyTransfer {
27533
27921
  return keys;
27534
27922
  }
27535
27923
  async import(path, passphrase) {
27536
- const text = await readFile(path, "utf8").catch((error48) => {
27924
+ const text = await readFile2(path, "utf8").catch((error48) => {
27537
27925
  throw fileError(error48);
27538
27926
  });
27539
27927
  const { keys, names } = await openKeyBundle(text, passphrase);
@@ -27630,9 +28018,9 @@ async function openStores(options, tenant, systemKeyring, chosen) {
27630
28018
  sidecar: SIDECAR_SUPPORTED ? new SidecarStore(new SidecarClient(sidecarSocketOf(options)), tenant) : null
27631
28019
  });
27632
28020
  }
27633
- async function openKeyStoresOnly(options, tenant, systemKeyring) {
28021
+ async function openKeyStoresOnly(options, tenant, systemKeyring, chosen) {
27634
28022
  return new SecretStores({
27635
- ...await openKeyStores(options, tenant, systemKeyring, await storesOf(options, systemKeyring, toStderr)),
28023
+ ...await openKeyStores(options, tenant, systemKeyring, chosen),
27636
28024
  sidecar: null
27637
28025
  });
27638
28026
  }
@@ -27640,7 +28028,7 @@ async function transferKeys(options, reader, systemKeyring = new OsKeyring) {
27640
28028
  const transferFile = parsed(options.transferFile);
27641
28029
  const { tenant_id: tenant } = await loadTokenConfig(options.configPath);
27642
28030
  const names = namesOf(options);
27643
- const terminal = new TransferConsole(reader, new KeyTransfer(await openKeyStoresOnly(options, tenant, systemKeyring), names), names);
28031
+ const terminal = new TransferConsole(reader, new KeyTransfer(await openKeyStoresOnly(options, tenant, systemKeyring, await storesOf(options, systemKeyring, toStderr)), names), names);
27644
28032
  await (options.mode === "export-keys" ? terminal.export(transferFile) : terminal.import(transferFile));
27645
28033
  }
27646
28034
  var WHILE_IT_RUNS = Number.POSITIVE_INFINITY;
@@ -27650,11 +28038,12 @@ async function runBackup(options, reader, systemKeyring = new OsKeyring) {
27650
28038
  const url2 = apiUrlFor(options, config2);
27651
28039
  const http = new HttpClient(url2, config2.token);
27652
28040
  const tenant = config2.tenant_id;
28041
+ const chosen = await storesOf(options, systemKeyring, toStderr);
27653
28042
  const sealed = {
27654
28043
  api: new SealedApi(http),
27655
28044
  session: new SealedSession(WHILE_IT_RUNS),
27656
28045
  tenant,
27657
- stores: options.mode === "export" ? await openStores(options, tenant, systemKeyring, await storesOf(options, systemKeyring, toStderr)) : await openKeyStoresOnly(options, tenant, systemKeyring),
28046
+ stores: options.mode === "export" ? await openStores(options, tenant, systemKeyring, chosen) : await openKeyStoresOnly(options, tenant, systemKeyring, chosen),
27658
28047
  names: namesOf(options)
27659
28048
  };
27660
28049
  const parts = {
@@ -27677,7 +28066,8 @@ async function runBackup(options, reader, systemKeyring = new OsKeyring) {
27677
28066
  await terminal.export(exporter, file2, options.compress ? "gzip" : "none");
27678
28067
  return;
27679
28068
  }
27680
- const importer = new Importer(parts, {
28069
+ const rawRootKeys = new RawRootKeys(await terminal.keysFrom(options.keyExports), (sourceTenant) => openStores(options, sourceTenant, systemKeyring, chosen));
28070
+ const importer = new Importer({ ...parts, rawRootKeys }, {
27681
28071
  tenantId: tenant,
27682
28072
  intoSpaceId: options.intoSpaceId,
27683
28073
  store: options.store ?? "default"
@@ -27734,7 +28124,7 @@ async function withKeyCommands(options, systemKeyring, run) {
27734
28124
  api: new SealedApi(new HttpClient(apiUrlFor(options, config2), config2.token)),
27735
28125
  session: new SealedSession(options.idleTtlMs),
27736
28126
  tenant: config2.tenant_id,
27737
- stores: await openKeyStoresOnly(options, config2.tenant_id, systemKeyring),
28127
+ stores: await openKeyStoresOnly(options, config2.tenant_id, systemKeyring, await storesOf(options, systemKeyring, toStderr)),
27738
28128
  names: namesOf(options)
27739
28129
  };
27740
28130
  try {