diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts index 949d937e..342cc4c4 100644 --- a/extensions/general/tic/index.ts +++ b/extensions/general/tic/index.ts @@ -27,7 +27,7 @@ import { TICAPIError } from './lib/tic-types' import type { TICCompanyProfile, TICFinancialReportSummary } from './lib/tic-types' import type { BankIdCompleteRequest } from './lib/bankid-types' import type { CompanyLookupResult } from '@/lib/company-lookup/types' -import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid' +import { hashPersonalNumber, encryptPersonalNumberForStorage } from '@/lib/auth/bankid' import { requireAuth } from '@/lib/auth/require-auth' import { createServiceClient } from '@/lib/supabase/server' import { createLogger } from '@/lib/logger' @@ -1046,7 +1046,7 @@ export const ticExtension: Extension = { .insert({ user_id: userId, personal_number_hash: pnrHash, - personal_number_enc: encryptPersonalNumber(personalNumber), + personal_number_enc: encryptPersonalNumberForStorage(personalNumber), given_name: givenName, surname, }) @@ -1190,7 +1190,7 @@ export const ticExtension: Extension = { .insert({ user_id: userId, personal_number_hash: pnrHash, - personal_number_enc: encryptPersonalNumber(personalNumber), + personal_number_enc: encryptPersonalNumberForStorage(personalNumber), given_name: givenName, surname, }) diff --git a/lib/auth/__tests__/bankid.test.ts b/lib/auth/__tests__/bankid.test.ts index 3321b5f4..2b4ec27f 100644 --- a/lib/auth/__tests__/bankid.test.ts +++ b/lib/auth/__tests__/bankid.test.ts @@ -4,6 +4,8 @@ import { hashPersonalNumber, encryptPersonalNumber, decryptPersonalNumber, + encryptPersonalNumberForStorage, + decryptStoredPersonalNumber, maskPersonalNumber, } from '../bankid' @@ -79,6 +81,40 @@ describe('bankid helpers', () => { }) }) + describe('storage codec', () => { + const pnr = '199001011234' + + it('encodes for storage as a \\x-prefixed hex string and round-trips', () => { + const stored = encryptPersonalNumberForStorage(pnr) + expect(stored).toMatch(/^\\x[0-9a-f]+$/) + expect(decryptStoredPersonalNumber(stored)).toBe(pnr) + }) + + it('decrypts a raw Buffer', () => { + expect(decryptStoredPersonalNumber(encryptPersonalNumber(pnr))).toBe(pnr) + }) + + it('decrypts a legacy JSON-serialized Buffer read back as \\x-hex text', () => { + // supabase-js Buffer insert stored the JSON text of buf.toJSON(); + // PostgREST returns that bytea as '\x' + hex of the UTF-8 JSON bytes. + const legacyText = JSON.stringify(encryptPersonalNumber(pnr).toJSON()) + const readBack = '\\x' + Buffer.from(legacyText, 'utf8').toString('hex') + expect(decryptStoredPersonalNumber(readBack)).toBe(pnr) + }) + + it('decrypts a legacy JSON-serialized Buffer passed as plain text or object', () => { + const encrypted = encryptPersonalNumber(pnr) + expect(decryptStoredPersonalNumber(JSON.stringify(encrypted.toJSON()))).toBe(pnr) + expect(decryptStoredPersonalNumber(encrypted.toJSON())).toBe(pnr) + }) + + it('rejects tampered ciphertext (GCM auth)', () => { + const stored = encryptPersonalNumberForStorage(pnr) + const tampered = stored.slice(0, -2) + (stored.endsWith('00') ? '01' : '00') + expect(() => decryptStoredPersonalNumber(tampered)).toThrow() + }) + }) + describe('maskPersonalNumber', () => { it('masks a 12-digit personnummer', () => { expect(maskPersonalNumber('199001011234')).toBe('XXXXXXXX-1234') diff --git a/lib/auth/bankid.ts b/lib/auth/bankid.ts index 8b739f25..636e7840 100644 --- a/lib/auth/bankid.ts +++ b/lib/auth/bankid.ts @@ -64,6 +64,69 @@ export function decryptPersonalNumber(data: Buffer): string { return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8') } +// --------------------------------------------------------------------------- +// Storage codec (bankid_identities.personal_number_enc) +// --------------------------------------------------------------------------- + +/** + * Encrypt a personnummer and encode it for a PostgREST bytea insert. + * + * Passing a raw Buffer to supabase-js serializes it as JSON + * ('{"type":"Buffer","data":[...]}'), storing that literal text in the + * column instead of the bytes. PostgREST's bytea input format is a + * '\x'-prefixed hex string, which is what this returns. + */ +export function encryptPersonalNumberForStorage(personalNumber: string): string { + return '\\x' + encryptPersonalNumber(personalNumber).toString('hex') +} + +/** Legacy shape written by supabase-js Buffer serialization before 2026-07. */ +type SerializedBuffer = { type: 'Buffer'; data: number[] } + +function isSerializedBuffer(value: unknown): value is SerializedBuffer { + return ( + typeof value === 'object' && + value !== null && + (value as SerializedBuffer).type === 'Buffer' && + Array.isArray((value as SerializedBuffer).data) + ) +} + +/** + * Decode and decrypt a personal_number_enc value as read back through + * PostgREST (a '\x'-prefixed hex string) or a raw Buffer. + * + * Tolerates rows written before migration 20260727170000, where the column + * holds the UTF-8 text of a JSON-serialized Buffer rather than the raw + * iv|tag|ciphertext bytes. + */ +export function decryptStoredPersonalNumber(stored: string | Buffer | SerializedBuffer): string { + let raw: Buffer + if (Buffer.isBuffer(stored)) { + raw = stored + } else if (typeof stored === 'string') { + raw = stored.startsWith('\\x') + ? Buffer.from(stored.slice(2), 'hex') + : Buffer.from(stored, 'utf8') + } else if (isSerializedBuffer(stored)) { + raw = Buffer.from(stored.data) + } else { + throw new Error('Unsupported personal_number_enc value') + } + + // Legacy JSON-serialized Buffer stored as text: unwrap to the real bytes. + if (raw[0] === 0x7b) { + try { + const parsed: unknown = JSON.parse(raw.toString('utf8')) + if (isSerializedBuffer(parsed)) raw = Buffer.from(parsed.data) + } catch { + // Not JSON after all: treat as raw ciphertext. + } + } + + return decryptPersonalNumber(raw) +} + // --------------------------------------------------------------------------- // Display helpers // --------------------------------------------------------------------------- diff --git a/supabase/migrations/20260727170000_fix_bankid_personal_number_enc_encoding.sql b/supabase/migrations/20260727170000_fix_bankid_personal_number_enc_encoding.sql new file mode 100644 index 00000000..5ce5e8d5 --- /dev/null +++ b/supabase/migrations/20260727170000_fix_bankid_personal_number_enc_encoding.sql @@ -0,0 +1,27 @@ +-- Fix bankid_identities.personal_number_enc rows written through supabase-js +-- with a raw Buffer (issue #1232). PostgREST serialized the Buffer as JSON, +-- so the bytea column holds the literal UTF-8 text +-- {"type":"Buffer","data":[...]} +-- instead of the raw iv|tag|ciphertext bytes that decryptPersonalNumber() +-- expects. Rewrite those rows to the raw bytes recovered from the JSON +-- "data" array. +-- +-- The WHERE guard uses CASE, not AND: Postgres does not guarantee AND +-- evaluation order, and convert_from() must never run on a row already +-- holding raw ciphertext (not valid UTF-8), such as one written by the +-- fixed code between deploy and apply, or any row on a re-run. CASE +-- guarantees the byte-prefix check gates the convert_from call, keeping +-- the migration idempotent and race-safe. + +UPDATE bankid_identities +SET personal_number_enc = ( + SELECT decode(string_agg(lpad(to_hex(elem::int), 2, '0'), '' ORDER BY ord), 'hex') + FROM jsonb_array_elements_text( + convert_from(personal_number_enc, 'UTF8')::jsonb -> 'data' + ) WITH ORDINALITY AS t(elem, ord) +) +WHERE CASE + WHEN substring(personal_number_enc FROM 1 FOR 16) = convert_to('{"type":"Buffer"', 'UTF8') + THEN jsonb_array_length(convert_from(personal_number_enc, 'UTF8')::jsonb -> 'data') > 0 + ELSE false +END;