@safekb/mcp 0.2.0 → 0.3.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 CHANGED
@@ -53,6 +53,8 @@ Claude Desktop and Cursor (`mcpServers` in their MCP config file):
53
53
  | `--configure` | | Choose where new sealed keys are kept. Writes `stores.json` next to the token file. |
54
54
  | `--sidecar` | | Run the sidecar in this terminal. With `--confirm` it asks before every release of a secret. |
55
55
  | `--sidecar-socket <path>` | `SAFEKB_SIDECAR_SOCKET` | The sidecar's socket. Default `sidecar.sock` next to the token file. |
56
+ | `--export-keys <file>` | | Write every sealed key this machine keeps to a new passphrase-protected file. See [Moving keys to another machine](#moving-keys-to-another-machine). |
57
+ | `--import-keys <file>` | | Read such a file and keep its keys on this machine. |
56
58
  | | `SAFEKB_SEALED_IDLE_TTL` | Seconds an unlocked seal root may stay idle. Default 900. `unlock_sealed` can set it per root with `idle_ttl_seconds` (60 to 3600). |
57
59
  | | `SAFEKB_HOSTED_TIMEOUT` | Seconds a hosted tool call may take. Default 300. |
58
60
 
@@ -107,9 +109,11 @@ a terminal of its own:
107
109
  npx -y @safekb/mcp --sidecar
108
110
  ```
109
111
 
110
- - `add <root id> [minutes]` asks for the root's secret without showing it and holds it for that long
112
+ - `add <name or id> [minutes]` asks for the root's secret without showing it and holds it for that long
111
113
  (default 15 minutes). The time is fixed when you type the secret: use does not extend it.
112
- - `list`, `drop <root id>`, `drop all`, `quit`.
114
+ - `list`, `drop <name or id>`, `drop all`, `quit`.
115
+ - A seal root can be named by its name instead of its id (`add Tax papers`), see
116
+ [Names of seal roots](#names-of-seal-roots).
113
117
  - A new seal root with `store: "sidecar"` makes the sidecar ask you for a new passphrase, twice, while
114
118
  the tool call waits.
115
119
  - With `--confirm` it asks for your approval before every release of a secret.
@@ -118,6 +122,41 @@ Several roots can be held at once, each with its own passphrase and time. The si
118
122
  socket only your user can open; it is not available on Windows. Any program running as you can still
119
123
  ask it for a held secret, which is what `--confirm` is for.
120
124
 
125
+ ### Names of seal roots
126
+
127
+ The server cannot read the name of a sealed folder, and an id like `6acdb031893533e0ae1f5456` is no
128
+ name to type. So the client remembers the name of every seal root it creates or unlocks, in
129
+ `roots.json` next to the token file (mode 0600), and follows a rename or a delete. The sidecar and
130
+ the key export show a root as `"Tax papers" (6acd…)` and take the name wherever they take an id.
131
+
132
+ - The names are in plaintext in that file: readable on this machine by programs running as you,
133
+ never by the server. Delete the file to forget them; they come back as roots are unlocked.
134
+ - A name is never put into a tool result or an error, so unlocking a root does not tell the model
135
+ what it is called.
136
+ - When two roots have the same name, the name stands for neither: use the id.
137
+
138
+ ### Moving keys to another machine
139
+
140
+ A generated key lives only where it was made: in the system keyring or in a key file. To carry all of
141
+ them to another machine, or to back them up:
142
+
143
+ ```sh
144
+ npx -y @safekb/mcp --export-keys keys.safekb # on the machine that has the keys
145
+ npx -y @safekb/mcp --import-keys keys.safekb # on the other machine, after --authorize there
146
+ ```
147
+
148
+ - Export asks for a passphrase (typed twice, not shown; 12 or more characters are recommended, a
149
+ shorter one is pointed out and taken) and writes one file
150
+ with every key of the system keyring and of the key file directory, and the names of their roots.
151
+ The content is encrypted with AES-256-GCM under a key derived from the passphrase with Argon2id.
152
+ Without the passphrase the file opens for nobody.
153
+ - Import asks for that passphrase and keeps the keys in the store `--configure` chose on that
154
+ machine. A key the machine already keeps is left as it is, so an import can be repeated.
155
+ - Both need a terminal: the passphrase is never an argument, so it is in no shell history, no process
156
+ list and no conversation.
157
+ - A root that is opened by a passphrase has no key on any machine. There is nothing to export: type
158
+ the passphrase at the other machine's sidecar.
159
+
121
160
  ## Sealed credentials
122
161
 
123
162
  A sealed credential belongs to a seal root. Its password, TOTP seed, `sealed_notes` and the values of
@@ -2358,7 +2358,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
2358
2358
  // package.json
2359
2359
  var package_default = {
2360
2360
  name: "@safekb/mcp",
2361
- version: "0.2.0",
2361
+ version: "0.3.0",
2362
2362
  description: "SafeKB local MCP client: proxies the hosted SafeKB tools and adds zero-knowledge sealed documents.",
2363
2363
  license: "SEE LICENSE IN LICENSE",
2364
2364
  author: "Mustafa Öztürk",
@@ -6524,6 +6524,8 @@ Usage:
6524
6524
  safekb-mcp --authorize --url <api url> [--config <path>]
6525
6525
  safekb-mcp --configure [--config <path>]
6526
6526
  safekb-mcp --sidecar [--confirm] [--config <path>]
6527
+ safekb-mcp --export-keys <file> [--config <path>] [--keyring <dir>]
6528
+ safekb-mcp --import-keys <file> [--config <path>] [--keyring <dir>]
6527
6529
 
6528
6530
  Options:
6529
6531
  --url <api url> SafeKB API base URL. Also: KB_API_URL. Otherwise the URL saved by --authorize.
@@ -6539,6 +6541,10 @@ Options:
6539
6541
  --sidecar Hold seal root secrets you type, in memory only and for a limited time, and
6540
6542
  hand them to the client. Runs in its own terminal.
6541
6543
  --confirm With --sidecar: ask before every release of a secret.
6544
+ --export-keys <file> Write every sealed key this machine keeps (system keyring and key files) to
6545
+ a new file, encrypted under a passphrase you type. Needs a terminal.
6546
+ --import-keys <file> Read such a file and keep its keys in the store chosen by --configure. A
6547
+ key this machine already keeps is left as it is. Needs a terminal.
6542
6548
  --sidecar-socket <path> The sidecar's socket. Also: SAFEKB_SIDECAR_SOCKET. Default: sidecar.sock
6543
6549
  next to the token file.
6544
6550
  --version, --help
@@ -6562,12 +6568,19 @@ function secondsAsMs(env, name, defaultSeconds) {
6562
6568
  }
6563
6569
  return Number(raw) * 1000;
6564
6570
  }
6571
+ var EXCLUSIVE_MODES = [
6572
+ "authorize",
6573
+ "configure",
6574
+ "sidecar",
6575
+ "export-keys",
6576
+ "import-keys"
6577
+ ];
6565
6578
  function mode(flags) {
6566
6579
  if (flags.help)
6567
6580
  return "help";
6568
6581
  if (flags.version)
6569
6582
  return "version";
6570
- const chosen = ["authorize", "configure", "sidecar"].filter((name) => flags[name] === true);
6583
+ const chosen = EXCLUSIVE_MODES.filter((name) => flags[name] !== undefined);
6571
6584
  if (chosen.length > 1) {
6572
6585
  throw new StartupError(`Pass only one of ${chosen.map((name) => `--${name}`).join(", ")}`);
6573
6586
  }
@@ -6589,6 +6602,7 @@ ${USAGE}`);
6589
6602
  configPath: nonEmpty(values.config) ?? nonEmpty(env.SAFEKB_MCP_CONFIG) ?? defaultTokenConfigPath(),
6590
6603
  keyringDir: nonEmpty(values.keyring) ?? nonEmpty(env.SAFEKB_SEALED_KEYRING),
6591
6604
  sidecarSocket: nonEmpty(values["sidecar-socket"]) ?? nonEmpty(env.SAFEKB_SIDECAR_SOCKET),
6605
+ transferFile: nonEmpty(values["export-keys"]) ?? nonEmpty(values["import-keys"]),
6592
6606
  confirm: values.confirm === true,
6593
6607
  sealedOnly: values["sealed-only"] === true,
6594
6608
  idleTtlMs: secondsAsMs(env, "SAFEKB_SEALED_IDLE_TTL", DEFAULT_IDLE_TTL_SECONDS),
@@ -6610,12 +6624,110 @@ function parse(argv) {
6610
6624
  sidecar: { type: "boolean" },
6611
6625
  confirm: { type: "boolean" },
6612
6626
  "sidecar-socket": { type: "string" },
6627
+ "export-keys": { type: "string" },
6628
+ "import-keys": { type: "string" },
6613
6629
  help: { type: "boolean" },
6614
6630
  version: { type: "boolean" }
6615
6631
  }
6616
6632
  });
6617
6633
  }
6618
6634
 
6635
+ // src/rootNames.ts
6636
+ import { existsSync as existsSync2 } from "node:fs";
6637
+ import { dirname as dirname2, join as join4 } from "node:path";
6638
+ var ROOT_NAMES_FILE = "roots.json";
6639
+ var ROOT_NAMES_VERSION = 1;
6640
+ var OBJECT_ID = /^[0-9a-f]{24}$/;
6641
+ function rootNamesPath(tokenConfigPath) {
6642
+ return join4(dirname2(tokenConfigPath), ROOT_NAMES_FILE);
6643
+ }
6644
+ function assertNames(value, path) {
6645
+ const raw = value;
6646
+ const names = raw?.names;
6647
+ if (raw?.v !== ROOT_NAMES_VERSION || typeof names !== "object" || names === null || Array.isArray(names) || !Object.entries(names).every(([id, name]) => OBJECT_ID.test(id) && typeof name === "string" && name.length > 0)) {
6648
+ throw new ClientError(`Seal root names ${path} are malformed`);
6649
+ }
6650
+ return new Map(Object.entries(names));
6651
+ }
6652
+
6653
+ class RootNames {
6654
+ path;
6655
+ changes = Promise.resolve();
6656
+ constructor(path) {
6657
+ this.path = path;
6658
+ }
6659
+ async all() {
6660
+ if (!existsSync2(this.path))
6661
+ return new Map;
6662
+ const text = await readPrivateFile(this.path, "Seal root names");
6663
+ let parsed;
6664
+ try {
6665
+ parsed = JSON.parse(text);
6666
+ } catch {
6667
+ throw new ClientError(`Seal root names ${this.path} are not valid JSON`);
6668
+ }
6669
+ return assertNames(parsed, this.path);
6670
+ }
6671
+ async nameOf(rootId) {
6672
+ return (await this.all()).get(rootId) ?? null;
6673
+ }
6674
+ async describe(rootId) {
6675
+ const name = await this.nameOf(rootId);
6676
+ return name === null ? rootId : `"${name}" (${rootId})`;
6677
+ }
6678
+ remember(rootId, name) {
6679
+ if (name.length === 0)
6680
+ return this.forget(rootId);
6681
+ return this.change(async () => {
6682
+ const names = await this.all();
6683
+ if (names.get(rootId) === name)
6684
+ return;
6685
+ names.set(rootId, name);
6686
+ await this.save(names);
6687
+ });
6688
+ }
6689
+ forget(rootId) {
6690
+ return this.change(async () => {
6691
+ const names = await this.all();
6692
+ if (names.delete(rootId))
6693
+ await this.save(names);
6694
+ });
6695
+ }
6696
+ change(apply) {
6697
+ const done = this.changes.then(apply);
6698
+ this.changes = done.catch(() => {});
6699
+ return done;
6700
+ }
6701
+ async follow(done, change) {
6702
+ try {
6703
+ await change();
6704
+ } catch (error) {
6705
+ throw new ClientError(`${done}, but its name could not be updated on this machine: ${error.message}`);
6706
+ }
6707
+ }
6708
+ async isName(reference) {
6709
+ return [...(await this.all()).values()].includes(reference);
6710
+ }
6711
+ async resolve(reference) {
6712
+ if (OBJECT_ID.test(reference))
6713
+ return reference;
6714
+ const ids = [...await this.all()].filter(([, name]) => name === reference).map(([id]) => id);
6715
+ const [first, ...others] = ids;
6716
+ if (first === undefined) {
6717
+ throw new ClientError(`No seal root is known as "${reference}"; use its id`);
6718
+ }
6719
+ if (others.length > 0) {
6720
+ throw new ClientError(`${ids.length} seal roots are named "${reference}" (${ids.join(", ")}); use the id`);
6721
+ }
6722
+ return first;
6723
+ }
6724
+ save(names) {
6725
+ const sorted = Object.fromEntries([...names].sort(([a], [b]) => a.localeCompare(b)));
6726
+ return writePrivateFile(this.path, `${JSON.stringify({ v: ROOT_NAMES_VERSION, names: sorted }, null, 2)}
6727
+ `);
6728
+ }
6729
+ }
6730
+
6619
6731
  // src/server.ts
6620
6732
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6621
6733
  import {
@@ -6823,12 +6935,12 @@ class SealedSession {
6823
6935
  import { connect } from "node:net";
6824
6936
 
6825
6937
  // src/sidecar/protocol.ts
6826
- import { dirname as dirname2, join as join4 } from "node:path";
6938
+ import { dirname as dirname3, join as join5 } from "node:path";
6827
6939
  var SIDECAR_PROTOCOL_VERSION = 1;
6828
6940
  var SIDECAR_SOCKET_NAME = "sidecar.sock";
6829
6941
  var SIDECAR_SUPPORTED = process.platform !== "win32";
6830
6942
  function defaultSidecarSocket(configPath) {
6831
- return join4(dirname2(configPath), SIDECAR_SOCKET_NAME);
6943
+ return join5(dirname3(configPath), SIDECAR_SOCKET_NAME);
6832
6944
  }
6833
6945
 
6834
6946
  // src/sidecar/client.ts
@@ -6887,17 +6999,79 @@ class SidecarClient {
6887
6999
  }
6888
7000
  }
6889
7001
 
7002
+ // src/sidecar/lineReader.ts
7003
+ import { createInterface } from "node:readline/promises";
7004
+ import { Writable } from "node:stream";
7005
+
7006
+ class Echo extends Writable {
7007
+ muted = false;
7008
+ target;
7009
+ constructor(target) {
7010
+ super();
7011
+ this.target = target;
7012
+ }
7013
+ _write(chunk, _encoding, done) {
7014
+ if (!this.muted)
7015
+ this.target.write(chunk);
7016
+ done();
7017
+ }
7018
+ }
7019
+
7020
+ class TerminalReader {
7021
+ echo;
7022
+ lines;
7023
+ output;
7024
+ constructor(input, output) {
7025
+ this.output = output;
7026
+ this.echo = new Echo(output);
7027
+ this.lines = createInterface({
7028
+ input,
7029
+ output: this.echo,
7030
+ terminal: true,
7031
+ historySize: 0
7032
+ });
7033
+ }
7034
+ async read(prompt, options = {}) {
7035
+ const { hidden = false, signal } = options;
7036
+ if (!hidden)
7037
+ return this.lines.question(prompt, { signal });
7038
+ this.output.write(prompt);
7039
+ this.echo.muted = true;
7040
+ try {
7041
+ return await this.lines.question("", { signal });
7042
+ } finally {
7043
+ this.echo.muted = false;
7044
+ this.output.write(`
7045
+ `);
7046
+ }
7047
+ }
7048
+ hasTyped() {
7049
+ return this.lines.line.length > 0;
7050
+ }
7051
+ write(text) {
7052
+ this.output.write(text);
7053
+ }
7054
+ onLeave(listener) {
7055
+ this.lines.once("SIGINT", listener);
7056
+ this.lines.once("close", listener);
7057
+ }
7058
+ close() {
7059
+ this.lines.close();
7060
+ }
7061
+ }
7062
+
6890
7063
  // src/sidecar/console.ts
6891
7064
  var DEFAULT_HOLD_MINUTES = 15;
6892
7065
  var MS_PER_MINUTE = 60000;
6893
- var OBJECT_ID = /^[0-9a-f]{24}$/;
6894
7066
  var WHOLE_MINUTES = /^[1-9][0-9]*$/;
7067
+ var TRAILING_MINUTES = /^(.*\S)\s+([1-9][0-9]*)$/s;
6895
7068
  var COMMAND_PROMPT = "safekb-sidecar> ";
6896
7069
  var HELP = `Commands:
6897
- add <root id> [minutes] Type the secret of a seal root; it is held for the given minutes (default ${DEFAULT_HOLD_MINUTES}).
6898
- list Seal roots held and the time left.
6899
- drop <root id> | all Forget secrets now.
6900
- quit Forget everything and stop.
7070
+ add <name or id> [minutes] Type the secret of a seal root; it is held for the given minutes (default ${DEFAULT_HOLD_MINUTES}).
7071
+ list Seal roots held and the time left.
7072
+ drop <name or id> | all Forget secrets now.
7073
+ quit Forget everything and stop.
7074
+ A seal root is named by its id or by its name, once this machine has created or unlocked it.
6901
7075
  `;
6902
7076
  var UNPRINTABLE = /[\p{Cc}\p{Cf}]/gu;
6903
7077
  function printable2(text) {
@@ -6910,12 +7084,14 @@ function minutesLeft(remainingMs) {
6910
7084
  class SidecarConsole {
6911
7085
  reader;
6912
7086
  holder;
7087
+ names;
6913
7088
  turns = Promise.resolve();
6914
7089
  waiting = 0;
6915
7090
  emptyPrompt = null;
6916
- constructor(reader, holder) {
7091
+ constructor(reader, holder, names) {
6917
7092
  this.reader = reader;
6918
7093
  this.holder = holder;
7094
+ this.names = names;
6919
7095
  }
6920
7096
  async run() {
6921
7097
  while (!await this.inTurn(() => this.commandTurn())) {}
@@ -6936,7 +7112,7 @@ The MCP client asks for a new passphrase for seal root ${rootId} ("${printable2(
6936
7112
  return this.inTurn(async () => {
6937
7113
  signal.throwIfAborted();
6938
7114
  const answer = await this.reader.read(`
6939
- Release the secret of seal root ${rootId} to the MCP client? [y/N] `, { signal });
7115
+ Release the secret of seal root ${await this.shown(rootId)} to the MCP client? [y/N] `, { signal });
6940
7116
  return answer.trim().toLowerCase() === "y";
6941
7117
  });
6942
7118
  }
@@ -6971,18 +7147,18 @@ Release the secret of seal root ${rootId} to the MCP client? [y/N] `, { signal }
6971
7147
  }
6972
7148
  }
6973
7149
  async execute(line) {
6974
- const [command = "", ...args] = line.split(/\s+/);
7150
+ const [, command = "", rest = ""] = /^(\S*)\s*(.*)$/s.exec(line) ?? [];
6975
7151
  switch (command) {
6976
7152
  case "":
6977
7153
  return false;
6978
7154
  case "add":
6979
- await this.add(args);
7155
+ await this.reportingMistakes(() => this.add(rest));
6980
7156
  return false;
6981
7157
  case "list":
6982
- this.list();
7158
+ await this.list();
6983
7159
  return false;
6984
7160
  case "drop":
6985
- this.drop(args);
7161
+ await this.reportingMistakes(() => this.drop(rest));
6986
7162
  return false;
6987
7163
  case "quit":
6988
7164
  case "exit":
@@ -6992,14 +7168,37 @@ Release the secret of seal root ${rootId} to the MCP client? [y/N] `, { signal }
6992
7168
  return false;
6993
7169
  }
6994
7170
  }
6995
- async add(args) {
6996
- const [rootId = "", minutes = String(DEFAULT_HOLD_MINUTES)] = args;
6997
- if (!OBJECT_ID.test(rootId) || !WHOLE_MINUTES.test(minutes)) {
6998
- this.reader.write(`Usage: add <root id> [minutes]
7171
+ async reportingMistakes(command) {
7172
+ try {
7173
+ await command();
7174
+ } catch (error) {
7175
+ if (!(error instanceof ClientError))
7176
+ throw error;
7177
+ this.reader.write(`${printable2(error.message)}
7178
+ `);
7179
+ }
7180
+ }
7181
+ async shown(rootId) {
7182
+ return printable2(await this.names.describe(rootId));
7183
+ }
7184
+ async addTarget(typed) {
7185
+ const [, before, minutes] = TRAILING_MINUTES.exec(typed) ?? [];
7186
+ if (before !== undefined && minutes !== undefined && !await this.names.isName(typed)) {
7187
+ return { rootId: await this.names.resolve(before), minutes };
7188
+ }
7189
+ return {
7190
+ rootId: await this.names.resolve(typed),
7191
+ minutes: String(DEFAULT_HOLD_MINUTES)
7192
+ };
7193
+ }
7194
+ async add(typed) {
7195
+ if (typed.length === 0) {
7196
+ this.reader.write(`Usage: add <name or id> [minutes]
6999
7197
  `);
7000
7198
  return;
7001
7199
  }
7002
- const secret2 = await this.reader.read(`Secret of seal root ${rootId} (not shown): `, { hidden: true });
7200
+ const { rootId, minutes } = await this.addTarget(typed);
7201
+ const secret2 = await this.reader.read(`Secret of seal root ${await this.shown(rootId)} (not shown): `, { hidden: true });
7003
7202
  if (secret2.length === 0) {
7004
7203
  this.reader.write(`Nothing entered; nothing is held.
7005
7204
  `);
@@ -7009,7 +7208,7 @@ Release the secret of seal root ${rootId} to the MCP client? [y/N] `, { signal }
7009
7208
  this.reader.write(`Held for ${minutes} minute(s).
7010
7209
  `);
7011
7210
  }
7012
- list() {
7211
+ async list() {
7013
7212
  const held = this.holder.summary();
7014
7213
  if (held.length === 0) {
7015
7214
  this.reader.write(`Nothing is held.
@@ -7018,24 +7217,23 @@ Release the secret of seal root ${rootId} to the MCP client? [y/N] `, { signal }
7018
7217
  }
7019
7218
  for (const { rootId, secrets, remainingMs } of held) {
7020
7219
  const unconfirmed = secrets > 1 ? `, ${secrets} secrets (a change is unconfirmed)` : "";
7021
- this.reader.write(`${rootId} ${minutesLeft(remainingMs)} minute(s) left${unconfirmed}
7220
+ this.reader.write(`${await this.shown(rootId)} ${minutesLeft(remainingMs)} minute(s) left${unconfirmed}
7022
7221
  `);
7023
7222
  }
7024
7223
  }
7025
- drop(args) {
7026
- const [target = ""] = args;
7224
+ async drop(target) {
7027
7225
  if (target === "all") {
7028
7226
  this.holder.dropAll();
7029
7227
  this.reader.write(`Everything is forgotten.
7030
7228
  `);
7031
7229
  return;
7032
7230
  }
7033
- if (!OBJECT_ID.test(target)) {
7034
- this.reader.write(`Usage: drop <root id> | all
7231
+ if (target.length === 0) {
7232
+ this.reader.write(`Usage: drop <name or id> | all
7035
7233
  `);
7036
7234
  return;
7037
7235
  }
7038
- const dropped = this.holder.dropRoot(target);
7236
+ const dropped = this.holder.dropRoot(await this.names.resolve(target));
7039
7237
  this.reader.write(dropped === 0 ? `Nothing was held for it.
7040
7238
  ` : `Forgotten.
7041
7239
  `);
@@ -7145,67 +7343,6 @@ class SecretHolder {
7145
7343
  }
7146
7344
  }
7147
7345
 
7148
- // src/sidecar/lineReader.ts
7149
- import { createInterface } from "node:readline/promises";
7150
- import { Writable } from "node:stream";
7151
-
7152
- class Echo extends Writable {
7153
- muted = false;
7154
- target;
7155
- constructor(target) {
7156
- super();
7157
- this.target = target;
7158
- }
7159
- _write(chunk, _encoding, done) {
7160
- if (!this.muted)
7161
- this.target.write(chunk);
7162
- done();
7163
- }
7164
- }
7165
-
7166
- class TerminalReader {
7167
- echo;
7168
- lines;
7169
- output;
7170
- constructor(input, output) {
7171
- this.output = output;
7172
- this.echo = new Echo(output);
7173
- this.lines = createInterface({
7174
- input,
7175
- output: this.echo,
7176
- terminal: true,
7177
- historySize: 0
7178
- });
7179
- }
7180
- async read(prompt, options = {}) {
7181
- const { hidden = false, signal } = options;
7182
- if (!hidden)
7183
- return this.lines.question(prompt, { signal });
7184
- this.output.write(prompt);
7185
- this.echo.muted = true;
7186
- try {
7187
- return await this.lines.question("", { signal });
7188
- } finally {
7189
- this.echo.muted = false;
7190
- this.output.write(`
7191
- `);
7192
- }
7193
- }
7194
- hasTyped() {
7195
- return this.lines.line.length > 0;
7196
- }
7197
- write(text) {
7198
- this.output.write(text);
7199
- }
7200
- onLeave(listener) {
7201
- this.lines.once("SIGINT", listener);
7202
- this.lines.once("close", listener);
7203
- }
7204
- close() {
7205
- this.lines.close();
7206
- }
7207
- }
7208
-
7209
7346
  // src/sidecar/server.ts
7210
7347
  import { chmod, lstat, rm as rm2 } from "node:fs/promises";
7211
7348
  import { connect as connect2, createServer as createServer3 } from "node:net";
@@ -7399,7 +7536,7 @@ Socket: ${settings.socketPath}
7399
7536
  ${approval}
7400
7537
  ${HELP}`;
7401
7538
  }
7402
- async function runSidecar(settings) {
7539
+ async function runSidecar(settings, names) {
7403
7540
  if (!SIDECAR_SUPPORTED) {
7404
7541
  throw new StartupError("The sidecar is not available on Windows");
7405
7542
  }
@@ -7408,7 +7545,7 @@ async function runSidecar(settings) {
7408
7545
  }
7409
7546
  const holder = new SecretHolder;
7410
7547
  const reader = new TerminalReader(process.stdin, process.stdout);
7411
- const terminal = new SidecarConsole(reader, holder);
7548
+ const terminal = new SidecarConsole(reader, holder, names);
7412
7549
  const server = new SidecarServer(holder, terminal, settings);
7413
7550
  try {
7414
7551
  await server.listen();
@@ -7445,8 +7582,8 @@ import { mkdir as mkdir2 } from "node:fs/promises";
7445
7582
  import { createInterface as createInterface2 } from "node:readline/promises";
7446
7583
 
7447
7584
  // src/stores/storeConfig.ts
7448
- import { existsSync as existsSync2 } from "node:fs";
7449
- import { dirname as dirname3, join as join5 } from "node:path";
7585
+ import { existsSync as existsSync3 } from "node:fs";
7586
+ import { dirname as dirname4, join as join6 } from "node:path";
7450
7587
 
7451
7588
  // src/stores/secretStore.ts
7452
7589
  var KEY_STORE_NAMES = ["system", "file"];
@@ -7459,10 +7596,10 @@ function passphraseNote(seal2) {
7459
7596
  var STORE_CONFIG_NAME = "stores.json";
7460
7597
  var DEFAULT_KEY_DIR_NAME = "keys";
7461
7598
  function storeConfigPath(tokenConfigPath) {
7462
- return join5(dirname3(tokenConfigPath), STORE_CONFIG_NAME);
7599
+ return join6(dirname4(tokenConfigPath), STORE_CONFIG_NAME);
7463
7600
  }
7464
7601
  function defaultKeyDir(tokenConfigPath) {
7465
- return join5(dirname3(tokenConfigPath), DEFAULT_KEY_DIR_NAME);
7602
+ return join6(dirname4(tokenConfigPath), DEFAULT_KEY_DIR_NAME);
7466
7603
  }
7467
7604
  function assertStoreConfig(value, path) {
7468
7605
  const raw = value;
@@ -7480,7 +7617,7 @@ function assertStoreConfig(value, path) {
7480
7617
  }
7481
7618
  async function loadStoreConfig(tokenConfigPath) {
7482
7619
  const path = storeConfigPath(tokenConfigPath);
7483
- if (!existsSync2(path))
7620
+ if (!existsSync3(path))
7484
7621
  return null;
7485
7622
  let text;
7486
7623
  try {
@@ -7563,7 +7700,7 @@ async function agreeToKeyFiles(request) {
7563
7700
 
7564
7701
  // src/stores/fileKeyStore.ts
7565
7702
  import { mkdir as mkdir3, unlink } from "node:fs/promises";
7566
- import { join as join6 } from "node:path";
7703
+ import { join as join7 } from "node:path";
7567
7704
  var PRIVATE_DIR_MODE3 = 448;
7568
7705
 
7569
7706
  class FileKeyStore {
@@ -7600,8 +7737,11 @@ class FileKeyStore {
7600
7737
  note: `Outdated key file for seal root ${seal2.rootId}: ${forRoot.map(({ path }) => path).join(", ")} (the root's key was changed)`
7601
7738
  };
7602
7739
  }
7740
+ async all() {
7741
+ return (await this.keyring.load()).map(({ file }) => file);
7742
+ }
7603
7743
  locationOf(file) {
7604
- return join6(this.keyring.dir, `${file.root_id}-${file.kid}.json`);
7744
+ return join7(this.keyring.dir, `${file.root_id}-${file.kid}.json`);
7605
7745
  }
7606
7746
  async save(file) {
7607
7747
  await mkdir3(this.keyring.dir, { recursive: true, mode: PRIVATE_DIR_MODE3 });
@@ -7617,6 +7757,7 @@ import { randomBytes as randomBytes4 } from "node:crypto";
7617
7757
 
7618
7758
  // src/stores/systemKeyStore.ts
7619
7759
  var SYSTEM_KEYRING_SERVICE = "safekb-sealed";
7760
+ var KEY_ACCOUNT = /^([^/]+)\/([0-9a-f]{24})\/([0-9a-f]{16})$/;
7620
7761
  function accountOf(tenant, rootId, kid) {
7621
7762
  return `${tenant}/${rootId}/${kid}`;
7622
7763
  }
@@ -7642,6 +7783,32 @@ class SystemKeyStore {
7642
7783
  note: `the system keyring has no current key for seal root ${seal2.rootId}`
7643
7784
  } : { secrets: [key], note: "" };
7644
7785
  }
7786
+ async all() {
7787
+ const files = [];
7788
+ for (const { account, secret: secret2 } of await this.keyring.list()) {
7789
+ const [, tenant, rootId, kid] = KEY_ACCOUNT.exec(account) ?? [];
7790
+ if (tenant === undefined)
7791
+ continue;
7792
+ files.push(this.keyFileOf(account, { tenant, rootId, kid }, secret2));
7793
+ }
7794
+ return files;
7795
+ }
7796
+ keyFileOf(account, id, key) {
7797
+ try {
7798
+ return assertKeyFile({
7799
+ v: KEY_FILE_VERSION,
7800
+ kind: KEY_FILE_KIND,
7801
+ tenant_id: id.tenant,
7802
+ root_id: id.rootId,
7803
+ kid: id.kid,
7804
+ key
7805
+ });
7806
+ } catch (error) {
7807
+ if (!(error instanceof FormatError))
7808
+ throw error;
7809
+ throw new ClientError(`The system keyring entry ${SYSTEM_KEYRING_SERVICE} / ${account} holds no sealed key: ${error.message}`);
7810
+ }
7811
+ }
7645
7812
  locationOf(file) {
7646
7813
  return `system keyring entry ${SYSTEM_KEYRING_SERVICE} / ${accountOf(file.tenant_id, file.root_id, file.kid)}`;
7647
7814
  }
@@ -7695,6 +7862,18 @@ class OsKeyring {
7695
7862
  throw unavailable(error);
7696
7863
  }
7697
7864
  }
7865
+ async list() {
7866
+ try {
7867
+ const { findCredentialsAsync } = await import("@napi-rs/keyring");
7868
+ const found = await findCredentialsAsync(this.service);
7869
+ return found.map(({ account, password }) => ({
7870
+ account,
7871
+ secret: password
7872
+ }));
7873
+ } catch (error) {
7874
+ throw unavailable(error);
7875
+ }
7876
+ }
7698
7877
  async entry(account) {
7699
7878
  try {
7700
7879
  const { AsyncEntry } = await import("@napi-rs/keyring");
@@ -7768,6 +7947,10 @@ class SecretStores {
7768
7947
  }
7769
7948
  return this.require(defaultKeyStore);
7770
7949
  }
7950
+ keyStores() {
7951
+ const { system, file } = this.set;
7952
+ return [system, file].filter((store) => store !== null);
7953
+ }
7771
7954
  sidecar() {
7772
7955
  return this.require("sidecar");
7773
7956
  }
@@ -7898,10 +8081,12 @@ class NodeAccess {
7898
8081
  throw new ClientError(`Sealed node ${node.id} has no wrapped key`);
7899
8082
  }
7900
8083
  async open(nodeId) {
7901
- const node = await this.ctx.api.getNode(nodeId);
8084
+ return this.openNode(await this.ctx.api.getNode(nodeId));
8085
+ }
8086
+ async openNode(node) {
7902
8087
  const root = this.ctx.session.require(node.seal_root_id);
7903
8088
  const dek = await this.dekOf(node);
7904
- const meta = await decryptMeta(dek, this.nodeContext(nodeId), parseEnvelope(node.enc_meta));
8089
+ const meta = await decryptMeta(dek, this.nodeContext(node.id), parseEnvelope(node.enc_meta));
7905
8090
  return { node, root, dek, meta };
7906
8091
  }
7907
8092
  async sign(message, signerRootIds) {
@@ -8268,6 +8453,7 @@ class CreateService {
8268
8453
  id,
8269
8454
  placement: await this.placement(args.space_id, args.parent_id),
8270
8455
  isFolder: false,
8456
+ title: args.title,
8271
8457
  dek,
8272
8458
  encMeta: fields2.encMeta,
8273
8459
  encBody: fields2.encBody,
@@ -8287,6 +8473,7 @@ class CreateService {
8287
8473
  id,
8288
8474
  placement: await this.placement(args.space_id, args.parent_id),
8289
8475
  isFolder: true,
8476
+ title: args.name,
8290
8477
  dek,
8291
8478
  encMeta: await sealMeta(dek, this.access.nodeContext(id), meta),
8292
8479
  encBody: null,
@@ -8346,22 +8533,24 @@ class CreateService {
8346
8533
  const sealed = await createSealRoot(ctx, secret2.secret, PASSPHRASE_KDF);
8347
8534
  const encDek = serializeEnvelope(await wrapDek(sealed.keys, node.id, node.dek));
8348
8535
  this.ctx.session.unlock(sealed.keys, sealed.seal.kid);
8536
+ let created;
8349
8537
  try {
8350
8538
  const signers = outerRoot === null ? [node.id] : [node.id, outerRoot];
8351
8539
  const request = await this.signedCreate(node, node.id, encDek, sealed.sealJson, signers);
8352
- const created = await keeping(secret2, () => this.ctx.api.create(request)).catch((error) => {
8540
+ created = await keeping(secret2, () => this.ctx.api.create(request)).catch((error) => {
8353
8541
  throw unconfirmedRoot(error, node.id, secret2);
8354
8542
  });
8355
- return {
8356
- ...writeResult(created),
8357
- new_seal_root: true,
8358
- key_file: secret2.keyFile,
8359
- secret_store: secret2.store
8360
- };
8361
8543
  } catch (error) {
8362
8544
  this.ctx.session.lock(node.id);
8363
8545
  throw error;
8364
8546
  }
8547
+ await this.ctx.names.follow(`Seal root ${node.id} was created`, () => this.ctx.names.remember(node.id, node.title));
8548
+ return {
8549
+ ...writeResult(created),
8550
+ new_seal_root: true,
8551
+ key_file: secret2.keyFile,
8552
+ secret_store: secret2.store
8553
+ };
8365
8554
  }
8366
8555
  async signedCreate(node, sealRootId, encDek, seal2, signerRootIds) {
8367
8556
  const { spaceId, parentId } = node.placement;
@@ -9297,7 +9486,13 @@ class DocumentService {
9297
9486
  if (args.summary === undefined !== (args.summary_short === undefined)) {
9298
9487
  throw new ClientError("Pass summary and summary_short together");
9299
9488
  }
9300
- return open4.node.is_folder ? this.updateFolder(open4, args) : this.updateDocument(open4, args);
9489
+ const result = await (open4.node.is_folder ? this.updateFolder(open4, args) : this.updateDocument(open4, args));
9490
+ const { id } = open4.node;
9491
+ const { title } = args;
9492
+ if (title !== undefined && id === open4.node.seal_root_id) {
9493
+ await this.ctx.names.follow(`Seal root ${id} was renamed`, () => this.ctx.names.remember(id, title));
9494
+ }
9495
+ return result;
9301
9496
  }
9302
9497
  async replace(args) {
9303
9498
  const open4 = await this.access.open(args.document_id);
@@ -9425,22 +9620,30 @@ class RootService {
9425
9620
  }
9426
9621
  const source = await this.readSource(args);
9427
9622
  const { rootId } = source;
9428
- const seal2 = sealOf(await this.ctx.api.getDocument(rootId));
9623
+ const document = await this.ctx.api.getDocument(rootId);
9624
+ const seal2 = sealOf(document);
9429
9625
  const keys = await this.open({ tenant: this.ctx.tenant, root: rootId }, seal2, await this.secretsFor(source, seal2, args.store));
9430
9626
  const { idleTtlMs } = this.ctx.session.unlock(keys, seal2.kid, args.idle_ttl_seconds === undefined ? undefined : args.idle_ttl_seconds * 1000);
9627
+ const indexedDocuments = await this.readyOrLock(document);
9431
9628
  return {
9432
9629
  root_id: rootId,
9433
9630
  unlocked: true,
9434
9631
  secret_kind: seal2.secret_kind,
9435
9632
  idle_ttl_seconds: idleTtlMs / 1000,
9436
- indexed_documents: await this.indexOrLock(rootId)
9633
+ indexed_documents: indexedDocuments
9437
9634
  };
9438
9635
  }
9439
- async indexOrLock(rootId) {
9636
+ async rememberName(root) {
9637
+ const { meta } = await this.access.openNode(toSealedNode(root));
9638
+ await this.ctx.names.remember(root.id, meta.title);
9639
+ }
9640
+ async readyOrLock(root) {
9440
9641
  try {
9441
- return await this.search.sync(rootId);
9642
+ const indexed = await this.search.sync(root.id);
9643
+ await this.rememberName(root);
9644
+ return indexed;
9442
9645
  } catch (error) {
9443
- this.ctx.session.lock(rootId);
9646
+ this.ctx.session.lock(root.id);
9444
9647
  throw error;
9445
9648
  }
9446
9649
  }
@@ -9803,6 +10006,9 @@ class TreeService {
9803
10006
  }
9804
10007
  async signedDelete(node) {
9805
10008
  await this.ctx.api.delete(node.id, await this.access.signPlain("delete", node));
10009
+ if (node.id === node.seal_root_id) {
10010
+ await this.ctx.names.follow(`Seal root ${node.id} was deleted`, () => this.ctx.names.forget(node.id));
10011
+ }
9806
10012
  }
9807
10013
  async export(args) {
9808
10014
  const open4 = await this.access.open(args.document_id);
@@ -23660,6 +23866,225 @@ function createSealedTools(ctx) {
23660
23866
  ];
23661
23867
  }
23662
23868
 
23869
+ // src/transfer/keyBundle.ts
23870
+ var KEY_BUNDLE_KIND = "safekb-key-export";
23871
+ var KEY_BUNDLE_VERSION = 1;
23872
+ var RECOMMENDED_EXPORT_PASSPHRASE_LENGTH = 12;
23873
+ var KEY_INFO = "safekb/key-export/v1";
23874
+ var EXPORT_KDF = MIN_KDF_PARAMS;
23875
+ var MAX_KDF = { m: 256 * 1024, t: 8, p: 4 };
23876
+ var OBJECT_ID3 = /^[0-9a-f]{24}$/;
23877
+ function malformed(why) {
23878
+ return new ClientError(`Not a SafeKB key export: ${why}`);
23879
+ }
23880
+ function aadOf(salt, kdf) {
23881
+ return `${KEY_INFO}|${salt}|${kdf.m}|${kdf.t}|${kdf.p}`;
23882
+ }
23883
+ async function bundleKey(passphrase, salt, kdf) {
23884
+ return deriveKey(await derivePassphraseIkm(passphrase, salt, kdf), KEY_INFO);
23885
+ }
23886
+ function assertContent(value) {
23887
+ const raw = value;
23888
+ const names = raw?.names;
23889
+ if (!Array.isArray(raw?.keys) || typeof names !== "object" || names === null || Array.isArray(names) || !Object.entries(names).every(([rootId, name]) => OBJECT_ID3.test(rootId) && typeof name === "string" && name.length > 0)) {
23890
+ throw malformed("its content is not keys and the names of their roots");
23891
+ }
23892
+ return { keys: raw.keys.map(assertKeyFile), names: { ...names } };
23893
+ }
23894
+ function parseBundle(text) {
23895
+ let value;
23896
+ try {
23897
+ value = JSON.parse(text);
23898
+ } catch {
23899
+ throw malformed("it is not valid JSON");
23900
+ }
23901
+ const raw = value;
23902
+ if (raw?.kind !== KEY_BUNDLE_KIND) {
23903
+ throw malformed(`its kind is not '${KEY_BUNDLE_KIND}'`);
23904
+ }
23905
+ if (raw.v !== KEY_BUNDLE_VERSION) {
23906
+ throw malformed(`its version is not ${KEY_BUNDLE_VERSION}; use the version of @safekb/mcp that wrote it`);
23907
+ }
23908
+ const { kdf, salt } = raw;
23909
+ if (typeof kdf !== "object" || kdf === null || typeof salt !== "string") {
23910
+ throw malformed("it has no KDF parameters and salt");
23911
+ }
23912
+ const params = assertKdfParams(kdf);
23913
+ if (params.m > MAX_KDF.m || params.t > MAX_KDF.t || params.p > MAX_KDF.p) {
23914
+ throw malformed("its KDF parameters are beyond what this client runs");
23915
+ }
23916
+ return {
23917
+ v: raw.v,
23918
+ kind: raw.kind,
23919
+ kdf: params,
23920
+ salt,
23921
+ sealed: assertEnvelope(raw.sealed)
23922
+ };
23923
+ }
23924
+ async function sealKeyBundle(content, passphrase) {
23925
+ const saltBytes = randomBytes(SALT_BYTES);
23926
+ const salt = toBase64(saltBytes);
23927
+ const bundle = {
23928
+ v: KEY_BUNDLE_VERSION,
23929
+ kind: KEY_BUNDLE_KIND,
23930
+ kdf: { ...EXPORT_KDF },
23931
+ salt,
23932
+ sealed: await sealEnvelope(await bundleKey(passphrase, saltBytes, EXPORT_KDF), utf8Encode(JSON.stringify(assertContent(content))), aadOf(salt, EXPORT_KDF))
23933
+ };
23934
+ return `${JSON.stringify(bundle, null, 2)}
23935
+ `;
23936
+ }
23937
+ async function openKeyBundle(text, passphrase) {
23938
+ const bundle = parseBundle(text);
23939
+ const key = await bundleKey(passphrase, fromBase64(bundle.salt), bundle.kdf);
23940
+ let plaintext;
23941
+ try {
23942
+ plaintext = await openEnvelope(key, bundle.sealed, aadOf(bundle.salt, bundle.kdf));
23943
+ } catch (error48) {
23944
+ if (!(error48 instanceof DecryptionError))
23945
+ throw error48;
23946
+ throw new ClientError("Wrong passphrase, or the export file was changed or damaged");
23947
+ }
23948
+ return assertContent(JSON.parse(utf8Decode(plaintext)));
23949
+ }
23950
+
23951
+ // src/transfer/console.ts
23952
+ var UNPRINTABLE2 = /[\p{Cc}\p{Cf}]/gu;
23953
+ var STORE_LABELS = {
23954
+ system: "the system keyring",
23955
+ file: "the key file directory"
23956
+ };
23957
+
23958
+ class TransferConsole {
23959
+ reader;
23960
+ transfer;
23961
+ names;
23962
+ constructor(reader, transfer, names) {
23963
+ this.reader = reader;
23964
+ this.transfer = transfer;
23965
+ this.names = names;
23966
+ }
23967
+ async export(path) {
23968
+ await this.transfer.exportable();
23969
+ const keys = await this.transfer.export(path, await this.readNewPassphrase());
23970
+ this.reader.write(`Exported ${keys.length} key(s) to ${path}:
23971
+ `);
23972
+ await this.writeRoots(keys);
23973
+ this.reader.write(`The file opens only with the passphrase you typed: keep both, and keep them apart.
23974
+ ` + `A seal root that is opened by a passphrase has no key on this machine and is not in the file.
23975
+ `);
23976
+ }
23977
+ async import(path) {
23978
+ const passphrase = await this.reader.read("Passphrase of the export file (not shown): ", { hidden: true });
23979
+ const { imported, store, present } = await this.transfer.import(path, passphrase);
23980
+ if (store !== null) {
23981
+ this.reader.write(`Imported ${imported.length} key(s) into ${STORE_LABELS[store]}:
23982
+ `);
23983
+ await this.writeRoots(imported);
23984
+ }
23985
+ if (present.length > 0) {
23986
+ this.reader.write(`${present.length} key(s) were already on this machine and were left as they are:
23987
+ `);
23988
+ await this.writeRoots(present);
23989
+ }
23990
+ }
23991
+ async writeRoots(keys) {
23992
+ for (const { root_id } of keys) {
23993
+ const shown = (await this.names.describe(root_id)).replace(UNPRINTABLE2, "");
23994
+ this.reader.write(` ${shown}
23995
+ `);
23996
+ }
23997
+ }
23998
+ async readNewPassphrase() {
23999
+ for (;; ) {
24000
+ const first = await this.reader.read(`Passphrase for the export file (not shown; ${RECOMMENDED_EXPORT_PASSPHRASE_LENGTH} or more characters recommended): `, { hidden: true });
24001
+ if (first.length === 0) {
24002
+ this.reader.write(`Nothing entered.
24003
+ `);
24004
+ continue;
24005
+ }
24006
+ if (first.length < RECOMMENDED_EXPORT_PASSPHRASE_LENGTH) {
24007
+ this.reader.write(`Note: shorter than the recommended ${RECOMMENDED_EXPORT_PASSPHRASE_LENGTH} characters; every key in the file is only as safe as this passphrase.
24008
+ `);
24009
+ }
24010
+ const second = await this.reader.read("Repeat it: ", { hidden: true });
24011
+ if (first === second)
24012
+ return first;
24013
+ this.reader.write(`The two entries differ.
24014
+ `);
24015
+ }
24016
+ }
24017
+ }
24018
+
24019
+ // src/transfer/keyTransfer.ts
24020
+ import { readFile } from "node:fs/promises";
24021
+ function idOf(file2) {
24022
+ return `${file2.tenant_id}/${file2.root_id}/${file2.kid}`;
24023
+ }
24024
+
24025
+ class KeyTransfer {
24026
+ stores;
24027
+ names;
24028
+ constructor(stores, names) {
24029
+ this.stores = stores;
24030
+ this.names = names;
24031
+ }
24032
+ async exportable() {
24033
+ const keys = [...(await this.kept()).values()];
24034
+ if (keys.length === 0) {
24035
+ throw new ClientError("There is no key to export: neither the system keyring nor the key file directory of this client keeps one");
24036
+ }
24037
+ return keys;
24038
+ }
24039
+ async export(path, passphrase) {
24040
+ const keys = await this.exportable();
24041
+ const known = await this.names.all();
24042
+ const names = Object.fromEntries(keys.flatMap(({ root_id }) => {
24043
+ const name = known.get(root_id);
24044
+ return name === undefined ? [] : [[root_id, name]];
24045
+ }));
24046
+ await writeNewPrivateFile(path, await sealKeyBundle({ keys, names }, passphrase));
24047
+ return keys;
24048
+ }
24049
+ async import(path, passphrase) {
24050
+ const text = await readFile(path, "utf8").catch((error48) => {
24051
+ throw fileError(error48);
24052
+ });
24053
+ const { keys, names } = await openKeyBundle(text, passphrase);
24054
+ const kept = await this.kept();
24055
+ const present = keys.filter((file2) => kept.has(idOf(file2)));
24056
+ const imported = keys.filter((file2) => !kept.has(idOf(file2)));
24057
+ const store = await this.saveNew(imported);
24058
+ await this.adoptNames(keys, names);
24059
+ return { imported, store, present };
24060
+ }
24061
+ async saveNew(keys) {
24062
+ if (keys.length === 0)
24063
+ return null;
24064
+ const target = this.stores.keyStore("default");
24065
+ for (const file2 of keys)
24066
+ await target.save(file2);
24067
+ return target.name;
24068
+ }
24069
+ async adoptNames(keys, names) {
24070
+ const known = await this.names.all();
24071
+ for (const { root_id } of keys) {
24072
+ const name = names[root_id];
24073
+ if (name !== undefined && !known.has(root_id)) {
24074
+ await this.names.remember(root_id, name);
24075
+ }
24076
+ }
24077
+ }
24078
+ async kept() {
24079
+ const kept = new Map;
24080
+ for (const store of this.stores.keyStores()) {
24081
+ for (const file2 of await store.all())
24082
+ kept.set(idOf(file2), file2);
24083
+ }
24084
+ return kept;
24085
+ }
24086
+ }
24087
+
23663
24088
  // src/main.ts
23664
24089
  var VERSION = package_default.version;
23665
24090
  var IDLE_SWEEP_MS = 30000;
@@ -23674,6 +24099,9 @@ async function openKeyring(dir) {
23674
24099
  }
23675
24100
  return keyring;
23676
24101
  }
24102
+ function namesOf(options) {
24103
+ return new RootNames(rootNamesPath(options.configPath));
24104
+ }
23677
24105
  function sidecarSocketOf(options) {
23678
24106
  return options.sidecarSocket ?? defaultSidecarSocket(options.configPath);
23679
24107
  }
@@ -23696,16 +24124,55 @@ async function storeConfigOf(options, systemKeyring) {
23696
24124
  await saveStoreConfig(options.configPath, chosen);
23697
24125
  return chosen;
23698
24126
  }
23699
- async function openStores(options, tenant, systemKeyring) {
24127
+ async function openKeyStores(options, tenant, systemKeyring) {
23700
24128
  const chosen = await storeConfigOf(options, systemKeyring);
23701
24129
  const keyring = await openKeyring(options.keyringDir ?? chosen?.key_dir ?? null);
23702
- return new SecretStores({
24130
+ return {
23703
24131
  system: chosen?.system_keyring === true ? new SystemKeyStore(systemKeyring, tenant) : null,
23704
24132
  file: keyring === null ? null : new FileKeyStore(keyring, tenant),
23705
- sidecar: SIDECAR_SUPPORTED ? new SidecarStore(new SidecarClient(sidecarSocketOf(options)), tenant) : null,
23706
24133
  defaultKeyStore: chosen?.default_store ?? null
24134
+ };
24135
+ }
24136
+ async function openStores(options, tenant, systemKeyring) {
24137
+ return new SecretStores({
24138
+ ...await openKeyStores(options, tenant, systemKeyring),
24139
+ sidecar: SIDECAR_SUPPORTED ? new SidecarStore(new SidecarClient(sidecarSocketOf(options)), tenant) : null
23707
24140
  });
23708
24141
  }
24142
+ async function transferKeys(options, reader, systemKeyring = new OsKeyring) {
24143
+ const flag = `--${options.mode}`;
24144
+ if (options.transferFile === null) {
24145
+ throw new StartupError(`${flag} needs the path of the export file`);
24146
+ }
24147
+ const { tenant_id: tenant } = await loadTokenConfig(options.configPath);
24148
+ const names = namesOf(options);
24149
+ const terminal = new TransferConsole(reader, new KeyTransfer(new SecretStores({
24150
+ ...await openKeyStores(options, tenant, systemKeyring),
24151
+ sidecar: null
24152
+ }), names), names);
24153
+ await (options.mode === "export-keys" ? terminal.export(options.transferFile) : terminal.import(options.transferFile));
24154
+ }
24155
+ async function transferKeysAtTerminal(options) {
24156
+ if (!process.stdin.isTTY) {
24157
+ throw new StartupError(`--${options.mode} needs a terminal: the passphrase of the export file is typed into it`);
24158
+ }
24159
+ const reader = new TerminalReader(process.stdin, process.stdout);
24160
+ let finished = false;
24161
+ reader.onLeave(() => {
24162
+ if (finished)
24163
+ return;
24164
+ process.stderr.write(`
24165
+ safekb-mcp: --${options.mode} was aborted
24166
+ `);
24167
+ process.exit(130);
24168
+ });
24169
+ try {
24170
+ await transferKeys(options, reader);
24171
+ } finally {
24172
+ finished = true;
24173
+ reader.close();
24174
+ }
24175
+ }
23709
24176
  function apiUrlFor(options, config2) {
23710
24177
  if (options.url === null)
23711
24178
  return config2.url;
@@ -23724,7 +24191,8 @@ async function serve(options, transport, systemKeyring = new OsKeyring) {
23724
24191
  api: new SealedApi(new HttpClient(apiUrl, config2.token)),
23725
24192
  session,
23726
24193
  tenant: config2.tenant_id,
23727
- stores: await openStores(options, config2.tenant_id, systemKeyring)
24194
+ stores: await openStores(options, config2.tenant_id, systemKeyring),
24195
+ names: namesOf(options)
23728
24196
  });
23729
24197
  const hosted = options.sealedOnly ? null : await HostedProxy.connect({
23730
24198
  apiUrl,
@@ -23790,10 +24258,11 @@ async function main(argv, env) {
23790
24258
  return;
23791
24259
  }
23792
24260
  if (options.mode === "sidecar") {
23793
- await runSidecar({
23794
- socketPath: sidecarSocketOf(options),
23795
- confirm: options.confirm
23796
- });
24261
+ await runSidecar({ socketPath: sidecarSocketOf(options), confirm: options.confirm }, namesOf(options));
24262
+ return;
24263
+ }
24264
+ if (options.mode === "export-keys" || options.mode === "import-keys") {
24265
+ await transferKeysAtTerminal(options);
23797
24266
  return;
23798
24267
  }
23799
24268
  if (!HAS_POSIX_MODES)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@safekb/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "SafeKB local MCP client: proxies the hosted SafeKB tools and adds zero-knowledge sealed documents.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Mustafa Öztürk",