@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.
- package/README.md +18 -6
- package/dist/safekb-mcp.js +1031 -641
- package/package.json +2 -2
package/dist/safekb-mcp.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
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
|
|
6850
|
+
var KEY_INFO2 = "safekb/export/v1";
|
|
6771
6851
|
var ARCHIVE_KDF = MIN_KDF_PARAMS;
|
|
6772
|
-
var
|
|
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
|
|
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,
|
|
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(`${
|
|
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
|
|
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
|
|
6928
|
+
throw malformed2(`its kind is not '${ARCHIVE_KIND}'`);
|
|
6849
6929
|
}
|
|
6850
6930
|
if (raw.v !== ARCHIVE_VERSION) {
|
|
6851
|
-
throw
|
|
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
|
|
6935
|
+
throw malformed2("its header is incomplete");
|
|
6856
6936
|
}
|
|
6857
6937
|
const params = assertKdfParams(kdf);
|
|
6858
|
-
if (params.m >
|
|
6859
|
-
throw
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
7285
|
+
return text.replace(OBJECT_ID2, (id) => this.all.get(id) ?? id);
|
|
7199
7286
|
}
|
|
7200
7287
|
mentionsUnknown(text) {
|
|
7201
|
-
return (text.match(
|
|
7288
|
+
return (text.match(OBJECT_ID2) ?? []).some((id) => !this.all.has(id));
|
|
7202
7289
|
}
|
|
7203
7290
|
static mentionsId(text) {
|
|
7204
|
-
return text.search(
|
|
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
|
|
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) => !
|
|
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
|
|
7821
|
-
throw new ClientError(`The export has record format ${record.format}, this client reads ${
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8527
|
-
const { node, meta } =
|
|
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(
|
|
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
|
-
|
|
8671
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
8695
|
-
|
|
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
|
|
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
|
-
|
|
8893
|
-
|
|
8894
|
-
|
|
8895
|
-
|
|
8896
|
-
|
|
8897
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
23467
|
-
|
|
23468
|
-
|
|
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
|
|
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: [
|
|
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
|
|
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]) =>
|
|
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
|
-
|
|
23897
|
-
|
|
23898
|
-
|
|
23899
|
-
|
|
23900
|
-
|
|
23901
|
-
|
|
23902
|
-
|
|
23903
|
-
|
|
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
|
|
23927
|
-
|
|
23928
|
-
|
|
23929
|
-
|
|
23930
|
-
|
|
23931
|
-
|
|
23932
|
-
|
|
23933
|
-
|
|
23934
|
-
|
|
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
|
|
24448
|
+
return assertNames(parsed, this.path);
|
|
23938
24449
|
}
|
|
23939
|
-
async
|
|
23940
|
-
|
|
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
|
|
23944
|
-
const
|
|
23945
|
-
|
|
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
|
-
|
|
23959
|
-
|
|
23960
|
-
|
|
23961
|
-
|
|
23962
|
-
|
|
23963
|
-
|
|
23964
|
-
|
|
23965
|
-
|
|
23966
|
-
|
|
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
|
-
|
|
23977
|
-
return
|
|
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
|
-
|
|
23980
|
-
|
|
23981
|
-
|
|
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
|
-
|
|
23986
|
-
|
|
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
|
|
24013
|
-
|
|
24014
|
-
|
|
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
|
|
24018
|
-
|
|
24019
|
-
|
|
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
|
|
24023
|
-
|
|
24024
|
-
|
|
24025
|
-
|
|
24026
|
-
|
|
24027
|
-
|
|
24028
|
-
|
|
24029
|
-
|
|
24030
|
-
|
|
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
|
-
|
|
24033
|
-
|
|
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
|
|
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" || !
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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 {
|