@chrischall/mcp-host-cli 0.0.0-stage → 2.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/prompt.mjs ADDED
@@ -0,0 +1,126 @@
1
+ // Terminal prompts for the register wizard and `secret set`.
2
+ //
3
+ // Every prompt writes to STDERR (the default `output`), never stdout: the
4
+ // CLI's stdout is JSON a script may be parsing, and a question printed into
5
+ // it would turn `mcp-host register | jq` into a parse error the moment
6
+ // someone ran it from a terminal. Streams are injectable so tests drive the
7
+ // prompter over a PassThrough and a collector without a TTY.
8
+
9
+ import { createInterface } from 'node:readline/promises';
10
+
11
+ /**
12
+ * @typedef {{ value: string, label?: string }} Choice
13
+ * @typedef {{
14
+ * ask(question: string, opts?: { default?: string, hint?: string }): Promise<string>,
15
+ * confirm(question: string, def?: boolean): Promise<boolean>,
16
+ * choose(question: string, options: Choice[]): Promise<string>,
17
+ * secret(question: string): Promise<string>,
18
+ * close(): void,
19
+ * }} Prompter
20
+ */
21
+
22
+ /**
23
+ * @param {{ input?: NodeJS.ReadableStream & { isTTY?: boolean, setRawMode?: (on: boolean) => unknown, resume?: () => unknown, pause?: () => unknown },
24
+ * output?: { write(s: string): unknown }, exit?: (code: number) => void }} [opts]
25
+ * @returns {Prompter}
26
+ */
27
+ export function makePrompter({ input = process.stdin, output = process.stderr, exit = (code) => process.exit(code) } = {}) {
28
+ /**
29
+ * One readline interface PER QUESTION rather than one for the prompter's
30
+ * life: an open interface keeps its own 'data' listener on the input, and
31
+ * `secret()` needs to be the ONLY reader of the stream while it runs —
32
+ * otherwise readline would buffer the keystrokes it saw and hand the
33
+ * secret back as the answer to the NEXT question. `terminal: false` so
34
+ * readline never echoes or redraws; the tty's own cooked mode handles
35
+ * line editing for the ordinary prompts.
36
+ */
37
+ async function line(prompt) {
38
+ const rl = createInterface({ input, output, terminal: false });
39
+ try {
40
+ return (await rl.question(prompt)).trim();
41
+ } finally {
42
+ rl.close();
43
+ }
44
+ }
45
+
46
+ /** @type {Prompter} */
47
+ const prompter = {
48
+ async ask(question, { default: def, hint } = {}) {
49
+ if (hint) output.write(` ${hint}\n`);
50
+ const suffix = def !== undefined && def !== '' ? ` [${def}]` : '';
51
+ const answer = await line(`${question}${suffix}: `);
52
+ return answer === '' && def !== undefined ? def : answer;
53
+ },
54
+
55
+ async confirm(question, def = false) {
56
+ for (;;) {
57
+ const answer = (await line(`${question} ${def ? '[Y/n]' : '[y/N]'}: `)).toLowerCase();
58
+ if (answer === '') return def;
59
+ if (answer === 'y' || answer === 'yes') return true;
60
+ if (answer === 'n' || answer === 'no') return false;
61
+ output.write(' answer y or n\n');
62
+ }
63
+ },
64
+
65
+ async choose(question, options) {
66
+ output.write(`${question}\n`);
67
+ options.forEach((o, i) => output.write(` ${i + 1}) ${o.label ?? o.value}\n`));
68
+ for (;;) {
69
+ const answer = await line(`> `);
70
+ const byNumber = /^\d+$/.test(answer) ? options[Number(answer) - 1] : undefined;
71
+ const byValue = options.find((o) => o.value === answer);
72
+ const picked = byNumber ?? byValue;
73
+ if (picked) return picked.value;
74
+ output.write(` pick 1-${options.length}\n`);
75
+ }
76
+ },
77
+
78
+ /**
79
+ * Read a value with NOTHING echoed. Raw mode (when the input is a TTY)
80
+ * is what stops the terminal itself from echoing; the rest is one
81
+ * character at a time: Enter ends it, backspace edits it, Ctrl-C is the
82
+ * conventional 130 (128 + SIGINT) because raw mode swallows the signal
83
+ * the terminal would otherwise have sent.
84
+ */
85
+ secret(question) {
86
+ output.write(`${question}: `);
87
+ return new Promise((resolve) => {
88
+ const raw = Boolean(input.isTTY && typeof input.setRawMode === 'function');
89
+ if (raw) input.setRawMode(true);
90
+ let value = '';
91
+ const done = (result) => {
92
+ input.off('data', onData);
93
+ if (raw) input.setRawMode(false);
94
+ input.pause?.();
95
+ output.write('\n');
96
+ resolve(result);
97
+ };
98
+ const onData = (chunk) => {
99
+ for (const ch of chunk.toString('utf8')) {
100
+ if (ch === '') {
101
+ done('');
102
+ exit(130);
103
+ return;
104
+ }
105
+ if (ch === '\r' || ch === '\n') {
106
+ done(value);
107
+ return;
108
+ }
109
+ if (ch === '' || ch === '\b') {
110
+ value = value.slice(0, -1);
111
+ continue;
112
+ }
113
+ value += ch;
114
+ }
115
+ };
116
+ input.on('data', onData);
117
+ input.resume?.();
118
+ });
119
+ },
120
+
121
+ close() {
122
+ input.pause?.();
123
+ },
124
+ };
125
+ return prompter;
126
+ }
package/src/seal.mjs ADDED
@@ -0,0 +1,226 @@
1
+ // The CLI's sealing half of docs/SEALED-SECRETS.md — a hand-written ESM
2
+ // MIRROR of `packages/core/src/seal.ts`, which is THE implementation. The CLI
3
+ // runs as plain Node on `.mjs` with no TypeScript loader, so it cannot import
4
+ // core; what it can do is be held against the same known-answer vectors
5
+ // (`core/tests/fixtures/seal-vectors.json`) by `tests/seal-mirror.test.ts`,
6
+ // which fails the moment the two drift by a byte.
7
+ //
8
+ // Deliberately MINIMAL: seal, fingerprint, and the encoding helpers. There is
9
+ // no unseal here and must never be — the CLI seals values it is handed and
10
+ // compares fingerprints; opening a sealed value is the runner's job alone.
11
+ //
12
+ // WebCrypto ONLY (`globalThis.crypto`), never `node:crypto`: the construction
13
+ // has to produce identical bytes from a browser, Node 26 and workerd, and
14
+ // keeping to the one API all three share is how the mirror stays a mirror.
15
+ //
16
+ // e = fresh X25519 keypair per seal
17
+ // shared = X25519(e_priv, pk_a)
18
+ // key = HKDF-SHA256(ikm = shared, salt = e_pub ‖ pk_a, info = "mcp-host/seal/v1")
19
+ // aad = scope ‖ "\0" ‖ accountId ‖ "\0" ‖ name (UTF-8)
20
+ // wire = "sealed:v1:" + kid + ":" + b64url(e_pub) + ":" + b64url(iv ‖ ct ‖ tag)
21
+ // fp = base32(SHA-256("mcp-host/fp/v1" ‖ salt ‖ value))[0:10] as xxxxx-xxxxx
22
+
23
+ export const SEAL_PREFIX = 'sealed:v1:';
24
+ const SEAL_INFO = 'mcp-host/seal/v1';
25
+ const FINGERPRINT_DOMAIN = 'mcp-host/fp/v1';
26
+
27
+ const KEY_BYTES = 32;
28
+ const IV_BYTES = 12;
29
+ const TAG_BYTES = 16;
30
+ const KID_HEX_CHARS = 16;
31
+ export const FINGERPRINT_SALT_BYTES = 16;
32
+
33
+ const SCOPE_RE = /^(account|principal:[^\0:]+|user:[^\0:]+:[^\0:]+)$/;
34
+ export const FINGERPRINT_RE = /^[a-z2-7]{5}-[a-z2-7]{5}$/;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Encoding
38
+
39
+ const B64URL = /^[A-Za-z0-9_-]*$/;
40
+
41
+ /** @param {Uint8Array} bytes */
42
+ export function toBase64Url(bytes) {
43
+ let binary = '';
44
+ for (const b of bytes) binary += String.fromCharCode(b);
45
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
46
+ }
47
+
48
+ /** Strict: unpadded, URL alphabet only — the same refusal core makes. @param {string} text */
49
+ export function fromBase64Url(text) {
50
+ if (!B64URL.test(text) || text.length % 4 === 1) throw new Error('malformed base64url (must be unpadded, URL alphabet)');
51
+ const padded = text.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (text.length % 4)) % 4);
52
+ const binary = atob(padded);
53
+ const out = new Uint8Array(binary.length);
54
+ for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
55
+ return out;
56
+ }
57
+
58
+ /** @param {Uint8Array} bytes */
59
+ export function bytesToHex(bytes) {
60
+ return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
61
+ }
62
+
63
+ /** @param {string} hex */
64
+ export function hexToBytes(hex) {
65
+ if (!/^([0-9a-f]{2})*$/i.test(hex)) throw new Error('not hex');
66
+ const out = new Uint8Array(hex.length / 2);
67
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
68
+ return out;
69
+ }
70
+
71
+ /** @param {string} text */
72
+ function utf8(text) {
73
+ return new TextEncoder().encode(text);
74
+ }
75
+
76
+ /** @param {Uint8Array[]} parts */
77
+ function concat(...parts) {
78
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
79
+ let at = 0;
80
+ for (const p of parts) {
81
+ out.set(p, at);
82
+ at += p.length;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ const subtle = () => globalThis.crypto.subtle;
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // X25519 via WebCrypto
91
+
92
+ /** PKCS#8 prefix for a raw 32-byte X25519 scalar (OID 1.3.101.110) — a constant, not a parser. */
93
+ const X25519_PKCS8_PREFIX = hexToBytes('302e020100300506032b656e04220420');
94
+
95
+ /** @param {Uint8Array} privateKey @param {boolean} [extractable] */
96
+ async function importPrivate(privateKey, extractable = false) {
97
+ if (privateKey.length !== KEY_BYTES) throw new Error(`X25519 private key must be ${KEY_BYTES} bytes`);
98
+ return subtle().importKey('pkcs8', concat(X25519_PKCS8_PREFIX, privateKey), { name: 'X25519' }, extractable, ['deriveBits']);
99
+ }
100
+
101
+ /** @param {Uint8Array} publicKey */
102
+ async function importPublic(publicKey) {
103
+ if (publicKey.length !== KEY_BYTES) throw new Error(`X25519 public key must be ${KEY_BYTES} bytes`);
104
+ return subtle().importKey('raw', publicKey, { name: 'X25519' }, true, []);
105
+ }
106
+
107
+ /** The public half of a raw X25519 private key. @param {Uint8Array} privateKey */
108
+ async function sealPublicKey(privateKey) {
109
+ const key = await importPrivate(privateKey, true);
110
+ const jwk = /** @type {{ x?: string }} */ (await subtle().exportKey('jwk', key));
111
+ if (!jwk.x) throw new Error('X25519 export yielded no public coordinate');
112
+ return fromBase64Url(jwk.x);
113
+ }
114
+
115
+ /** @param {Uint8Array} privateKey @param {Uint8Array} publicKey */
116
+ async function x25519(privateKey, publicKey) {
117
+ const priv = await importPrivate(privateKey);
118
+ const pub = await importPublic(publicKey);
119
+ return new Uint8Array(await subtle().deriveBits({ name: 'X25519', public: pub }, priv, KEY_BYTES * 8));
120
+ }
121
+
122
+ /** @param {Uint8Array} ikm @param {Uint8Array} salt @param {string} info */
123
+ async function hkdf(ikm, salt, info) {
124
+ const key = await subtle().importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
125
+ return new Uint8Array(await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info: utf8(info) }, key, KEY_BYTES * 8));
126
+ }
127
+
128
+ /** `kid` = the first 16 hex characters of SHA-256(publicKey). @param {Uint8Array} publicKey */
129
+ export async function deriveKid(publicKey) {
130
+ if (publicKey.length !== KEY_BYTES) throw new Error(`X25519 public key must be ${KEY_BYTES} bytes`);
131
+ const digest = new Uint8Array(await subtle().digest('SHA-256', publicKey));
132
+ return bytesToHex(digest).slice(0, KID_HEX_CHARS);
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Seal
137
+
138
+ /** @param {string} scope @param {string} accountId @param {string} name */
139
+ function aadFor(scope, accountId, name) {
140
+ for (const [label, part] of [
141
+ ['scope', scope],
142
+ ['accountId', accountId],
143
+ ['name', name],
144
+ ]) {
145
+ if (!part) throw new Error(`${label} is required for the AAD`);
146
+ if (part.includes('\0')) throw new Error(`${label} must not contain NUL`);
147
+ }
148
+ if (!SCOPE_RE.test(scope)) throw new Error(`scope ${scope} is not one of the three AAD scopes`);
149
+ return utf8(`${scope}\0${accountId}\0${name}`);
150
+ }
151
+
152
+ /**
153
+ * Seal `value` to the account public key `publicKey` (kid `kid`) under the
154
+ * AAD (scope, accountId, name). `ephemeral` is test-vector injection ONLY —
155
+ * a fresh key and iv per seal is what makes IV reuse impossible.
156
+ *
157
+ * @param {{ publicKey: Uint8Array, kid: string, scope: string, accountId: string, name: string, value: string,
158
+ * ephemeral?: { privateKey: Uint8Array, iv: Uint8Array } }} args
159
+ * @returns {Promise<string>} the wire form
160
+ */
161
+ export async function sealSecret(args) {
162
+ const { publicKey, kid, scope, accountId, name, value } = args;
163
+ const aad = aadFor(scope, accountId, name);
164
+ const actualKid = await deriveKid(publicKey);
165
+ if (actualKid !== kid) throw new Error(`public key is kid ${actualKid}, caller asked to seal to kid ${kid}`);
166
+ const ePriv = args.ephemeral?.privateKey ?? globalThis.crypto.getRandomValues(new Uint8Array(KEY_BYTES));
167
+ const iv = args.ephemeral?.iv ?? globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
168
+ if (iv.length !== IV_BYTES) throw new Error(`iv must be ${IV_BYTES} bytes`);
169
+ const ePub = await sealPublicKey(ePriv);
170
+ const shared = await x25519(ePriv, publicKey);
171
+ const raw = await hkdf(shared, concat(ePub, publicKey), SEAL_INFO);
172
+ const key = await subtle().importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt']);
173
+ const ct = new Uint8Array(
174
+ await subtle().encrypt({ name: 'AES-GCM', iv, additionalData: aad, tagLength: TAG_BYTES * 8 }, key, utf8(value)),
175
+ );
176
+ return `${SEAL_PREFIX}${kid}:${toBase64Url(ePub)}:${toBase64Url(concat(iv, ct))}`;
177
+ }
178
+
179
+ /**
180
+ * The wire form's parts, for a caller that wants to check what it sealed
181
+ * (the kid, the 32-byte ephemeral key). Never opens anything.
182
+ * @param {string} sealed
183
+ */
184
+ export function parseSealed(sealed) {
185
+ if (typeof sealed !== 'string' || !sealed.startsWith(SEAL_PREFIX)) throw new Error('not a sealed value (expected the sealed:v1: prefix)');
186
+ const parts = sealed.split(':');
187
+ if (parts.length !== 5) throw new Error('malformed sealed value: expected sealed:v1:<kid>:<ePub>:<body>');
188
+ const [, , kid, ePubText, bodyText] = parts;
189
+ if (!/^[0-9a-f]{16}$/.test(kid)) throw new Error('malformed sealed value: kid must be 16 lowercase hex characters');
190
+ const ePub = fromBase64Url(ePubText);
191
+ if (ePub.length !== KEY_BYTES) throw new Error('malformed sealed value: ephemeral public key must be 32 bytes');
192
+ const body = fromBase64Url(bodyText);
193
+ if (body.length < IV_BYTES + TAG_BYTES) throw new Error('malformed sealed value: body shorter than iv + tag');
194
+ return { kid, ePub, body };
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Fingerprints
199
+
200
+ const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567';
201
+
202
+ export function newFingerprintSalt() {
203
+ return globalThis.crypto.getRandomValues(new Uint8Array(FINGERPRINT_SALT_BYTES));
204
+ }
205
+
206
+ /**
207
+ * `base32(SHA-256("mcp-host/fp/v1" ‖ salt ‖ value))[0:10]` as `xxxxx-xxxxx`.
208
+ * Compared locally and only locally: no server route answers "is this the value".
209
+ * @param {string} value @param {Uint8Array} salt
210
+ */
211
+ export async function computeFingerprint(value, salt) {
212
+ if (salt.length !== FINGERPRINT_SALT_BYTES) throw new Error(`fingerprint salt must be ${FINGERPRINT_SALT_BYTES} bytes`);
213
+ const digest = new Uint8Array(await subtle().digest('SHA-256', concat(utf8(FINGERPRINT_DOMAIN), salt, utf8(value))));
214
+ let bits = 0;
215
+ let acc = 0;
216
+ let out = '';
217
+ for (const byte of digest.subarray(0, 7)) {
218
+ acc = (acc << 8) | byte;
219
+ bits += 8;
220
+ while (bits >= 5 && out.length < 10) {
221
+ out += BASE32[(acc >>> (bits - 5)) & 31];
222
+ bits -= 5;
223
+ }
224
+ }
225
+ return `${out.slice(0, 5)}-${out.slice(5, 10)}`;
226
+ }
@@ -0,0 +1,100 @@
1
+ // Storing and checking account secrets over the control API, sealed by default
2
+ // (docs/SEALED-SECRETS.md). ONE path for every place the CLI creates a secret
3
+ // — `secret set`, the wizard's "a new secret" row — so there is exactly one
4
+ // place a value is sealed and exactly one that could get it wrong.
5
+ //
6
+ // The value arrives as a string and leaves as a `sealed:v1:…` wire string and
7
+ // a fingerprint. It is never logged, never returned to stdout, and never
8
+ // written into a message: every sentence this module prints names the SECRET
9
+ // (by name), the fingerprint and the kid, which are the three things a person
10
+ // needs afterwards and the three things that are safe to print.
11
+
12
+ import { computeFingerprint, fromBase64Url, newFingerprintSalt, sealSecret, toBase64Url } from './seal.mjs';
13
+
14
+ /** A `say` over a stderr-like sink. @param {{ write(s: string): unknown }} stderr */
15
+ const sayTo = (stderr) => (/** @type {string} */ s) => stderr.write(`${s}\n`);
16
+
17
+ /** `?account=<slug>` when one is named, else nothing. @param {string | undefined} account */
18
+ const accountQuery = (account) => (account ? `?account=${encodeURIComponent(account)}` : '');
19
+
20
+ /**
21
+ * `GET /api/v1/secrets/public-key` — the caller's account seal key. A 503
22
+ * here is the gateway saying there is no key to seal to (no runner, no root);
23
+ * its sentence is the thing to show, verbatim, so it propagates untouched.
24
+ * @param {(method: string, path: string, body?: unknown) => Promise<any>} api
25
+ */
26
+ async function fetchPublicKey(api, account) {
27
+ // The key of the account NAMED, and its id comes back with it: the seal's
28
+ // AAD binds `res.account`, so an operator sealing into `--account acme`
29
+ // seals to acme's key under acme's id, never their own.
30
+ const res = await api('GET', `/api/v1/secrets/public-key${accountQuery(account)}`);
31
+ const publicKey = fromBase64Url(res.publicKey);
32
+ if (publicKey.length !== 32) throw new Error('the gateway answered a public key that is not 32 bytes');
33
+ return { accountId: res.account, kid: res.kid, publicKey };
34
+ }
35
+
36
+ /**
37
+ * Seal `value` for account secret `name` and PUT it. A 409 is the gateway
38
+ * refusing a kid that is no longer current (a rotation landed between the
39
+ * key fetch and the write): the key is fetched again and the seal retried
40
+ * ONCE — twice would be a loop against a gateway that keeps moving.
41
+ *
42
+ * `legacy` sends the deprecated `{value}` body: the gateway encrypts (and can
43
+ * read) the value itself. Kept for the migration and says so every time.
44
+ *
45
+ * @param {(method: string, path: string, body?: unknown) => Promise<any>} api the THROWING variant: a non-2xx must reject, or the 409 retry cannot see it
46
+ * @param {string} name
47
+ * @param {string} value
48
+ * @param {{ legacy?: boolean, account?: string, stderr?: { write(s: string): unknown } }} [opts]
49
+ * @returns {Promise<{ stored: any, fingerprint: string | null, kid: string | null }>}
50
+ */
51
+ export async function storeSecretSealed(api, name, value, { legacy = false, account, stderr = process.stderr } = {}) {
52
+ const say = sayTo(stderr);
53
+ const path = `/api/v1/secrets/${encodeURIComponent(name)}${accountQuery(account)}`;
54
+ if (legacy) {
55
+ const stored = await api('PUT', path, { value });
56
+ say(`mcp-host: warning: --legacy stored ${name} gateway-readable; the {"value"} body is deprecated — drop --legacy to seal it to the account key`);
57
+ return { stored, fingerprint: null, kid: null };
58
+ }
59
+ const salt = newFingerprintSalt();
60
+ const fingerprint = await computeFingerprint(value, salt);
61
+ const fingerprintSalt = toBase64Url(salt);
62
+ let key = await fetchPublicKey(api, account);
63
+ for (let attempt = 0; ; attempt++) {
64
+ const sealed = await sealSecret({ publicKey: key.publicKey, kid: key.kid, scope: 'account', accountId: key.accountId, name, value });
65
+ try {
66
+ const stored = await api('PUT', path, { sealed, kid: key.kid, fingerprint, fingerprintSalt });
67
+ say(`stored ${name} — fingerprint ${fingerprint} (sealed to kid ${key.kid}); compare later with: mcp-host secret fingerprint ${name}`);
68
+ return { stored, fingerprint, kid: key.kid };
69
+ } catch (error) {
70
+ if (attempt === 0 && error?.status === 409) {
71
+ say(`mcp-host: the seal key moved (${error.message}); fetching the current key and sealing again`);
72
+ key = await fetchPublicKey(api, account);
73
+ continue;
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Recompute `name`'s fingerprint from a value the caller supplies and the
82
+ * salt the listing returns, and compare LOCALLY. The value is hashed here and
83
+ * nowhere else: the only request is the listing, and it carries nothing.
84
+ *
85
+ * @param {(method: string, path: string, body?: unknown) => Promise<any>} api
86
+ * @param {string} name
87
+ * @param {string} value
88
+ * @param {{ account?: string }} [opts]
89
+ * @returns {Promise<{ match: boolean, stored: string, yours: string }>}
90
+ */
91
+ export async function compareFingerprint(api, name, value, { account } = {}) {
92
+ const listed = await api('GET', `/api/v1/secrets${accountQuery(account)}`);
93
+ const row = (listed.secrets ?? []).find((/** @type {{ name: string }} */ s) => s.name === name);
94
+ if (!row) throw new Error(`no secret named ${name}`);
95
+ if (!row.fingerprint || !row.fingerprintSalt) {
96
+ throw new Error(`${name} has no fingerprint stored (a legacy row, or one stored without); \`mcp-host secret set ${name}\` re-stores it sealed with one`);
97
+ }
98
+ const yours = await computeFingerprint(value, fromBase64Url(row.fingerprintSalt));
99
+ return { match: yours === row.fingerprint, stored: row.fingerprint, yours };
100
+ }