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
+7 -7
View File
@@ -1,16 +1,16 @@
import { Injectable } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { Result, ok } from '@shared/kernel/fp';
import { parseBsn } from '@shared/kernel/bsn';
import { Session } from '../domain/session';
/** Infrastructure: talks to the (mock) DigiD identity provider. */
@Injectable({ providedIn: 'root' })
export class DigidAdapter {
// ponytail: fake DigiD — any 9-digit BSN authenticates to a fixed identity.
// Swap for a real OIDC redirect flow when there's a backend.
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
// for a real OIDC redirect flow when there's an IdP.
async authenticate(bsn: string): Promise<Result<string, Session>> {
const t = bsn.trim();
if (!/^\d{9}$/.test(t))
return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
return ok({ bsn: t, naam: 'Dr. A. (Anna) de Vries' });
const r = parseBsn(bsn);
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
}
}
@@ -22,14 +22,14 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
fieldId="bsn"
required
i18n-description="@@login.bsnDescription"
description="9 cijfers (demo: vul iets in)"
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
>
<app-text-input
inputId="bsn"
hasDescription
[(ngModel)]="bsn"
name="bsn"
placeholder="123456789"
placeholder="123456782"
/>
</app-form-field>
@@ -2,6 +2,7 @@ import { Component, ElementRef, computed, input, output, viewChild } from '@angu
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
import { inferSelection } from '@brief/domain/besluit';
@@ -25,6 +26,7 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
imports: [
ButtonComponent,
HeadingComponent,
MaskedValueComponent,
StepperComponent,
LetterCanvasComponent,
DiagnosticsPanelComponent,
@@ -92,10 +94,13 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
<span>{{ caseContext().aanvraagReferentie }}</span>
<span>{{ caseContext().zorgverlenerNaam }}</span>
<span>
{{ bigLabel() }} {{ caseContext().bigNummer }}
@if (canRevealBigNummer() && isMasked()) {
<app-button variant="subtle" (click)="onReveal()">{{ revealLabel() }}</app-button>
}
{{ bigLabel() }}
<app-masked-value
[value]="caseContext().bigNummer"
[canReveal]="canRevealBigNummer()"
[revealLabel]="revealLabel()"
(reveal)="onReveal()"
/>
</span>
<span>{{ caseContext().beroep }}</span>
</div>
@@ -172,10 +177,6 @@ export class BehandelSchermComponent {
locate = output<Diagnostic>();
revealBigNummer = output<void>();
/** The BIG-nummer arrives masked (contains `*`); once revealed the swapped value has
no `*`, so the reveal action hides itself — no separate "revealed" flag needed. */
protected isMasked = computed(() => this.caseContext().bigNummer.includes('*'));
/** Step-up (PRD-0002 §5d) stubbed as a native confirm — the extra verification gesture
before an audited PII reveal. ponytail: real systems prompt MFA / recent re-auth. */
protected onReveal() {
+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 },
};
+20 -4
View File
@@ -6,8 +6,24 @@
<source>Voer een geldig BSN van 9 cijfers in.</source>
<target datatype="html">Enter a valid 9-digit BSN.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/infrastructure/digid.adapter.ts</context>
<context context-type="linenumber">13</context>
<context context-type="sourcefile">src/app/shared/kernel/bsn.ts</context>
<context context-type="linenumber">15</context>
</context-group>
</trans-unit>
<trans-unit id="validation.bsnElfproef" datatype="html">
<source>Dit is geen geldig BSN (klopt niet met de elfproef).</source>
<target datatype="html">This is not a valid BSN (fails the eleven-test checksum).</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/kernel/bsn.ts</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="maskedValue.reveal" datatype="html">
<source>Tonen</source>
<target datatype="html">Show</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/ui/masked-value/masked-value.component.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="form.verplichteVelden" datatype="html">
@@ -35,8 +51,8 @@
</context-group>
</trans-unit>
<trans-unit id="login.bsnDescription" datatype="html">
<source>9 cijfers (demo: vul iets in)</source>
<target datatype="html">9 digits (demo: fill in anything)</target>
<source>9-cijferig BSN, elfproef-geldig (demo: 123456782)</source>
<target datatype="html">9-digit BSN, valid eleven-test checksum (demo: 123456782)</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">25,28</context>
+41 -27
View File
@@ -2,13 +2,6 @@
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="nl" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="validation.bsn" datatype="html">
<source>Voer een geldig BSN van 9 cijfers in.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/infrastructure/digid.adapter.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="form.verplichteVelden" datatype="html">
<source>* verplichte velden</source>
<context-group purpose="location">
@@ -32,7 +25,7 @@
</context-group>
</trans-unit>
<trans-unit id="login.bsnDescription" datatype="html">
<source>9 cijfers (demo: vul iets in)</source>
<source>9-cijferig BSN, elfproef-geldig (demo: 123456782)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">25,28</context>
@@ -322,81 +315,81 @@
<source>Opnieuw indienen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">221</context>
<context context-type="linenumber">222</context>
</context-group>
</trans-unit>
<trans-unit id="brief.submit" datatype="html">
<source>Indienen ter beoordeling</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">222</context>
<context context-type="linenumber">223</context>
</context-group>
</trans-unit>
<trans-unit id="brief.step.beoordelen" datatype="html">
<source>Beoordelen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">226</context>
<context context-type="linenumber">227</context>
</context-group>
</trans-unit>
<trans-unit id="brief.step.opstellen" datatype="html">
<source>Brief opstellen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">227</context>
<context context-type="linenumber">228</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">231</context>
<context context-type="linenumber">232</context>
</context-group>
</trans-unit>
<trans-unit id="brief.step.indienen" datatype="html">
<source>Indienen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">228</context>
<context context-type="linenumber">229</context>
</context-group>
</trans-unit>
<trans-unit id="brief.process" datatype="html">
<source>Herregistratie behandelen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">230</context>
<context context-type="linenumber">231</context>
</context-group>
</trans-unit>
<trans-unit id="brief.case.heading" datatype="html">
<source>Aanvraag herregistratie</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">232</context>
<context context-type="linenumber">233</context>
</context-group>
</trans-unit>
<trans-unit id="brief.case.big" datatype="html">
<source>BIG-nummer</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">233</context>
<context context-type="linenumber">234</context>
</context-group>
</trans-unit>
<trans-unit id="brief.case.reveal" datatype="html">
<source>Toon BIG-nummer</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">234</context>
<context context-type="linenumber">235</context>
</context-group>
</trans-unit>
<trans-unit id="brief.case.revealConfirm" datatype="html">
<source>Extra verificatie vereist. Het tonen van het BIG-nummer wordt vastgelegd. Doorgaan?</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">236</context>
<context context-type="linenumber">237</context>
</context-group>
</trans-unit>
<trans-unit id="brief.preview.open" datatype="html">
<source>Voorbeeld</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">238</context>
<context context-type="linenumber">239</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/letter-composer/letter-composer.component.ts</context>
@@ -407,21 +400,21 @@
<source>Openen als document (PDF)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">240</context>
<context context-type="linenumber">241</context>
</context-group>
</trans-unit>
<trans-unit id="common.close" datatype="html">
<source>Sluiten</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">242</context>
<context context-type="linenumber">243</context>
</context-group>
</trans-unit>
<trans-unit id="brief.submitHint" datatype="html">
<source>Vul eerst alle verplichte secties en los fouten op.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts</context>
<context context-type="linenumber">244</context>
<context context-type="linenumber">245</context>
</context-group>
</trans-unit>
<trans-unit id="brief.besluit.positief" datatype="html">
@@ -1070,11 +1063,11 @@
<source>Maak een keuze.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/herregistratie/domain/intake.machine.ts</context>
<context context-type="linenumber">100</context>
<context context-type="linenumber">102</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/herregistratie/domain/intake.machine.ts</context>
<context context-type="linenumber">112</context>
<context context-type="linenumber">114</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/registratie/domain/registratie-wizard.machine.ts</context>
@@ -1085,7 +1078,7 @@
<source>Vul een land in.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/herregistratie/domain/intake.machine.ts</context>
<context context-type="linenumber">103</context>
<context context-type="linenumber">105</context>
</context-group>
</trans-unit>
<trans-unit id="herregWizard.processName" datatype="html">
@@ -1681,7 +1674,7 @@
<source>Voer een geldige postcode in, bijv. 1234 AB.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/registratie/domain/value-objects/postcode.ts</context>
<context context-type="linenumber">13</context>
<context context-type="linenumber">14</context>
</context-group>
</trans-unit>
<trans-unit id="validation.telefoon" datatype="html">
@@ -2614,6 +2607,20 @@
<context context-type="linenumber">28</context>
</context-group>
</trans-unit>
<trans-unit id="validation.bsn" datatype="html">
<source>Voer een geldig BSN van 9 cijfers in.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/kernel/bsn.ts</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="validation.bsnElfproef" datatype="html">
<source>Dit is geen geldig BSN (klopt niet met de elfproef).</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/kernel/bsn.ts</context>
<context context-type="linenumber">21</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.huisstijl" datatype="html">
<source>Huisstijl</source>
<context-group purpose="location">
@@ -2936,6 +2943,13 @@
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="maskedValue.reveal" datatype="html">
<source>Tonen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/ui/masked-value/masked-value.component.ts</context>
<context context-type="linenumber">28</context>
</context-group>
</trans-unit>
<trans-unit id="placeholderChip.auto" datatype="html">
<source>wordt automatisch ingevuld</source>
<context-group purpose="location">