From 411ce841ee09e5df18554f825f636e3237a7f5c9 Mon Sep 17 00:00:00 2001 From: Ariene Maiara Ribeiro Date: Fri, 24 Jul 2026 14:06:57 -0300 Subject: [PATCH 1/2] feat(THU-708): upgrade tinfoil and enable prompt caching - bump tinfoil 1.1.4 -> 1.1.11 for userCacheSecret support - generate a per-device cache secret in localStorage; it only reaches the attested enclave, never our backend - pass the secret to both system and user SecureClient instances - clear the secret on identity teardown so a new cache namespace starts --- bun.lock | 23 ++++++++++++++++------- package.json | 2 +- src/ai/fetch.ts | 6 +++--- src/lib/auth-token.test.ts | 26 ++++++++++++++++++++++++++ src/lib/auth-token.ts | 22 ++++++++++++++++++++++ src/lib/cleanup.ts | 3 ++- src/testing-library.ts | 3 ++- 7 files changed, 72 insertions(+), 13 deletions(-) diff --git a/bun.lock b/bun.lock index 5de979170..286b1e285 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "thunderbolt", @@ -99,7 +98,7 @@ "remark-parse": "^11.0.0", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", - "tinfoil": "^1.1.4", + "tinfoil": "^1.1.11", "unified": "^11.0.5", "uuid": "^14.0.0", "web-haptics": "^0.0.6", @@ -440,7 +439,7 @@ "@freedomofpress/crypto-browser": ["@freedomofpress/crypto-browser@0.1.7", "", { "dependencies": { "@noble/curves": "^1.6.0" } }, "sha512-zjWmZDKdAu8g0Zq1IjBQ+sKQ/NpfzStBDFjy/qHUSMVEL4wNlNGtA7lhtw8v8asXa0yqF2QTYQ3rq6xCTQeADw=="], - "@freedomofpress/sigstore-browser": ["@freedomofpress/sigstore-browser@0.1.13", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/tuf-browser": "^0.1.11", "@noble/curves": "^2.0.1" } }, "sha512-3YfmP9JQ5h8CAO/vKJEGYrFdzss6xWxYi5ZkbR4KoHlxIaLGrnu+5TQDoZVWhuhyfDBdLQqj6ypdN0W6PsH3Jw=="], + "@freedomofpress/sigstore-browser": ["@freedomofpress/sigstore-browser@0.1.14", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/tuf-browser": "^0.1.11", "@noble/curves": "^2.0.1" } }, "sha512-1dqc7HojiBcr/sJSAXjBwNz4+WGeeAJP8ENQnDxm+idVV0/YIxpfwXRNSJdNw6EXJuHviq/5kSPEBCxamPcrpQ=="], "@freedomofpress/tuf-browser": ["@freedomofpress/tuf-browser@0.1.11", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7" } }, "sha512-d76ohB/AS5+zI+lnbiFMX/BIK3nT18hZ8q3Na6X4vyYaSYjXkeknzhAnbx1da+8ObWLwsaZLue46haEch28qtQ=="], @@ -1014,7 +1013,7 @@ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], - "@tinfoilsh/verifier": ["@tinfoilsh/verifier@1.1.4", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/sigstore-browser": "^0.1.11", "@freedomofpress/tuf-browser": "^0.1.8" } }, "sha512-fcO0shcAIPBUG6xrWifsSF01motVa4fpma820FbgTtHzuaivoXzfeArylDXWlRcd+KWxn0wipVj0OCyjFMOr7w=="], + "@tinfoilsh/verifier": ["@tinfoilsh/verifier@1.1.11", "", { "dependencies": { "@freedomofpress/crypto-browser": "^0.1.7", "@freedomofpress/sigstore-browser": "^0.1.14", "@freedomofpress/tuf-browser": "^0.1.11" } }, "sha512-XMIG8kuPYotJMMGmPCXF/uMc/CI5N8cgjw5KdPCvnKeuF71b7UaXtapU+M/4Ss4QcAgN87wbXKi0vq0hFdL43A=="], "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], @@ -1136,6 +1135,8 @@ "@webcontainer/env": ["@webcontainer/env@1.1.1", "", {}, "sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng=="], + "@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="], + "@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], @@ -1410,7 +1411,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "ehbp": ["ehbp@0.2.0", "", { "dependencies": { "@panva/hpke-noble": "^1.0.3", "hpke": "^1.0.1" } }, "sha512-hYkeupwvY0S1h9RpcrCD8pAgc6yfxfMqbI0y6khtS+ZNEByZAf116LTA6aBFB2JJQFsUaOEO7convZVrVhyeZA=="], + "ehbp": ["ehbp@0.3.0", "", { "dependencies": { "@panva/hpke-noble": "^1.0.3", "hpke": "^1.0.1" } }, "sha512-emLvsu3aeE1LXHvJASMXgJWxw9AuXVLPA4zkyujVwlqlfogBh9Gdd+tPH7nQJw5JfVwfZPU3W2oA5UuihMUIMg=="], "electron-to-chromium": ["electron-to-chromium@1.5.360", "", {}, "sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA=="], @@ -2458,7 +2459,7 @@ "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], - "tinfoil": ["tinfoil@1.1.4", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.26", "@freedomofpress/sigstore-browser": "^0.1.11", "@tinfoilsh/verifier": "1.1.4", "ehbp": "^0.2.0", "openai": "^6.17.0" }, "peerDependencies": { "ai": "^6.0.69" }, "optionalPeers": ["ai"] }, "sha512-Ris0RaWb5uu+N02CX0LPhi4LwUiMoyEGUpxTF6CsTWWz6ThT9Z7P1VXoXwLwriuJnzePh/cwRA71UCULwoEmJg=="], + "tinfoil": ["tinfoil@1.1.11", "", { "dependencies": { "@ai-sdk/openai-compatible": "^3.0.7", "@freedomofpress/sigstore-browser": "^0.1.14", "@tinfoilsh/verifier": "1.1.11", "@types/ws": "^8.18.1", "ehbp": "^0.3.0", "openai": "^6.46.0", "ws": "^8.21.0" }, "peerDependencies": { "ai": "^6.0.168 || ^7.0.0" }, "optionalPeers": ["ai"] }, "sha512-ewGmQzGdX5ajjCM1ZvhHiCiPdGhKWf1+EP71MC3LCLwrdkWTrNIiTSFJcr9MqvYj7agofeFoPYL+hJIx5tJwsw=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -2844,7 +2845,11 @@ "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "tinfoil/openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="], + "tinfoil/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@3.0.14", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.12" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nUl7dBHUDTqcnuWGnEbKMwDDqL94BxRfibLwYv8uWp+kZn8Zqdxy7tUBt4sv2hpHQi+S2P6bUyqW18xmbykErw=="], + + "tinfoil/openai": ["openai@6.48.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-KhVp+FyV50QrXNextvL9hIU5l6ox5HYuKQjGVk7lIqprgJol90+dQXWONV6S1lRWsKA1bXjrow8RsUT14M1hNA=="], + + "tinfoil/ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], "tsx/esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], @@ -2972,6 +2977,10 @@ "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "tinfoil/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@4.0.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw=="], + + "tinfoil/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.12", "", { "dependencies": { "@ai-sdk/provider": "4.0.3", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], diff --git a/package.json b/package.json index 10e951f2d..667ba43cc 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,7 @@ "remark-parse": "^11.0.0", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", - "tinfoil": "^1.1.4", + "tinfoil": "^1.1.11", "unified": "^11.0.5", "uuid": "^14.0.0", "web-haptics": "^0.0.6", diff --git a/src/ai/fetch.ts b/src/ai/fetch.ts index edc62e71e..87b6a3c8b 100644 --- a/src/ai/fetch.ts +++ b/src/ai/fetch.ts @@ -21,7 +21,7 @@ import { getLocalSetting } from '@/stores/local-settings-store' import { hydrateAttachmentsAsFileParts } from '@/lib/attachments' import { hydrateQuotesAsText } from '@/lib/quotes' import { isSsoMode } from '@/lib/auth-mode' -import { getAuthToken } from '@/lib/auth-token' +import { getAuthToken, getUserCacheSecret } from '@/lib/auth-token' import { fetch as baseFetch } from '@/lib/fetch' import { isLoopbackHost } from '@/lib/mcp-url-validation' import { normalizeOpenAiBaseUrl } from '@/lib/openai-base-url' @@ -109,7 +109,7 @@ let userTinfoilClient: SecureClient | null = null */ const createSystemTinfoilClient = (cloudUrl: string): Promise => { const clientPromise = import('tinfoil').then( - ({ SecureClient }) => new SecureClient({ baseURL: `${cloudUrl}/tinfoil` }), + ({ SecureClient }) => new SecureClient({ baseURL: `${cloudUrl}/tinfoil`, userCacheSecret: getUserCacheSecret() }), ) void clientPromise.catch(() => systemTinfoilClients.delete(cloudUrl)) systemTinfoilClients.set(cloudUrl, clientPromise) @@ -163,7 +163,7 @@ const evictSystemTinfoilClient = (): void => { export const getTinfoilClient = async (): Promise => { if (!userTinfoilClient) { const { SecureClient } = await import('tinfoil') - userTinfoilClient = new SecureClient() + userTinfoilClient = new SecureClient({ userCacheSecret: getUserCacheSecret() }) } await userTinfoilClient.ready() return userTinfoilClient diff --git a/src/lib/auth-token.test.ts b/src/lib/auth-token.test.ts index 3b9a9cd20..974af035a 100644 --- a/src/lib/auth-token.test.ts +++ b/src/lib/auth-token.test.ts @@ -6,9 +6,11 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bu import { clearAuthToken, clearDeviceId, + clearUserCacheSecret, getAuthenticatedHeaders, getAuthToken, getDeviceId, + getUserCacheSecret, onAuthTokenChangedInOtherTab, setAuthToken, } from './auth-token' @@ -47,6 +49,7 @@ beforeAll(() => { beforeEach(() => { clearAuthToken() clearDeviceId() + clearUserCacheSecret() }) // Mirror the beforeEach cleanup so the last test's token can't leak into the @@ -54,6 +57,7 @@ beforeEach(() => { afterEach(() => { clearAuthToken() clearDeviceId() + clearUserCacheSecret() }) describe('auth-token', () => { @@ -99,6 +103,28 @@ describe('auth-token', () => { }) }) + describe('getUserCacheSecret', () => { + it('returns 64-char hex', () => { + expect(getUserCacheSecret()).toMatch(/^[0-9a-f]{64}$/) + }) + + it('returns the same secret across calls', () => { + expect(getUserCacheSecret()).toBe(getUserCacheSecret()) + }) + + it('differs from the device ID', () => { + expect(getUserCacheSecret()).not.toBe(getDeviceId()) + }) + + it('generates a new secret after clearUserCacheSecret', () => { + const first = getUserCacheSecret() + clearUserCacheSecret() + const second = getUserCacheSecret() + expect(second).toMatch(/^[0-9a-f]{64}$/) + expect(second).not.toBe(first) + }) + }) + describe('getAuthenticatedHeaders', () => { it('returns Authorization, X-Device-ID, and X-Device-Name when token and device ID exist', () => { setAuthToken('my-token') diff --git a/src/lib/auth-token.ts b/src/lib/auth-token.ts index 9a176a0c7..8aaa5d529 100644 --- a/src/lib/auth-token.ts +++ b/src/lib/auth-token.ts @@ -15,6 +15,7 @@ import { getDeviceDisplayName } from '@/lib/platform' const deviceIdKey = 'thunderbolt_device_id' const authTokenKey = 'thunderbolt_auth_token' +const userCacheSecretKey = 'thunderbolt_user_cache_secret' /** Get or create device_id (from localStorage). */ export const getDeviceId = (): string => { @@ -44,6 +45,27 @@ export const clearDeviceId = (): void => { localStorage.removeItem(deviceIdKey) } +/** + * Get or create the Tinfoil prompt-cache secret (from localStorage). + * Per-device, never synced (THU-708). Distinct from the device ID: it must + * only reach the attested enclave, never our backend. + */ +export const getUserCacheSecret = (): string => { + const existing = localStorage.getItem(userCacheSecretKey) + if (existing) { + return existing + } + const bytes = crypto.getRandomValues(new Uint8Array(32)) + const secret = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') + localStorage.setItem(userCacheSecretKey, secret) + return secret +} + +/** Clear the prompt-cache secret (identity teardown — forces a new cache namespace). */ +export const clearUserCacheSecret = (): void => { + localStorage.removeItem(userCacheSecretKey) +} + /** * Build authenticated headers (Authorization + device identity). * Single source of truth for callers that cannot use the HTTP client (e.g. PowerSync connector). diff --git a/src/lib/cleanup.ts b/src/lib/cleanup.ts index 27cc8818d..97197cd3c 100644 --- a/src/lib/cleanup.ts +++ b/src/lib/cleanup.ts @@ -5,7 +5,7 @@ import { disposeAllAdapters } from '@/acp/adapter-cache' import { clearIrohClientSecret } from '@/acp/iroh/iroh-transport' import { setSyncEnabled } from '@/db/powersync/sync-state' -import { clearAuthToken, clearDeviceId } from '@/lib/auth-token' +import { clearAuthToken, clearDeviceId, clearUserCacheSecret } from '@/lib/auth-token' import { resetAppDir } from '@/lib/fs' import { clearCachedSession } from '@/lib/session-cache' import { handleFullWipe } from '@/services/encryption' @@ -70,6 +70,7 @@ export const clearLocalData = async (options?: ClearLocalDataOptions): Promise { // get-session call against the next file's HTTP client. clearAuthToken() clearDeviceId() + clearUserCacheSecret() }) /** From 1c90e4eaa2007df33d6d3a3e6432c1c857fb55ed Mon Sep 17 00:00:00 2001 From: Ariene Maiara Ribeiro Date: Fri, 24 Jul 2026 14:12:50 -0300 Subject: [PATCH 2/2] docs(THU-708): explain plain localStorage choice for cache secret - the store already holds the bearer token, which grants more access - SDK needs the plaintext string, so a wrapping key adds no protection --- src/lib/auth-token.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/auth-token.ts b/src/lib/auth-token.ts index 8aaa5d529..f7120263a 100644 --- a/src/lib/auth-token.ts +++ b/src/lib/auth-token.ts @@ -49,6 +49,12 @@ export const clearDeviceId = (): void => { * Get or create the Tinfoil prompt-cache secret (from localStorage). * Per-device, never synced (THU-708). Distinct from the device ID: it must * only reach the attested enclave, never our backend. + * + * Plain localStorage is deliberate: the same store holds the bearer token, + * which grants full account access — strictly more than a cache-namespace + * key. The SDK needs the plaintext string, so a client-side wrapping key + * would add no protection (it would live in the same origin storage). Moves + * to encrypted storage together with the auth token (see file TODO). */ export const getUserCacheSecret = (): string => { const existing = localStorage.getItem(userCacheSecretKey)