@shieldfive/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/CHANGELOG.md +35 -0
- package/README.md +187 -59
- package/SECURITY.md +27 -8
- package/package.json +22 -12
- package/server.json +34 -0
- package/src/server.mjs +206 -30
- package/src/tools/vault.mjs +548 -0
- package/src/vault/api.mjs +190 -0
- package/src/vault/cli.mjs +115 -0
- package/src/vault/content.mjs +89 -0
- package/src/vault/credential.mjs +69 -0
- package/src/vault/namePool.mjs +90 -0
- package/src/vault/nameWorker.mjs +23 -0
- package/src/vault/session.mjs +170 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The grant-authenticated ShieldFive API (/api/agent/v1).
|
|
2
|
+
//
|
|
3
|
+
// Every call goes to the server: nothing here caches a response, so an expired
|
|
4
|
+
// or revoked grant fails on the very next tool call (the server re-checks it on
|
|
5
|
+
// every request). Scope checks in this process are a courtesy that produces a
|
|
6
|
+
// clearer error; the server is the boundary.
|
|
7
|
+
|
|
8
|
+
import { grantBearerToken } from '@shieldfive/crypto/vault'
|
|
9
|
+
|
|
10
|
+
import { ToolError } from '../roots.mjs'
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_API_URL = 'https://shieldfive.com'
|
|
13
|
+
const TIMEOUT_MS = 30_000
|
|
14
|
+
|
|
15
|
+
function sleep(ms, signal) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const t = setTimeout(resolve, ms)
|
|
18
|
+
signal?.addEventListener(
|
|
19
|
+
'abort',
|
|
20
|
+
() => {
|
|
21
|
+
clearTimeout(t)
|
|
22
|
+
reject(signal.reason)
|
|
23
|
+
},
|
|
24
|
+
{ once: true },
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createVaultApi({ credential, baseUrl = DEFAULT_API_URL, fetchImpl = fetch }) {
|
|
30
|
+
const bearer = grantBearerToken(credential)
|
|
31
|
+
const origin = new URL(baseUrl)
|
|
32
|
+
if (origin.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(origin.hostname)) {
|
|
33
|
+
throw new Error('SHIELDFIVE_API_URL must be https (or localhost for development).')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A rate-limited request waits and retries (at most 3 times, honouring
|
|
37
|
+
// Retry-After and cancellation). A quota refusal does not: it will not clear
|
|
38
|
+
// by waiting, so it surfaces at once.
|
|
39
|
+
async function call(method, path, body, signal) {
|
|
40
|
+
for (let attempt = 0; ; attempt++) {
|
|
41
|
+
try {
|
|
42
|
+
return await once(method, path, body, signal)
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err?.code !== 'rate_limited' || attempt >= 3 || signal?.aborted) throw err
|
|
45
|
+
await sleep(Math.min(60_000, (err.retryAfterSeconds ?? 15 * (attempt + 1)) * 1000), signal)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function once(method, path, body, signal) {
|
|
51
|
+
const url = new URL(`/api/agent/v1${path}`, origin)
|
|
52
|
+
const timeout = AbortSignal.timeout(TIMEOUT_MS)
|
|
53
|
+
let res
|
|
54
|
+
try {
|
|
55
|
+
res = await fetchImpl(url, {
|
|
56
|
+
method,
|
|
57
|
+
headers: {
|
|
58
|
+
authorization: `Bearer ${bearer}`,
|
|
59
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
60
|
+
},
|
|
61
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
62
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
63
|
+
cache: 'no-store',
|
|
64
|
+
})
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (signal?.aborted) throw err
|
|
67
|
+
throw new ToolError('network_error', 'Could not reach ShieldFive. Nothing was changed.')
|
|
68
|
+
}
|
|
69
|
+
if (res.ok) return res.json().catch(() => ({}))
|
|
70
|
+
return failure(res)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function failure(res) {
|
|
74
|
+
const data = await res.json().catch(() => ({}))
|
|
75
|
+
// The server's code is echoed to the model, so only a plain identifier passes.
|
|
76
|
+
const code =
|
|
77
|
+
typeof data.code === 'string' && /^[a-z_]{1,40}$/.test(data.code) ? data.code : `http_${res.status}`
|
|
78
|
+
if (res.status === 401) {
|
|
79
|
+
throw new ToolError(
|
|
80
|
+
'grant_invalid',
|
|
81
|
+
'This ShieldFive connection is expired or has been revoked. Create a new one in ' +
|
|
82
|
+
'ShieldFive → Settings → AI assistants and run `npx @shieldfive/mcp login`.',
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
if (res.status === 403 && code === 'missing_scope') {
|
|
86
|
+
throw new ToolError(
|
|
87
|
+
'missing_scope',
|
|
88
|
+
`This connection does not include the "${data.scope === 'read' ? 'read' : 'organize'}" permission. ` +
|
|
89
|
+
'The vault owner can create a connection with it in Settings → AI assistants.',
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
if (res.status === 404) {
|
|
93
|
+
throw new ToolError('not_found', 'That item is not in this connection’s scope, or no longer exists.')
|
|
94
|
+
}
|
|
95
|
+
if (res.status === 409) {
|
|
96
|
+
throw new ToolError('conflict', 'That item changed since it was listed. List again and retry.')
|
|
97
|
+
}
|
|
98
|
+
if (res.status === 429 && (code === 'transfer_limit' || code === 'egress_cap' || data.reason === 'egress_cap')) {
|
|
99
|
+
throw new ToolError(
|
|
100
|
+
'quota_exceeded',
|
|
101
|
+
'The vault owner’s download quota is used up for now (daily egress or monthly transfer). ' +
|
|
102
|
+
'Downloads resume when it resets; listing and organizing still work.',
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
if (res.status === 429) {
|
|
106
|
+
const err = new ToolError('rate_limited', 'ShieldFive is rate-limiting this connection. Wait a minute and retry.')
|
|
107
|
+
const retry = Number(res.headers.get('retry-after'))
|
|
108
|
+
if (Number.isFinite(retry) && retry > 0) err.retryAfterSeconds = retry
|
|
109
|
+
throw err
|
|
110
|
+
}
|
|
111
|
+
throw new ToolError(code, `ShieldFive refused the request (${res.status}). Nothing was changed.`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// A download that fails is reported through the same error mapping as every
|
|
115
|
+
// other call (401 revoked, 429 quota, …); a success is the byte stream.
|
|
116
|
+
async function rawDownload(id, signal) {
|
|
117
|
+
for (let attempt = 0; ; attempt++) {
|
|
118
|
+
const url = new URL(`/api/agent/v1/files/${id}/download`, origin)
|
|
119
|
+
const res = await fetchImpl(url, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
122
|
+
signal,
|
|
123
|
+
cache: 'no-store',
|
|
124
|
+
}).catch((err) => {
|
|
125
|
+
if (signal?.aborted) throw err
|
|
126
|
+
throw new ToolError('network_error', 'Could not reach ShieldFive.')
|
|
127
|
+
})
|
|
128
|
+
if (res.ok && res.body) return res
|
|
129
|
+
try {
|
|
130
|
+
await failure(res)
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (err?.code !== 'rate_limited' || attempt >= 3 || signal?.aborted) throw err
|
|
133
|
+
await sleep(Math.min(60_000, (err.retryAfterSeconds ?? 15 * (attempt + 1)) * 1000), signal)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function listAll(path, key, signal) {
|
|
139
|
+
const out = []
|
|
140
|
+
let after = null
|
|
141
|
+
for (let page = 0; page < 1000; page++) {
|
|
142
|
+
const q = `?limit=1000${after ? `&after=${after}` : ''}`
|
|
143
|
+
const data = await call('GET', `${path}${q}`, undefined, signal)
|
|
144
|
+
out.push(...(data[key] ?? []))
|
|
145
|
+
if (!data.next) return out
|
|
146
|
+
after = data.next
|
|
147
|
+
}
|
|
148
|
+
throw new ToolError('too_large', 'The listing did not finish within 1,000 pages.')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
grant: (signal) => call('GET', '/grant', undefined, signal),
|
|
153
|
+
folders: (signal) => listAll('/folders', 'folders', signal),
|
|
154
|
+
files: (signal) => listAll('/files', 'files', signal),
|
|
155
|
+
stats: (signal) => call('GET', '/stats', undefined, signal),
|
|
156
|
+
/**
|
|
157
|
+
* The file's ciphertext, streamed through ShieldFive (no storage URL is ever
|
|
158
|
+
* handed out). Refused past `maxBytes` without buffering the rest.
|
|
159
|
+
*/
|
|
160
|
+
async download(id, maxBytes, signal) {
|
|
161
|
+
const res = await rawDownload(id, signal)
|
|
162
|
+
const len = Number(res.headers.get('content-length') ?? 0)
|
|
163
|
+
if (len && len > maxBytes) throw new ToolError('too_large', 'File exceeds the size cap.')
|
|
164
|
+
const reader = res.body.getReader()
|
|
165
|
+
const parts = []
|
|
166
|
+
let total = 0
|
|
167
|
+
for (;;) {
|
|
168
|
+
const { done, value } = await reader.read()
|
|
169
|
+
if (done) break
|
|
170
|
+
total += value.length
|
|
171
|
+
if (total > maxBytes) {
|
|
172
|
+
await reader.cancel()
|
|
173
|
+
throw new ToolError('too_large', 'File exceeds the size cap.')
|
|
174
|
+
}
|
|
175
|
+
parts.push(value)
|
|
176
|
+
}
|
|
177
|
+
const out = new Uint8Array(total)
|
|
178
|
+
let off = 0
|
|
179
|
+
for (const p of parts) {
|
|
180
|
+
out.set(p, off)
|
|
181
|
+
off += p.length
|
|
182
|
+
}
|
|
183
|
+
return out
|
|
184
|
+
},
|
|
185
|
+
patchFile: (id, body, signal) => call('PATCH', `/files/${id}`, body, signal),
|
|
186
|
+
patchFolder: (id, body, signal) => call('PATCH', `/folders/${id}`, body, signal),
|
|
187
|
+
createFolder: (body, signal) => call('POST', '/folders', body, signal),
|
|
188
|
+
trash: (items, signal) => call('POST', '/trash', { items }, signal),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// `npx @shieldfive/mcp login | logout | status`
|
|
2
|
+
//
|
|
3
|
+
// login reads the connection string without echoing it, checks it against the
|
|
4
|
+
// server (so a typo or a revoked grant is caught now, not in the middle of a
|
|
5
|
+
// conversation), and stores it in the OS keychain. Nothing is written to a
|
|
6
|
+
// file and the value is never printed.
|
|
7
|
+
|
|
8
|
+
import { parseConnectionString } from '@shieldfive/crypto/vault'
|
|
9
|
+
|
|
10
|
+
import { createVaultApi, DEFAULT_API_URL } from './api.mjs'
|
|
11
|
+
import { deleteKeychain, loadGrantCredential, writeKeychain } from './credential.mjs'
|
|
12
|
+
|
|
13
|
+
const out = (s) => process.stderr.write(`${s}\n`)
|
|
14
|
+
|
|
15
|
+
function readHidden(prompt) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const stdin = process.stdin
|
|
18
|
+
if (!stdin.isTTY) {
|
|
19
|
+
let data = ''
|
|
20
|
+
stdin.setEncoding('utf8')
|
|
21
|
+
stdin.on('data', (c) => (data += c))
|
|
22
|
+
stdin.on('end', () => resolve(data.trim()))
|
|
23
|
+
stdin.on('error', reject)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
process.stderr.write(prompt)
|
|
27
|
+
let value = ''
|
|
28
|
+
stdin.setRawMode(true)
|
|
29
|
+
stdin.resume()
|
|
30
|
+
stdin.setEncoding('utf8')
|
|
31
|
+
const onData = (chunk) => {
|
|
32
|
+
for (const ch of chunk) {
|
|
33
|
+
const code = ch.charCodeAt(0)
|
|
34
|
+
if (ch === '\r' || ch === '\n') {
|
|
35
|
+
stdin.setRawMode(false)
|
|
36
|
+
stdin.pause()
|
|
37
|
+
stdin.off('data', onData)
|
|
38
|
+
process.stderr.write('\n')
|
|
39
|
+
resolve(value.trim())
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
if (code === 3) {
|
|
43
|
+
stdin.setRawMode(false)
|
|
44
|
+
process.stderr.write('\n')
|
|
45
|
+
reject(new Error('cancelled'))
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (code === 127 || code === 8) value = value.slice(0, -1)
|
|
49
|
+
else if (code >= 32) value += ch
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
stdin.on('data', onData)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function describe(credential, env) {
|
|
57
|
+
const api = createVaultApi({ credential, baseUrl: env.SHIELDFIVE_API_URL || DEFAULT_API_URL })
|
|
58
|
+
const { grant } = await api.grant()
|
|
59
|
+
const scope = grant.scopeAll ? 'whole vault' : `${grant.scopeFolderIds.length} folder(s)`
|
|
60
|
+
return `connection ${grant.id.slice(0, 8)}…: ${grant.scopes.join(' + ')}, ${scope}, expires ${grant.expiresAt}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function runCli(command, env = process.env) {
|
|
64
|
+
if (command === 'login') {
|
|
65
|
+
const raw = await readHidden('Paste the ShieldFive connection string (input hidden): ')
|
|
66
|
+
let credential
|
|
67
|
+
try {
|
|
68
|
+
credential = parseConnectionString(raw)
|
|
69
|
+
} catch {
|
|
70
|
+
out('That is not a ShieldFive connection string. Copy it again from Settings → AI assistants.')
|
|
71
|
+
return 1
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
out(`Checking with ShieldFive… ${await describe(credential, env)}`)
|
|
75
|
+
} catch (err) {
|
|
76
|
+
out(`ShieldFive did not accept it: ${err?.message ?? 'unknown error'}`)
|
|
77
|
+
return 1
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
await writeKeychain(raw)
|
|
81
|
+
} catch {
|
|
82
|
+
out(
|
|
83
|
+
'No system keychain is available here. Set SHIELDFIVE_GRANT in the MCP server’s ' +
|
|
84
|
+
'environment instead (anything that can read that environment can read the connection).',
|
|
85
|
+
)
|
|
86
|
+
return 1
|
|
87
|
+
}
|
|
88
|
+
out('Saved to the system keychain. Restart your AI assistant to pick it up.')
|
|
89
|
+
return 0
|
|
90
|
+
}
|
|
91
|
+
if (command === 'logout') {
|
|
92
|
+
const removed = await deleteKeychain()
|
|
93
|
+
out(removed ? 'Removed the connection from the system keychain.' : 'No connection was stored in the keychain.')
|
|
94
|
+
out('To cut off access everywhere, revoke the connection in ShieldFive → Settings → AI assistants.')
|
|
95
|
+
return 0
|
|
96
|
+
}
|
|
97
|
+
if (command === 'status') {
|
|
98
|
+
const credential = await loadGrantCredential(env).catch((e) => {
|
|
99
|
+
out(e.message)
|
|
100
|
+
return null
|
|
101
|
+
})
|
|
102
|
+
if (!credential) {
|
|
103
|
+
out('No ShieldFive connection configured. Vault tools are off; local tools work as before.')
|
|
104
|
+
return 0
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
out(`Configured from ${credential.source}: ${await describe(credential, env)}`)
|
|
108
|
+
} catch (err) {
|
|
109
|
+
out(`Configured from ${credential.source}, but ShieldFive refused it: ${err?.message ?? 'unknown error'}`)
|
|
110
|
+
return 1
|
|
111
|
+
}
|
|
112
|
+
return 0
|
|
113
|
+
}
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Opening a file's contents in memory. Nothing here writes plaintext, keys or
|
|
2
|
+
// ciphertext to disk; buffers are dropped when the call returns.
|
|
3
|
+
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
import { base64ToBytes } from '@shieldfive/crypto'
|
|
7
|
+
import { decryptToBytes as decryptV1 } from '@shieldfive/crypto/aes-gcm-v1'
|
|
8
|
+
import { decryptV0 } from '@shieldfive/crypto/legacy-v0'
|
|
9
|
+
import { decryptStreamPqHybridV1 } from '@shieldfive/crypto/streams/pq-hybrid-v1'
|
|
10
|
+
import { unwrapChainKey } from '@shieldfive/crypto/vault'
|
|
11
|
+
|
|
12
|
+
import { ToolError } from '../roots.mjs'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The key that opens a file's content, from the keys this grant holds:
|
|
16
|
+
* cipher_version 3 (suite 0x03): the combined key K, from the aux wrap under
|
|
17
|
+
* the parent folder key, or the grant's file_pq wrap for a root-level file;
|
|
18
|
+
* cipher_version 1/2: the content key under the parent folder key, or the
|
|
19
|
+
* grant's file wrap for a root-level file.
|
|
20
|
+
*/
|
|
21
|
+
export async function contentKey(file, view) {
|
|
22
|
+
const row = file.raw
|
|
23
|
+
if (row.folderId) {
|
|
24
|
+
const fk = view.folderKeys.get(row.folderId)
|
|
25
|
+
if (!fk) return null
|
|
26
|
+
if (row.cipherVersion === 3) {
|
|
27
|
+
if (!row.pqkFkWrapped) return null
|
|
28
|
+
return unwrapChainKey(fk, { wrapped: row.pqkFkWrapped, iv: row.pqkFkIv })
|
|
29
|
+
}
|
|
30
|
+
if (!row.cskWrapped) return null
|
|
31
|
+
return unwrapChainKey(fk, { wrapped: row.cskWrapped, iv: row.cskIv })
|
|
32
|
+
}
|
|
33
|
+
return (row.cipherVersion === 3 ? view.wraps.file_pq : view.wraps.file).get(row.id) ?? null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The key csk_wrapped holds (classical envelope key for v3) — what a move re-wraps. */
|
|
37
|
+
export async function classicalKey(file, view) {
|
|
38
|
+
const row = file.raw
|
|
39
|
+
if (!row.folderId) return view.wraps.file.get(row.id) ?? null
|
|
40
|
+
const fk = view.folderKeys.get(row.folderId)
|
|
41
|
+
if (!fk || !row.cskWrapped) return null
|
|
42
|
+
return unwrapChainKey(fk, { wrapped: row.cskWrapped, iv: row.cskIv })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function decryptContent(file, view, api, { maxBytes, signal }) {
|
|
46
|
+
const key = await contentKey(file, view)
|
|
47
|
+
if (!key) {
|
|
48
|
+
throw new ToolError(
|
|
49
|
+
'pending_owner_unlock',
|
|
50
|
+
'This file cannot be opened by this connection yet. Post-quantum files become ' +
|
|
51
|
+
'readable after the owner next opens ShieldFive on a device with their full keys.',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
// Ciphertext is a little larger than plaintext (header + per-chunk tags).
|
|
55
|
+
const ciphertext = await api.download(file.id, maxBytes + 1024 * 1024, signal)
|
|
56
|
+
const blob = new Blob([ciphertext])
|
|
57
|
+
const row = file.raw
|
|
58
|
+
let out
|
|
59
|
+
try {
|
|
60
|
+
if (row.cipherVersion === 3) {
|
|
61
|
+
const { plaintext } = decryptStreamPqHybridV1(blob.stream(), { combinedKey: key })
|
|
62
|
+
out = new Uint8Array(await new Response(plaintext).arrayBuffer())
|
|
63
|
+
} else if (row.cipherVersion === 2) {
|
|
64
|
+
out = await decryptV1({ blob, contentKey: key })
|
|
65
|
+
} else if (row.cipherVersion === 1) {
|
|
66
|
+
const plain = await decryptV0({
|
|
67
|
+
blob,
|
|
68
|
+
contentKey: key,
|
|
69
|
+
noncePrefix: base64ToBytes(row.cipherNoncePrefix),
|
|
70
|
+
chunkSize: row.cipherChunkSize,
|
|
71
|
+
})
|
|
72
|
+
out = new Uint8Array(await plain.arrayBuffer())
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
throw new ToolError(
|
|
76
|
+
'decrypt_failed',
|
|
77
|
+
'The file did not decrypt. It may be damaged or tampered with; nothing was changed.',
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
if (!out) {
|
|
81
|
+
throw new ToolError('unsupported', `Unsupported file format (cipher_version ${row.cipherVersion}).`)
|
|
82
|
+
}
|
|
83
|
+
if (out.length > maxBytes) throw new ToolError('too_large', 'File exceeds the size cap.')
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function sha256Hex(bytes) {
|
|
88
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
89
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Where the grant's connection string comes from, and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// A connection string holds the bearer token AND the grant secret that opens
|
|
4
|
+
// the grant's keys. It is looked up in this order:
|
|
5
|
+
//
|
|
6
|
+
// 1. SHIELDFIVE_GRANT in the environment — for CI and headless use. Anything
|
|
7
|
+
// that can read this process's environment can read it; say so in docs.
|
|
8
|
+
// 2. The OS keychain (macOS Keychain, Windows Credential Manager, the Secret
|
|
9
|
+
// Service on Linux), written by `npx @shieldfive/mcp login`.
|
|
10
|
+
//
|
|
11
|
+
// It is never written to a file by this package, never logged, and never
|
|
12
|
+
// included in a tool result or an error message. parseConnectionString's
|
|
13
|
+
// errors describe the shape, not the value.
|
|
14
|
+
|
|
15
|
+
import { parseConnectionString, VaultCryptoError } from '@shieldfive/crypto/vault'
|
|
16
|
+
|
|
17
|
+
export const KEYCHAIN_SERVICE = 'shieldfive-mcp'
|
|
18
|
+
export const KEYCHAIN_ACCOUNT = 'grant'
|
|
19
|
+
|
|
20
|
+
async function keychainEntry() {
|
|
21
|
+
const { Entry } = await import('@napi-rs/keyring')
|
|
22
|
+
return new Entry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readKeychain() {
|
|
26
|
+
try {
|
|
27
|
+
const entry = await keychainEntry()
|
|
28
|
+
return entry.getPassword() ?? null
|
|
29
|
+
} catch {
|
|
30
|
+
// No keychain on this machine (headless Linux without a Secret Service),
|
|
31
|
+
// or no entry: both mean "not configured here".
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function writeKeychain(connectionString) {
|
|
37
|
+
const entry = await keychainEntry()
|
|
38
|
+
entry.setPassword(connectionString)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function deleteKeychain() {
|
|
42
|
+
try {
|
|
43
|
+
const entry = await keychainEntry()
|
|
44
|
+
return entry.deletePassword()
|
|
45
|
+
} catch {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The configured grant, parsed, or null when none is configured. A malformed
|
|
52
|
+
* value is an error with a fixed message; the value itself never appears.
|
|
53
|
+
*/
|
|
54
|
+
export async function loadGrantCredential(env = process.env, readStore = readKeychain) {
|
|
55
|
+
const raw = env.SHIELDFIVE_GRANT?.trim() || (await readStore())?.trim() || null
|
|
56
|
+
if (!raw) return null
|
|
57
|
+
try {
|
|
58
|
+
return { ...parseConnectionString(raw), source: env.SHIELDFIVE_GRANT ? 'env' : 'keychain' }
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (err instanceof VaultCryptoError) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
'The configured ShieldFive connection string is not valid. Create a new ' +
|
|
63
|
+
'connection in ShieldFive → Settings → AI assistants and run ' +
|
|
64
|
+
'`npx @shieldfive/mcp login` again.',
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
throw err
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// A fixed pool of name-decryption workers with an in-memory cache keyed by the
|
|
2
|
+
// envelope itself: a renamed item has a new envelope and is decrypted again;
|
|
3
|
+
// an unchanged one never is. The cache dies with the process.
|
|
4
|
+
|
|
5
|
+
import { availableParallelism } from 'node:os'
|
|
6
|
+
import { Worker } from 'node:worker_threads'
|
|
7
|
+
|
|
8
|
+
export function createNamePool({ size = Math.max(1, Math.min(8, availableParallelism() - 1)) } = {}) {
|
|
9
|
+
const workers = []
|
|
10
|
+
const idle = []
|
|
11
|
+
const queue = []
|
|
12
|
+
const pending = new Map()
|
|
13
|
+
const cache = new Map()
|
|
14
|
+
let seq = 0
|
|
15
|
+
|
|
16
|
+
function startWorker() {
|
|
17
|
+
const w = new Worker(new URL('./nameWorker.mjs', import.meta.url))
|
|
18
|
+
w.unref()
|
|
19
|
+
w.on('message', ({ id, ok, name }) => {
|
|
20
|
+
const p = pending.get(id)
|
|
21
|
+
pending.delete(id)
|
|
22
|
+
p?.resolve(ok ? name : null)
|
|
23
|
+
idle.push(w)
|
|
24
|
+
pump()
|
|
25
|
+
})
|
|
26
|
+
// A crashed worker must not strand its job: fail that name (it shows as
|
|
27
|
+
// unavailable), drop the worker and start a replacement.
|
|
28
|
+
w.on('error', () => {})
|
|
29
|
+
w.on('exit', () => {
|
|
30
|
+
for (const [id, job] of pending) {
|
|
31
|
+
if (job.worker === w) {
|
|
32
|
+
pending.delete(id)
|
|
33
|
+
job.resolve(null)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const i = workers.indexOf(w)
|
|
37
|
+
if (i >= 0) workers.splice(i, 1)
|
|
38
|
+
const j = idle.indexOf(w)
|
|
39
|
+
if (j >= 0) idle.splice(j, 1)
|
|
40
|
+
if (!closing) {
|
|
41
|
+
startWorker()
|
|
42
|
+
pump()
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
workers.push(w)
|
|
46
|
+
idle.push(w)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pump() {
|
|
50
|
+
while (idle.length && queue.length) {
|
|
51
|
+
const w = idle.pop()
|
|
52
|
+
const job = queue.shift()
|
|
53
|
+
job.worker = w
|
|
54
|
+
pending.set(job.msg.id, job)
|
|
55
|
+
w.postMessage(job.msg)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decrypt one name. `key` is the parent folder key, or with direct: true the
|
|
61
|
+
* already-derived name key from a grant `name` wrap. Resolves to null when
|
|
62
|
+
* the envelope does not open — callers show it as unavailable, never guess.
|
|
63
|
+
*/
|
|
64
|
+
function decrypt({ raw, key, rowId, direct = false }) {
|
|
65
|
+
const cacheKey = `${rowId}|${raw}`
|
|
66
|
+
if (cache.has(cacheKey)) return Promise.resolve(cache.get(cacheKey))
|
|
67
|
+
if (!workers.length) for (let i = 0; i < size; i++) startWorker()
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const id = ++seq
|
|
70
|
+
queue.push({
|
|
71
|
+
msg: { id, raw, key, rowId, direct },
|
|
72
|
+
resolve: (name) => {
|
|
73
|
+
if (name !== null) cache.set(cacheKey, name)
|
|
74
|
+
resolve(name)
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
pump()
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let closing = false
|
|
82
|
+
async function close() {
|
|
83
|
+
closing = true
|
|
84
|
+
await Promise.all(workers.map((w) => w.terminate()))
|
|
85
|
+
workers.length = 0
|
|
86
|
+
idle.length = 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { decrypt, close, get cached() { return cache.size } }
|
|
90
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Worker for name decryption. Argon2id costs ~70 ms per name at the web app's
|
|
2
|
+
// "interactive" level, so names are opened on a small pool of threads. Keys
|
|
3
|
+
// arrive over the in-process message channel; nothing touches disk.
|
|
4
|
+
|
|
5
|
+
import { parentPort } from 'node:worker_threads'
|
|
6
|
+
|
|
7
|
+
import { decryptName, decryptNameWithKey, parseNameEnvelope } from '@shieldfive/crypto/vault'
|
|
8
|
+
|
|
9
|
+
parentPort.on('message', async ({ id, raw, key, rowId, direct }) => {
|
|
10
|
+
try {
|
|
11
|
+
const envelope = parseNameEnvelope(raw)
|
|
12
|
+
if (!envelope) {
|
|
13
|
+
parentPort.postMessage({ id, ok: false })
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
const name = direct
|
|
17
|
+
? await decryptNameWithKey({ envelope, nameKey: key, rowId })
|
|
18
|
+
: await decryptName({ envelope, folderKey: key, rowId })
|
|
19
|
+
parentPort.postMessage({ id, ok: true, name })
|
|
20
|
+
} catch {
|
|
21
|
+
parentPort.postMessage({ id, ok: false })
|
|
22
|
+
}
|
|
23
|
+
})
|