- Tabellen vault_categories en vault_category_fields (db/21), startrijen handmatig (db/handmatig/21b) - VaultCategoryService met regels voor zichtbaarheid, gebruik en eigen velden - Beheer > Kluissoorten (/beheer/soorten) met gebruikstellers; soort in gebruik niet te verwijderen - Kluis en Nalatenschap (web en app) tonen soorten en eigen velden uit de database - Socketprotocol: vault.categories; GRANTs voor mijnbesluit_app in db/02 - db/20 herhaalbaar gemaakt - App versie 1.1 (versiecode 3) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014MGLqt1uKfNARQHut9ifcx
210 lines
11 KiB
Markdown
210 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;
|
|
- de naam van een kluissoort die de beheerder zelf maakte (zie [Kluissoorten](Kluissoorten.md));
|
|
- 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.
|