Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions src/ai/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -109,7 +109,7 @@ let userTinfoilClient: SecureClient | null = null
*/
const createSystemTinfoilClient = (cloudUrl: string): Promise<SecureClient> => {
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)
Expand Down Expand Up @@ -163,7 +163,7 @@ const evictSystemTinfoilClient = (): void => {
export const getTinfoilClient = async (): Promise<SecureClient> => {
if (!userTinfoilClient) {
const { SecureClient } = await import('tinfoil')
userTinfoilClient = new SecureClient()
userTinfoilClient = new SecureClient({ userCacheSecret: getUserCacheSecret() })
}
await userTinfoilClient.ready()
return userTinfoilClient
Expand Down
26 changes: 26 additions & 0 deletions src/lib/auth-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -47,13 +49,15 @@ beforeAll(() => {
beforeEach(() => {
clearAuthToken()
clearDeviceId()
clearUserCacheSecret()
})

// Mirror the beforeEach cleanup so the last test's token can't leak into the
// next test file (AuthProvider's mount effect fires get-session on any token).
afterEach(() => {
clearAuthToken()
clearDeviceId()
clearUserCacheSecret()
})

describe('auth-token', () => {
Expand Down Expand Up @@ -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')
Expand Down
28 changes: 28 additions & 0 deletions src/lib/auth-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -44,6 +45,33 @@ 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.
*
* 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)
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)
Comment thread
arienemaiara marked this conversation as resolved.
Dismissed
return secret
}
Comment on lines +59 to +68

/** 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).
Expand Down
3 changes: 2 additions & 1 deletion src/lib/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -70,6 +70,7 @@ export const clearLocalData = async (options?: ClearLocalDataOptions): Promise<v
if (clearAuth) {
clearAuthToken()
clearDeviceId()
clearUserCacheSecret()
// The iroh client secret is the bridge access credential (plaintext localStorage),
// so it must be wiped with the other local creds on every identity teardown.
clearIrohClientSecret()
Expand Down
3 changes: 2 additions & 1 deletion src/testing-library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { clearAuthToken, clearDeviceId } from '@/lib/auth-token'
import { clearAuthToken, clearDeviceId, clearUserCacheSecret } from '@/lib/auth-token'
import { clearMemoizeCache } from '@/lib/memoize'
import { installFakeTimers } from '@/test-utils/fake-timers'
import type { Clock } from '@sinonjs/fake-timers'
Expand Down Expand Up @@ -128,6 +128,7 @@ afterEach(() => {
// get-session call against the next file's HTTP client.
clearAuthToken()
clearDeviceId()
clearUserCacheSecret()
})

/**
Expand Down
Loading