Files
MijnBesluit/docs/Beveiliging.md
T
2026-09-27 14:21:31 +00:00

209 lines
11 KiB
Markdown

# MijnBesluit — beveiliging
In een kluis met wachtwoorden, pincodes en uitvaartwensen is beveiliging geen bijzaak. Dit
document beschrijft precies hoe het werkt, zodat je het kunt controleren en er later bewust van
kunt afwijken.
## Inloggen met een code per e-mail
Er zijn geen wachtwoorden. Je vult je e-mailadres in, krijgt een code van zes cijfers toegestuurd
en vult die in. Dat is dezelfde gang van zaken als in de WinDev-versie, met drie verschillen.
**De code staat niet leesbaar opgeslagen.** In de WinDev-versie stond hij in
`mlPeople.ActivationCode` en werd hij bovendien in een paginavariabele (`gsInlogcode`) aan de
browser meegegeven; wie de paginabron bekeek, kon zo inloggen. Hier gaat alleen een hash de
database in (PBKDF2-HMAC-SHA512), en de code verlaat de server nooit anders dan per e-mail.
**Zes cijfers in plaats van vier.** `Random(1111,9999)` gaf ruim achtduizend mogelijkheden; nu
zijn het er een miljoen. En `Random` is voorspelbaar zodra je een paar uitkomsten kent, dus de
code komt uit `RandomNumberGenerator`.
**Er zitten remmen op.** Een code is 15 minuten geldig, werkt één keer, en na vijf misgokken
wordt hij weggegooid. Een nieuwe code maakt de vorige ongeldig. Per e-mailadres mag er hooguit
één code per 30 seconden en vijf per uur worden aangevraagd, zodat niemands postvak volloopt.
Die vijf per uur tellen alleen de codes waarmee **niet** is ingelogd (`signed_in_at` leeg). De
rem is er tegen iemand die andermans postvak volgooit, en wie de code invult zit zelf in dat
postvak. Telden geslaagde aanmeldingen wel mee, dan sloot je jezelf buiten na vijf keer inloggen
in een uur — en dat gebeurt precies wanneer er iets anders aan de hand is en je het opnieuw
probeert. Een code die is verdrongen door een nieuwe aanvraag of die is stukgeraden telt wél
mee: allebei die dingen kan een vreemde doen die alleen het adres kent, en anders was de rem in
twee stappen leeg te trekken.
Alles staat als constante bovenin `AccountService`, dus aanpassen is één regel.
## Een account opheffen
Zelf, op elk moment, in de app bij Mijn profiel en op `/account-verwijderen`. Dat webadres werkt
ook zonder in te loggen en zonder de app - Google Play eist dat apart, en terecht: een
verwijderknop die alleen in de app zit helpt niet als je de app al hebt weggegooid.
**Er komt een verse code per e-mail aan te pas**, met een eigen doel (`LoginCodePurpose.Opheffen`)
zodat een gewone inlogcode hier niet volstaat. Een openstaande sessie op een telefoon die iemand
anders in handen krijgt, hoort een kluis niet leeg te kunnen gooien.
De regel waar alles op teruggaat: **weg is wat van jou is, blijven doet wat van een ander is.**
In deze applicatie liggen die twee dichter bij elkaar dan je zou denken, dus staat per soort in
`AccountOpheffenService` waarom het de ene of de andere kant op valt. Kort:
| Weg | Blijft |
|---|---|
| Je kluis, je bestanden, je sleutel | Berichten die al bezorgd zijn - die zijn van de ontvanger |
| Berichten die nog klaarstonden | Je naam in de lijst van wie jou heeft toegevoegd |
| Je eigen families en je aantekeningen over anderen | Aantekeningen die ook aan iemand anders hangen |
| Je aanwijzingen in nalatenschappen, beide kanten op | Het logboek (daar staat geen inhoud in) |
| Je toestellen, je sessies, je inlogcodes | |
Dat bezorgde berichten leesbaar blijven is geen toeval: bij het bezorgen krijgt de ontvanger zijn
eigen kopie van de sleutel (zie `MessageService`). Jouw sleutel weggooien maakt ze dus niet stuk.
Zonder die eigenschap zou "bezorgd blijft" een belofte zijn die we niet waarmaken.
Het scherm laat eerst zien wát er weggaat - aantallen, geen "weet je het zeker?". En wie ergens
als nalatenschapsbeheerder is aangewezen, krijgt dat er apart bij te zien: die aanwijzing vervalt,
en de ander rekent erop.
> **Wat dit betekent.** Wie bij iemands e-mail kan, kan bij diens kluis. Dat is de prijs van
> inloggen zonder wachtwoord — al gold dat bij wachtwoorden met e-mailherstel net zo goed. Het
> alternatief, wachtwoord én code, is veiliger maar legt een drempel bij precies de groep waar
> MijnBesluit voor bedoeld is. Wil je later toch twee stappen: het staat allemaal in
> `AccountService`, de rest van de code verandert niet.
Bij inloggen wordt gezegd dat een e-mailadres onbekend is ("Maak dan eerst een account aan").
Dat verraadt of iemand deelnemer is. Bewust: zonder die melding raken mensen die zich vergissen
in hun adres het spoor bijster. Wil je dat niet prijsgeven, geef dan altijd hetzelfde antwoord
en stuur bij een onbekend adres een mail met de tekst dat er geen account is.
## De kluis: drie lagen
```
inhoud van een item --AES-256-GCM--> cijfertekst in de database
^
| contentsleutel (CEK), per item willekeurig
|
CEK --AES-256-GCM--> wrapped_content_key (ingepakt met de datasleutel van de eigenaar)
^
| datasleutel (DEK), per gebruiker
|
DEK --AES-256-GCM--> wrapped_data_key (ingepakt met de mastersleutel van de server)
```
Er staat dus nergens een sleutel in leesbare vorm in de database. Wie de database kopieert heeft
er niets aan zolang hij het sleutelbestand niet ook heeft.
Waarom drie lagen en niet twee? Omdat toegang geven aan een ander dan neerkomt op één handeling:
de contentsleutel nóg een keer inpakken, nu met de datasleutel van die ander. Dat is precies wat
er gebeurt als een bericht wordt bezorgd of als een nalatenschapsbeheerder toegang krijgt. De
inhoud zelf hoeft niet opnieuw versleuteld te worden, en de eigenaar houdt zijn eigen sleutel.
AES-256-GCM is *authenticated encryption*: bij elke ontsleuteling wordt de authenticatietag
gecontroleerd. Één gewijzigde byte in de database en het openen mislukt met een duidelijke fout,
in plaats van dat er stilletjes onzin uit komt.
## Wat is niet versleuteld?
Bewust leesbaar, omdat er anders niet te zoeken of te sorteren valt:
- de titel en de categorie van een kluisitem;
- het onderwerp, de afzender, de ontvanger en de bezorgdatum van een bericht;
- namen, e-mailadressen en relaties.
Zet dus geen geheimen in een titel. Dat staat ook zo in het scherm waar je een item aanmaakt.
## De mastersleutel
Die staat in een sleutelbestand (standaard `keys/masterkey.json`), als een sleutelring: meerdere
genummerde sleutels waarvan er één de actieve is. In een ingepakte datasleutel staat het
versienummer vooraan, zodat je kunt roteren zonder oude gegevens onleesbaar te maken.
Eenmalig aanmaken:
```csharp
FileMasterKeyProtector.CreateKeyFile("keys/masterkey.json");
```
Drie dingen om goed te doen:
1. **Zet het bestand niet in dezelfde back-up als de database.** Anders heeft wie de back-up
steelt alsnog alles.
2. **Beperk de rechten** tot het account waaronder de service draait.
3. **Maak er een aparte back-up van, op een andere plek.** Raak je dit bestand kwijt, dan is
iedere kluis onherstelbaar dicht. Er is geen achterdeur — dat is het punt.
Voor productie is een sleutelkluis of HSM beter. Vervang dan alleen `FileMasterKeyProtector`
door een eigen implementatie van `IMasterKeyProtector`; de rest van de code verandert niet.
Merk op: omdat de server de mastersleutel heeft, kan iemand met volledige serverrechten in
theorie bij de kluisinhoud. Dat is de prijs van deze opzet, en dat is bewust gekozen: het maakt
wachtwoordherstel mogelijk en zorgt dat de nalatenschapsbeheerder ook echt toegang krijgt als de
overledene zelf niets meer kan doen. Wil je dat niet, dan is de hybride variant nodig waarbij de
datasleutel ook met het wachtwoord van de gebruiker wordt ingepakt — maar dan is een vergeten
wachtwoord gelijk aan een verloren kluis.
## Bestanden
Bijlagen staan versleuteld op schijf, buiten de database, in mappen per jaar en maand. De
oorspronkelijke bestandsnaam komt niet in het pad voor (die kan al iets verraden); wat op schijf
staat heet `<willekeurige-guid>.bin`. Van de onversleutelde inhoud wordt een SHA-256 bewaard,
zodat beschadiging vast te stellen is. Paden worden gecontroleerd, zodat een opslagsleutel nooit
buiten de map kan wijzen.
## Tokens
Uitnodigingstokens en vernieuwtokens zijn 32 willekeurige bytes uit een cryptografische
generator, URL-veilig gecodeerd. In de database staat alleen de SHA-256 ervan. Een uitnodiging is
30 dagen geldig en eenmalig bruikbaar.
## Verbindingen
De website draait achter HTTPS met HSTS. Het sessiecookie is `HttpOnly`, `SameSite=Lax` en
`Secure`.
De SocketServer gebruikt TLS 1.2 of 1.3, met een servercertificaat uit een pfx-bestand of uit de
certificaatopslag van Windows. Zonder certificaat start de server wel, maar onversleuteld, met
een waarschuwing in het logboek — dat is uitsluitend bedoeld voor lokaal testen. Er gaan
wachtwoorden en pincodes over deze verbinding.
> **Verschil met de RenH-socketserver.** Daar wordt de verbinding beveiligd met een
> "socketsleutel": een tijdstempel, versleuteld met AES-128-CBC waarvan de sleutel de MD5 is van
> een vast wachtwoord in de broncode. Dat werkt prima als bewijs dat de client bij het systeem
> hoort en dat het bericht vers is, maar het versleutelt de inhoud niet en het wachtwoord staat
> in elke uitgeleverde app. Voor een dashboard met verkoopcijfers is dat een andere afweging dan
> voor een kluis met pincodes. Daarom hier TLS, waarbij de hele verbinding versleuteld is en het
> certificaat aan de serverkant blijft.
## Het logboek
`audit_log` legt vast wie wat wanneer deed: inloggen, mislukte pogingen, profielwijzigingen,
uitnodigingen, het openen van een kluisitem, overlijdensmeldingen en het vrijgeven van een
nalatenschap. De applicatiegebruiker in de database heeft op die tabel alleen INSERT-rechten
(zie `db/02_applicatiegebruiker.sql`), zodat regels niet achteraf gewijzigd of verwijderd kunnen
worden.
## Foutmeldingen
De SocketServer stuurt bij een onverwachte fout altijd dezelfde neutrale tekst terug. Wat er echt
misging staat in het logboek van de server, niet op het scherm van de gebruiker. Zo lekken er
geen servernamen, tabelnamen of stacktraces naar buiten.
## Waar de geheimen staan
Zet de connectiereeks en het SMTP-wachtwoord **niet** in `appsettings.json` in je repository. Bij
ontwikkelen:
```
dotnet user-secrets set "ConnectionStrings:MijnBesluit" "Server=localhost;Database=mijnbesluit;User Id=...;Password=..." --project src\MijnBesluit.Web
```
In productie: omgevingsvariabelen of een sleutelkluis.
## Wat er nog niet is
Eerlijk overzicht van wat bewust nog ontbreekt:
- **Twee-factor-authenticatie.** Het veld staat in de database, de implementatie nog niet.
- **Snelheidsbegrenzing per IP-adres.** Er is wel een blokkade per account na vijf mislukte
pogingen, maar nog geen begrenzing op verzoekniveau.
- **Certificaat-pinning in de app.** Aan te raden zodra de app publiek is.
- **Een onafhankelijke beveiligingsaudit.** Voor een product dat dit soort gegevens bewaart is
dat geen luxe.