diff --git a/.changeset/passkey-prf-byte-arrays.md b/.changeset/passkey-prf-byte-arrays.md new file mode 100644 index 000000000..6d51f081e --- /dev/null +++ b/.changeset/passkey-prf-byte-arrays.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-client': patch +--- + +Accept validated 32-byte arrays from passkey providers such as 1Password before deriving the BYOK encryption key. Preserve the key bytes and reject malformed arrays before saving. diff --git a/docs/advanced/byok.md b/docs/advanced/byok.md index 96419242e..12f89c908 100644 --- a/docs/advanced/byok.md +++ b/docs/advanced/byok.md @@ -40,6 +40,8 @@ Passkey storage runs a WebAuthn prompt to save and to unlock a key. Some browser This activation check applies to existing keyrings. First-time registration can require a second PRF prompt, which the browser handles even if registration consumes activation. +Passkey storage accepts 32-byte PRF arrays from password managers such as 1Password and converts them to binary data without changing the encryption key. + ## 2. Save a key Call `byok.update("openai", value)` from your own UI. The library does not ship a dialog. diff --git a/packages/ai-client/src/byok/passkey.ts b/packages/ai-client/src/byok/passkey.ts index 3b1e8cd18..7ff4d4545 100644 --- a/packages/ai-client/src/byok/passkey.ts +++ b/packages/ai-client/src/byok/passkey.ts @@ -83,6 +83,22 @@ export function isPasskeyStorageSupported(): boolean { export async function deriveAesKey( prfOutput: BufferSource, ): Promise { + // Some passkey providers return a plain byte array instead of BufferSource. + if (Array.isArray(prfOutput)) { + if ( + prfOutput.length !== 32 || + !Array.from(prfOutput).every( + (byte: unknown) => + typeof byte === 'number' && + Number.isInteger(byte) && + byte >= 0 && + byte <= 255, + ) + ) { + throw new Error('Invalid passkey PRF byte array') + } + prfOutput = new Uint8Array(prfOutput) + } const base = await crypto.subtle.importKey('raw', prfOutput, 'HKDF', false, [ 'deriveKey', ]) diff --git a/packages/ai-client/tests/byok-passkey-ceremony.test.ts b/packages/ai-client/tests/byok-passkey-ceremony.test.ts index 41501e156..5325f86aa 100644 --- a/packages/ai-client/tests/byok-passkey-ceremony.test.ts +++ b/packages/ai-client/tests/byok-passkey-ceremony.test.ts @@ -6,15 +6,17 @@ afterEach(() => vi.unstubAllGlobals()) function mockBrowser( existing: unknown, registrationConsumesActivation = false, + prfOutput: unknown = new Uint8Array(32), + registrationHasResult = false, ) { const activation = { isActive: true } class Passkey { rawId = new Uint8Array([1, 2, 3]).buffer constructor(private registration: boolean) {} getClientExtensionResults() { - return this.registration + return this.registration && !registrationHasResult ? { prf: { enabled: true } } - : { prf: { results: { first: new Uint8Array(32) } } } + : { prf: { enabled: true, results: { first: prfOutput } } } } } const create = vi.fn(async () => { @@ -78,3 +80,42 @@ it('allows the PRF follow-up after registration consumes activation', async () = expect(get).toHaveBeenCalledTimes(1) expect(put).toHaveBeenCalledTimes(1) }) + +it.each([true, false])( + 'saves array PRF bytes and unlocks with native bytes (registration result: %s)', + async (registrationHasResult) => { + const bytes = Array.from({ length: 32 }, (_, i) => (i === 31 ? 255 : i)) + const { put } = mockBrowser(null, false, bytes, registrationHasResult) + await passkeyStorage().save({ openai: 'sk-test-secret' }) + const record = put.mock.calls[0]?.[0] + expect(record).toBeDefined() + mockBrowser(record, false, new Uint8Array(bytes)) + await expect(passkeyStorage().load()).resolves.toEqual({ + openai: 'sk-test-secret', + }) + mockBrowser(record, false, bytes) + await expect(passkeyStorage().load()).resolves.toEqual({ + openai: 'sk-test-secret', + }) + }, +) + +it.each( + [ + [], + Array(32), + Array(31).fill(0), + Array(33).fill(0), + Array(32).fill(-1), + Array(32).fill(256), + Array(32).fill(0.5), + Array(32).fill('1'), + Array(32).fill(NaN), + ].map((value) => [value]), +)('rejects invalid array PRF output %# before saving', async (value) => { + const { put } = mockBrowser(null, false, value, true) + await expect( + passkeyStorage().save({ openai: 'sk-test-secret' }), + ).rejects.toThrow('Invalid passkey PRF byte array') + expect(put).not.toHaveBeenCalled() +}) diff --git a/testing/e2e/tests/byok.spec.ts b/testing/e2e/tests/byok.spec.ts index 4e008b391..773c15544 100644 --- a/testing/e2e/tests/byok.spec.ts +++ b/testing/e2e/tests/byok.spec.ts @@ -31,7 +31,7 @@ test.describe('byok', () => { getClientExtensionResults() { return this.registration ? { prf: { enabled: true } } - : { prf: { results: { first: new Uint8Array(32) } } } + : { prf: { results: { first: Array.from(new Uint8Array(32)) } } } } } Object.defineProperty(window, 'PublicKeyCredential', { value: Passkey }) @@ -53,6 +53,12 @@ test.describe('byok', () => { await expect(page.getByTestId('byok-last4')).toHaveText('1234') await page.reload() await expect(page.getByTestId('byok-last4')).toHaveText('1234') + await page.getByTestId('byok-unlock-button').click() + await page.getByTestId('byok-key-input').fill('sk-e2e-updated-5678') + await page.getByTestId('byok-save-button').click() + await expect(page.getByTestId('byok-last4')).toHaveText('5678') + await page.reload() + await expect(page.getByTestId('byok-last4')).toHaveText('5678') await page.evaluate(() => { Object.defineProperty(navigator.userActivation, 'isActive', { value: false,