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:
Jakob Wennberg
2026-07-27 16:58:48 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 88099f5390
commit ff205951b1
4 changed files with 129 additions and 3 deletions
+36
View File
@@ -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')