@shieldfive/mcp 0.2.0 → 0.4.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.
@@ -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. Call vault_connect to ' +
82
+ 'connect again (it opens ShieldFive in the browser to authorize).',
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,150 @@
1
+ // `npx @shieldfive/mcp login [--paste] | logout | status`
2
+ //
3
+ // login opens ShieldFive in the browser, where the owner chooses the scope and
4
+ // clicks Authorize; the connection string comes back to this process over
5
+ // 127.0.0.1 (connect.mjs). `login --paste` reads it from the terminal instead,
6
+ // without echoing it. Either way it is checked against the server (so a
7
+ // revoked grant is caught now, not in the middle of a conversation) and stored
8
+ // in the OS keychain. Nothing is written to a file and the value is never
9
+ // printed.
10
+
11
+ import { parseConnectionString } from '@shieldfive/crypto/vault'
12
+
13
+ import { createVaultApi, DEFAULT_API_URL } from './api.mjs'
14
+ import { openBrowser, startConnectFlow } from './connect.mjs'
15
+ import { deleteKeychain, loadGrantCredential, writeKeychain } from './credential.mjs'
16
+
17
+ const out = (s) => process.stderr.write(`${s}\n`)
18
+
19
+ function readHidden(prompt) {
20
+ return new Promise((resolve, reject) => {
21
+ const stdin = process.stdin
22
+ if (!stdin.isTTY) {
23
+ let data = ''
24
+ stdin.setEncoding('utf8')
25
+ stdin.on('data', (c) => (data += c))
26
+ stdin.on('end', () => resolve(data.trim()))
27
+ stdin.on('error', reject)
28
+ return
29
+ }
30
+ process.stderr.write(prompt)
31
+ let value = ''
32
+ stdin.setRawMode(true)
33
+ stdin.resume()
34
+ stdin.setEncoding('utf8')
35
+ const onData = (chunk) => {
36
+ for (const ch of chunk) {
37
+ const code = ch.charCodeAt(0)
38
+ if (ch === '\r' || ch === '\n') {
39
+ stdin.setRawMode(false)
40
+ stdin.pause()
41
+ stdin.off('data', onData)
42
+ process.stderr.write('\n')
43
+ resolve(value.trim())
44
+ return
45
+ }
46
+ if (code === 3) {
47
+ stdin.setRawMode(false)
48
+ process.stderr.write('\n')
49
+ reject(new Error('cancelled'))
50
+ return
51
+ }
52
+ if (code === 127 || code === 8) value = value.slice(0, -1)
53
+ else if (code >= 32) value += ch
54
+ }
55
+ }
56
+ stdin.on('data', onData)
57
+ })
58
+ }
59
+
60
+ export function describeGrant(grant) {
61
+ const scope = grant.scopeAll ? 'whole vault' : `${grant.scopeFolderIds.length} folder(s)`
62
+ return `connection ${grant.id.slice(0, 8)}…: ${grant.scopes.join(' + ')}, ${scope}, expires ${grant.expiresAt}`
63
+ }
64
+
65
+ async function describe(credential, env) {
66
+ const api = createVaultApi({ credential, baseUrl: env.SHIELDFIVE_API_URL || DEFAULT_API_URL })
67
+ const { grant } = await api.grant()
68
+ return describeGrant(grant)
69
+ }
70
+
71
+ async function authorizeInBrowser(env, open) {
72
+ const flow = await startConnectFlow({ baseUrl: env.SHIELDFIVE_API_URL || DEFAULT_API_URL, client: 'other' })
73
+ const opened = await open(flow.url)
74
+ out(
75
+ opened
76
+ ? 'Opened ShieldFive in your browser. Choose what the assistant may reach and click Authorize.'
77
+ : 'Open this link in your browser, choose what the assistant may reach and click Authorize:',
78
+ )
79
+ out(` ${flow.url}`)
80
+ out('Waiting (up to 10 minutes; Ctrl+C to stop)…')
81
+ return flow.result
82
+ }
83
+
84
+ export async function runCli(command, env = process.env, args = [], { open = openBrowser } = {}) {
85
+ if (command === 'login') {
86
+ let raw
87
+ if (args.includes('--paste')) {
88
+ raw = await readHidden('Paste the ShieldFive connection string (input hidden): ')
89
+ } else {
90
+ try {
91
+ raw = await authorizeInBrowser(env, open)
92
+ } catch (err) {
93
+ out(
94
+ err?.code === 'cancelled'
95
+ ? 'The request was denied in ShieldFive. Nothing was connected.'
96
+ : 'Nobody authorized the connection in time. Run the command again, or use --paste.',
97
+ )
98
+ return 1
99
+ }
100
+ }
101
+ let credential
102
+ try {
103
+ credential = parseConnectionString(raw)
104
+ } catch {
105
+ out('That is not a ShieldFive connection string. Copy it again from Settings → AI assistants.')
106
+ return 1
107
+ }
108
+ try {
109
+ out(`Checking with ShieldFive… ${await describe(credential, env)}`)
110
+ } catch (err) {
111
+ out(`ShieldFive did not accept it: ${err?.message ?? 'unknown error'}`)
112
+ return 1
113
+ }
114
+ try {
115
+ await writeKeychain(raw)
116
+ } catch {
117
+ out(
118
+ 'No system keychain is available here. Set SHIELDFIVE_GRANT in the MCP server’s ' +
119
+ 'environment instead (anything that can read that environment can read the connection).',
120
+ )
121
+ return 1
122
+ }
123
+ out('Saved to the system keychain. Restart your AI assistant to pick it up.')
124
+ return 0
125
+ }
126
+ if (command === 'logout') {
127
+ const removed = await deleteKeychain()
128
+ out(removed ? 'Removed the connection from the system keychain.' : 'No connection was stored in the keychain.')
129
+ out('To cut off access everywhere, revoke the connection in ShieldFive → Settings → AI assistants.')
130
+ return 0
131
+ }
132
+ if (command === 'status') {
133
+ const credential = await loadGrantCredential(env).catch((e) => {
134
+ out(e.message)
135
+ return null
136
+ })
137
+ if (!credential) {
138
+ out('No ShieldFive connection configured. Vault tools are off; local tools work as before.')
139
+ return 0
140
+ }
141
+ try {
142
+ out(`Configured from ${credential.source}: ${await describe(credential, env)}`)
143
+ } catch (err) {
144
+ out(`Configured from ${credential.source}, but ShieldFive refused it: ${err?.message ?? 'unknown error'}`)
145
+ return 1
146
+ }
147
+ return 0
148
+ }
149
+ return null
150
+ }
@@ -0,0 +1,227 @@
1
+ // Browser authorization: the connection string arrives without anyone copying it.
2
+ //
3
+ // This process listens on a random port on 127.0.0.1 and opens
4
+ //
5
+ // https://shieldfive.com/files?settings=agents&connect=<state>&port=<port>&client=<hint>
6
+ //
7
+ // The owner signs in, unlocks, chooses the scope and clicks Authorize. The page
8
+ // creates the grant in the browser as it always does, then submits a form to
9
+ // http://127.0.0.1:<port>/callback carrying the state and the connection
10
+ // string. The page builds that address itself from the port; it accepts no
11
+ // callback URL, so a link someone else crafted can only deliver to the machine
12
+ // the browser runs on.
13
+ //
14
+ // What this listener accepts, and nothing else: one POST to /callback, Host
15
+ // exactly 127.0.0.1:<port> (defeats DNS rebinding), no Origin other than
16
+ // ShieldFive's (a POST from any other site is refused before its body is read),
17
+ // and a state that matches in constant time. The first valid delivery closes
18
+ // the listener.
19
+
20
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
21
+ import { spawn } from 'node:child_process'
22
+ import { createServer } from 'node:http'
23
+
24
+ import { parseConnectionString } from '@shieldfive/crypto/vault'
25
+
26
+ const MAX_BODY = 16 * 1024
27
+ export const CONNECT_TIMEOUT_MS = 10 * 60 * 1000
28
+
29
+ export const CLIENT_HINTS = ['claude-desktop', 'claude-code', 'cursor', 'chatgpt', 'local', 'other']
30
+
31
+ /** Map an MCP clientInfo.name to the hint the settings page understands. */
32
+ export function clientHintFor(name) {
33
+ const n = String(name ?? '').toLowerCase()
34
+ if (n.includes('claude-code') || n.includes('claude code')) return 'claude-code'
35
+ if (n.includes('claude')) return 'claude-desktop'
36
+ if (n.includes('cursor')) return 'cursor'
37
+ if (n.includes('chatgpt') || n.includes('openai')) return 'chatgpt'
38
+ return 'other'
39
+ }
40
+
41
+ /** Open a URL in the default browser without a shell. Resolves false on failure. */
42
+ export function openBrowser(url, platform = process.platform, spawnImpl = spawn) {
43
+ const [cmd, args] =
44
+ platform === 'darwin'
45
+ ? ['open', [url]]
46
+ : platform === 'win32'
47
+ ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
48
+ : ['xdg-open', [url]]
49
+ return new Promise((resolve) => {
50
+ try {
51
+ const child = spawnImpl(cmd, args, { stdio: 'ignore', detached: true })
52
+ child.on('error', () => resolve(false))
53
+ child.on('spawn', () => {
54
+ child.unref()
55
+ resolve(true)
56
+ })
57
+ } catch {
58
+ resolve(false)
59
+ }
60
+ })
61
+ }
62
+
63
+ const PAGE_CSP = "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'"
64
+
65
+ function page(title, body) {
66
+ return (
67
+ '<!doctype html><html lang="en"><head><meta charset="utf-8">' +
68
+ '<meta name="viewport" content="width=device-width,initial-scale=1">' +
69
+ `<title>${title}</title><style>` +
70
+ 'body{font:16px/1.5 system-ui,sans-serif;margin:0;display:grid;place-items:center;min-height:100vh;' +
71
+ 'background:#f7f7f8;color:#111}main{max-width:32rem;padding:2rem}h1{font-size:1.4rem;margin:0 0 .5rem}' +
72
+ '@media (prefers-color-scheme:dark){body{background:#111;color:#eee}}' +
73
+ `</style></head><body><main><h1>${title}</h1><p>${body}</p></main></body></html>`
74
+ )
75
+ }
76
+
77
+ function send(res, status, title, body, onSent) {
78
+ res.writeHead(status, {
79
+ 'content-type': 'text/html; charset=utf-8',
80
+ 'cache-control': 'no-store',
81
+ 'content-security-policy': PAGE_CSP,
82
+ 'referrer-policy': 'no-referrer',
83
+ 'x-content-type-options': 'nosniff',
84
+ })
85
+ res.end(page(title, body), onSent)
86
+ }
87
+
88
+ function sameSecret(a, b) {
89
+ const x = Buffer.from(String(a))
90
+ const y = Buffer.from(String(b))
91
+ return x.length === y.length && timingSafeEqual(x, y)
92
+ }
93
+
94
+ export class ConnectError extends Error {
95
+ constructor(code, message) {
96
+ super(message)
97
+ this.name = 'ConnectError'
98
+ this.code = code
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Start listening and build the authorization URL. `result` resolves with the
104
+ * raw connection string, or rejects with a ConnectError (cancelled, timeout).
105
+ */
106
+ export async function startConnectFlow({
107
+ baseUrl,
108
+ client = 'other',
109
+ timeoutMs = CONNECT_TIMEOUT_MS,
110
+ onDelivered,
111
+ } = {}) {
112
+ const origin = new URL(baseUrl).origin
113
+ const state = randomBytes(32).toString('base64url')
114
+ let settle
115
+ const result = new Promise((resolve, reject) => {
116
+ settle = { resolve, reject }
117
+ })
118
+ // A flow can end (timeout) with nobody awaiting it; that is not a crash.
119
+ result.catch(() => {})
120
+ let done = false
121
+ let port = 0
122
+
123
+ const server = createServer((req, res) => {
124
+ if (req.headers.host !== `127.0.0.1:${port}`) {
125
+ send(res, 400, 'Not here', 'This address only accepts ShieldFive connections.')
126
+ return
127
+ }
128
+ const path = new URL(req.url ?? '/', `http://127.0.0.1:${port}`).pathname
129
+ if (path !== '/callback' || req.method !== 'POST') {
130
+ send(res, 404, 'Not found', 'Return to your assistant.')
131
+ return
132
+ }
133
+ // Browsers send ShieldFive's origin here; a few send "null" for a
134
+ // navigation to plain http. Any OTHER site's origin is refused outright;
135
+ // the 256-bit state is what actually authenticates the delivery.
136
+ const from = req.headers.origin
137
+ if (from !== undefined && from !== origin && from !== 'null') {
138
+ send(res, 403, 'Refused', 'This connection did not come from ShieldFive.')
139
+ return
140
+ }
141
+ if (done) {
142
+ send(res, 409, 'Already connected', 'This request has already been used. Return to your assistant.')
143
+ return
144
+ }
145
+ let size = 0
146
+ const chunks = []
147
+ req.on('data', (c) => {
148
+ size += c.length
149
+ if (size > MAX_BODY) req.destroy()
150
+ else chunks.push(c)
151
+ })
152
+ req.on('end', () => {
153
+ const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'))
154
+ if (!sameSecret(form.get('state') ?? '', state)) {
155
+ send(res, 403, 'Refused', 'This connection was meant for a different request.')
156
+ return
157
+ }
158
+ if (form.get('error')) {
159
+ done = true
160
+ send(res, 200, 'Request denied', 'No connection was created. You can close this tab.', finish)
161
+ settle.reject(new ConnectError('cancelled', 'The owner denied the connection request.'))
162
+ return
163
+ }
164
+ const raw = (form.get('connection_string') ?? '').trim()
165
+ try {
166
+ parseConnectionString(raw)
167
+ } catch {
168
+ send(res, 400, 'Something went wrong', 'The connection could not be read. Try connecting again.')
169
+ return
170
+ }
171
+ done = true
172
+ send(
173
+ res,
174
+ 200,
175
+ 'Connected',
176
+ 'Your assistant can now reach the folders you chose. You can close this tab and go back to it. ' +
177
+ 'Revoke the connection any time in ShieldFive → Settings → AI assistants.',
178
+ finish,
179
+ )
180
+ onDelivered?.()
181
+ settle.resolve(raw)
182
+ })
183
+ })
184
+ server.headersTimeout = 10_000
185
+ server.requestTimeout = 15_000
186
+
187
+ await new Promise((resolve, reject) => {
188
+ server.once('error', reject)
189
+ server.listen(0, '127.0.0.1', resolve)
190
+ })
191
+ port = server.address().port
192
+
193
+ const timer = setTimeout(() => {
194
+ if (done) return
195
+ done = true
196
+ finish()
197
+ settle.reject(new ConnectError('timeout', 'Nobody authorized the connection in time.'))
198
+ }, timeoutMs)
199
+ timer.unref?.()
200
+
201
+ function finish() {
202
+ clearTimeout(timer)
203
+ server.close()
204
+ server.closeAllConnections?.()
205
+ }
206
+
207
+ // Settings is an overlay on /files opened with ?settings=<section>; there is
208
+ // no page at /files/settings/agents (opening one is how this first shipped,
209
+ // and it 404s).
210
+ const url = new URL('/files', origin)
211
+ url.searchParams.set('settings', 'agents')
212
+ url.searchParams.set('connect', state)
213
+ url.searchParams.set('port', String(port))
214
+ url.searchParams.set('client', CLIENT_HINTS.includes(client) ? client : 'other')
215
+
216
+ return {
217
+ url: url.toString(),
218
+ port,
219
+ result,
220
+ cancel() {
221
+ if (done) return
222
+ done = true
223
+ finish()
224
+ settle.reject(new ConnectError('cancelled', 'Connection request cancelled.'))
225
+ },
226
+ }
227
+ }
@@ -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
+ }