fix(auth): store BankID personnummer ciphertext as raw bytea, not JSON-serialized Buffer (#1233)
Both writers of bankid_identities.personal_number_enc passed a raw Buffer
to supabase-js, which PostgREST serializes as JSON: every row stored the
literal text {"type":"Buffer","data":[...]} instead of iv|tag|ciphertext
bytes, so decryptPersonalNumber could never have read them (issue #1232).
- encryptPersonalNumberForStorage(): hex-encode for PostgREST bytea input
- decryptStoredPersonalNumber(): tolerant decode (raw bytea read-back,
legacy JSON-Buffer text, Buffer, serialized object)
- migration 20260727170000 rewrites existing rows to raw bytes; prefix
guard keeps it idempotent and skips already-raw rows. Conversion SQL
verified read-only against prod: converted bytes decrypt with the live
key (GCM tag valid, 12-digit result).
Closes #1232
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
88099f5390
commit
ff205951b1
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user