@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 +41 -2
- package/dist/safekb-mcp.js +589 -120
- package/package.json +1 -1
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 <
|
|
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 <
|
|
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
|
package/dist/safekb-mcp.js
CHANGED
|
@@ -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.
|
|
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 =
|
|
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
|
|
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
|
|
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 <
|
|
6898
|
-
list
|
|
6899
|
-
drop <
|
|
6900
|
-
quit
|
|
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 = "",
|
|
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(
|
|
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(
|
|
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
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
7034
|
-
this.reader.write(`Usage: drop <
|
|
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
|
|
7449
|
-
import { dirname as
|
|
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
|
|
7599
|
+
return join6(dirname4(tokenConfigPath), STORE_CONFIG_NAME);
|
|
7463
7600
|
}
|
|
7464
7601
|
function defaultKeyDir(tokenConfigPath) {
|
|
7465
|
-
return
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
9633
|
+
indexed_documents: indexedDocuments
|
|
9437
9634
|
};
|
|
9438
9635
|
}
|
|
9439
|
-
async
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
23795
|
-
|
|
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