Files
accounted/lib/auth/bankid.ts
T
Jakob Wennberg ff205951b1 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>
2026-07-27 16:58:48 +02:00

141 lines
5.0 KiB
TypeScript

/**
* BankID authentication helpers.
*
* BankID is only available on the hosted deployment (requires TIC Identity API).
* Self-hosted deployments never show the BankID option.
*/
import crypto from 'crypto'
const ALGORITHM = 'aes-256-gcm'
// ---------------------------------------------------------------------------
// Feature flag
// ---------------------------------------------------------------------------
export function isBankIdEnabled(): boolean {
if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false
return process.env.NEXT_PUBLIC_BANKID_ENABLED === 'true'
}
// ---------------------------------------------------------------------------
// Personnummer hashing (for lookup)
// ---------------------------------------------------------------------------
/** SHA-256 hash of a personnummer for fast DB lookup. */
export function hashPersonalNumber(personalNumber: string): string {
return crypto.createHash('sha256').update(personalNumber).digest('hex')
}
// ---------------------------------------------------------------------------
// Personnummer encryption (for display in settings)
// ---------------------------------------------------------------------------
function getEncryptionKey(): Buffer {
const key = process.env.BANKID_ENCRYPTION_KEY
if (!key) throw new Error('BANKID_ENCRYPTION_KEY is required for BankID operations')
return Buffer.from(key, 'hex')
}
/** AES-256-GCM encrypt a personnummer for storage. */
export function encryptPersonalNumber(personalNumber: string): Buffer {
const key = getEncryptionKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(personalNumber, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
// Format: iv (12) + tag (16) + ciphertext
return Buffer.concat([iv, tag, encrypted])
}
/** AES-256-GCM decrypt a stored personnummer. */
export function decryptPersonalNumber(data: Buffer): string {
const key = getEncryptionKey()
const iv = data.subarray(0, 12)
const tag = data.subarray(12, 28)
const encrypted = data.subarray(28)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
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
// ---------------------------------------------------------------------------
/** Mask a personnummer for display: "XXXXXXXX-1234" */
export function maskPersonalNumber(personalNumber: string): string {
if (personalNumber.length < 4) return '****'
const last4 = personalNumber.slice(-4)
const masked = personalNumber.length === 12 ? 'XXXXXXXX' : 'XXXXXX'
return `${masked}-${last4}`
}