feat(privacy): WP-40 — PII kernel (Bsn value object + masked-value atom)

Branded Bsn value object with the elfproef (11-test) checksum in shared/kernel/bsn.ts,
wired into the DigiD login boundary so login does real BSN validation (hint + e2e BSNs
updated to a valid 123456782). Consolidate the pure maskers into shared/kernel/pii.ts
(maskBsn/maskTail/REDACTED); debug-state keeps redactProfile (needs the registratie
BigProfile — boundary). New <app-masked-value> atom (+story) centralises the masked
`.includes('*')` detection + reveal affordance; behandel-scherm refactored onto it.
Session.bsn stays string (persistence boundary drops it for privacy). +specs for bsn/pii.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 15:24:59 +02:00
co-authored by Claude Opus 4.8
parent 19f2e9b734
commit 62cb34b60f
18 changed files with 246 additions and 83 deletions
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { parseBsn } from './bsn';
describe('parseBsn (elfproef)', () => {
it('accepts a valid BSN (passes the elfproef)', () => {
const r = parseBsn('123456782'); // Σ d·w = 154, divisible by 11
expect(r.ok && r.value).toBe('123456782');
});
it('rejects a 9-digit number that fails the elfproef', () => {
expect(parseBsn('123456789').ok).toBe(false); // sum 147, not divisible
});
it('rejects wrong length / non-digits / all zeros', () => {
expect(parseBsn('12345').ok).toBe(false);
expect(parseBsn('abcdefghi').ok).toBe(false);
expect(parseBsn('000000000').ok).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/**
* Value object: a Dutch **BSN** (burgerservicenummer) — art. 9 GDPR/AVG special-category
* data. "Parse, don't validate": a `Bsn` is a distinct type from a raw string, mintable only
* via `parseBsn`, so holding one is proof it passed the **elfproef** (11-test) checksum, not
* just a 9-digit shape. Format/checksum only — identity is still faked in this POC (DigiD stub).
*/
export type Bsn = Brand<string, 'Bsn'>;
// Positional weights for the elfproef: 9·d1 + 8·d2 + … + 2·d8 1·d9 ≡ 0 (mod 11).
const WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1];
export function parseBsn(raw: string): Result<string, Bsn> {
const t = raw.trim();
if (!/^\d{9}$/.test(t)) {
return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
}
const sum = [...t].reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0);
if (t === '000000000' || sum % 11 !== 0) {
return err(
$localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,
);
}
return ok(t as Bsn);
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { maskBsn, maskTail } from './pii';
describe('pii maskers', () => {
it('maskBsn keeps the last 3 digits', () => {
expect(maskBsn('123456789')).toBe('******789');
});
it('maskTail keeps the requested tail length', () => {
expect(maskTail('abcdef', 2)).toBe('****ef');
});
it('masks the whole value when it is not longer than the kept tail', () => {
expect(maskBsn('12')).toBe('**');
expect(maskBsn('')).toBe('');
});
});
+17
View File
@@ -0,0 +1,17 @@
/**
* PII masking — pure functional core (WP-40). Data-minimisation helpers shared by the app
* (dev state panel, the masked-value atom, anywhere sensitive data is shown). No framework,
* no domain imports. The backend keeps a `MaskTail` twin in sync (see Program.cs).
*/
export const REDACTED = 'redacted';
/** Keep the last `keep` characters, mask the rest with `*`. */
export function maskTail(value: string, keep: number): string {
if (value.length <= keep) return '*'.repeat(value.length);
return '*'.repeat(value.length - keep) + value.slice(-keep);
}
/** Mask a BSN / BIG-nummer for display: keep the last 3 digits, mask the rest. */
export function maskBsn(value: string): string {
return maskTail(value, 3);
}
@@ -8,7 +8,8 @@ import { Role } from '@shared/domain/role';
import { ROLES, currentRole, setRole } from '@shared/infrastructure/role';
import { Scenario, SCENARIOS, currentScenario, setScenario } from '@shared/infrastructure/scenario';
import { stripDevParams } from '@shared/infrastructure/dev-params';
import { maskBsn, redactProfile } from './mask';
import { maskBsn } from '@shared/kernel/pii';
import { redactProfile } from './mask';
// CIBG-GAP EXTENSION: n/a — devtool, no corresponding CIBG concept; deliberately
// off-theme by design (see the ponytail note below), see cibg-gaps.mdx.
@@ -1,13 +0,0 @@
import { describe, it, expect } from 'vitest';
import { maskBsn } from './mask';
describe('maskBsn', () => {
it('keeps the last 3 digits and masks the rest', () => {
expect(maskBsn('123456789')).toBe('******789');
});
it('handles short and empty input without throwing', () => {
expect(maskBsn('12')).toBe('**');
expect(maskBsn('')).toBe('');
});
});
+5 -14
View File
@@ -1,23 +1,14 @@
import { BigProfile } from '@registratie/domain/big-profile';
const REDACTED = 'redacted';
/** Keep the last `keep` characters, mask the rest. */
function maskTail(value: string, keep: number): string {
if (value.length <= keep) return '*'.repeat(value.length);
return '*'.repeat(value.length - keep) + value.slice(-keep);
}
/** Redact a BSN for the dev state view: keep the last 3 digits, mask the rest. */
export function maskBsn(bsn: string): string {
return maskTail(bsn, 3);
}
import { REDACTED, maskTail } from '@shared/kernel/pii';
/**
* Data minimisation for the dev "show the Model" panel: keep the structural /
* decision-relevant fields (status, beroep, dates of registration) but redact
* direct personal identifiers (name, address, date of birth) and mask the BIG
* number. The panel is for inspecting state SHAPE, never for reading PII.
* number. The panel is for inspecting state SHAPE, never for reading PII. The
* generic maskers live in `@shared/kernel/pii`; this stays here because it depends
* on the registratie `BigProfile` domain type (debug-state is the sanctioned
* cross-context devtool).
*/
export function redactProfile(p: BigProfile): unknown {
return {
@@ -0,0 +1,32 @@
import { Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component';
/**
* Atom: a possibly-masked sensitive value (BSN, BIG-nummer, …) with an optional, audited
* reveal affordance (WP-40). The value arrives masked from the server (data-minimisation)
* and is swapped for the full value on reveal; the reveal button shows only when the value
* is still masked AND the caller says the principal may reveal it. Centralises the
* masked-detection that consumers used to sniff inline. The atom only emits `reveal`; the
* caller owns the step-up gesture + the audited fetch (see behandel-scherm).
*
* ponytail: masked-detection is the mask character (`*`) — a POC heuristic. A server-sent
* `masked` boolean would remove the sniff; wire it here without touching consumers.
*/
@Component({
selector: 'app-masked-value',
imports: [ButtonComponent],
template: `
<span class="value">{{ value() }}</span>
@if (canReveal() && masked()) {
<app-button variant="subtle" (click)="reveal.emit()">{{ revealLabel() }}</app-button>
}
`,
})
export class MaskedValueComponent {
value = input.required<string>();
canReveal = input(false);
revealLabel = input($localize`:@@maskedValue.reveal:Tonen`);
reveal = output<void>();
protected masked = computed(() => this.value().includes('*'));
}
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { MaskedValueComponent } from './masked-value.component';
const meta: Meta<MaskedValueComponent> = {
title: 'Design System/Atoms/Masked Value',
component: MaskedValueComponent,
};
export default meta;
type Story = StoryObj<MaskedValueComponent>;
/** Masked + the principal may reveal → the reveal button shows. */
export const RevealableMasked: Story = {
args: { value: '******601', canReveal: true, revealLabel: 'Toon BIG-nummer' },
};
/** Masked but no reveal right → just the masked value, no affordance. */
export const MaskedNoReveal: Story = {
args: { value: '******601', canReveal: false },
};
/** Already revealed (no mask character) → no reveal button even with the right. */
export const Revealed: Story = {
args: { value: '990000000012', canReveal: true },
};