Date: 2026-05-30
Accepted (updated 2026-06-08 — encrypted mode implemented)
WireGuard peers are identified by a Curve25519 keypair. The private key lives on the client device — that's the whole security model. Islandr generates the keypair server-side for the user's convenience (so they don't have to install wg themselves), then hands it back in the create-peer response.
The open question PRD OQ-1 originally raised: does the server keep a copy of the private key, or forget it after the response?
Two real-world reference points:
.conf (including the private key) in /etc/wireguard/configs/. pivpn -qr re-renders the QR anytime. Operator convenience over strict key hygiene.Both choices are defensible for their target audience. PiVPN's audience is the single-operator homelab where "the server has all secrets anyway" is true; the hub VM holds the WireGuard server's own private key, which is more sensitive than any individual peer's. Forbidding peer-key retention while keeping the server key on disk is closing the smallest door in the room.
For Islandr's audience profile (PRD §3 — Felix the sysadmin running a 30–80 person company and the homelab operator) we have both worlds. A one-size-fits-all "never persist" is too strict for the homelab; a one-size-fits-all "always persist" is too loose for the company.
A first attempt at this ADR (in OQ-1's original resolution) picked never as the only mode. After feedback this was reopened — operators familiar with PiVPN expect re-display to work, and the convenience is real.
Instance-wide config property islandr.peer.privateKey.retention with two values:
never (default) — server generates keypair, returns private key in the create-peer response body once, never persists it. No subsequent endpoint can return it. Recovery from lost artefact = regenerate peer.plaintext — server generates keypair, returns it in the create-peer response, also persists it in the peers.private_key_pem column unencrypted. GET /api/v1/peers/{id}/conf re-renders the .conf and QR on demand. PiVPN-equivalent behaviour. Audit-logged on every re-display.encrypted — same as plaintext, but the private key is stored AES-256-GCM encrypted. The encryption key is never written to the database. T-007 (DB-file exfiltration) requires a separate key compromise to recover any peer private key.Not per-peer. The mode is set once at the instance level and applies to every peer created from that point forward. Per-peer toggles invite "oops I clicked the wrong checkbox" leaks and make the threat model unfalsifiable ("which peers on this hub have keys server-side?" should have a single answer: all of them, or none).
encrypted mode is now implemented in v1 (implemented 2026-06-08; previously deferred). Encryption key delivery uses systemd-creds as the primary channel (machine-bound, no plaintext on disk) with an env-var fallback for Docker / dev use.
| Aspect | never |
plaintext |
encrypted |
|---|---|---|---|
peers.private_key_pem column |
Always NULL |
Plaintext WireGuard key (44 chars) | enc$ + Base64(IV ∥ ciphertext ∥ GCM tag) (~100 chars) |
POST /users/{id}/peers response |
Includes privateKey, conf, qrPngBase64 |
Same | Same — key is decrypted before the response |
GET /peers/{id}/conf |
404 Not Found (with explanation body) |
200 with conf + qrPngBase64 |
200 — key decrypted on demand, never returned in stored form |
| Audit on re-display | N/A | PEER_CONF_RESHOW entry, every call |
Same |
Allowed in prod profile |
Yes | Yes, but admin sees a banner: "Private keys are stored unencrypted." | Yes — recommended when plaintext is needed |
| Required setup | None | None | Encryption key delivered via systemd-creds or ISLANDR_ENCRYPTION_KEY env var |
encrypted mode — key deliveryThe encryption master key is a 32-byte AES-256 key delivered out-of-band, never stored in the database:
systemd-creds (recommended for production):
# Generate and encrypt the key, bound to this machine's TPM2:
openssl rand -base64 32 | systemd-creds encrypt --tpm2=yes - /etc/islandr/kek.cred
# Add to /etc/systemd/system/islandr.service:
LoadCredentialEncrypted=ENCRYPTION_KEY:/etc/islandr/kek.cred
# systemd decrypts and delivers it to:
# /run/credentials/islandr.service/ENCRYPTION_KEY
# Set in /etc/islandr/env:
ISLANDR_ENCRYPTION_KEY_PATH=/run/credentials/islandr.service/ENCRYPTION_KEY
Env-var fallback (Docker / dev):
export ISLANDR_ENCRYPTION_KEY=$(openssl rand -base64 32)
When the admin changes the retention mode in Settings, all existing stored keys are migrated atomically in the same DB transaction:
| Transition | Effect |
|---|---|
plaintext → encrypted |
All plaintext keys are encrypted in-place |
encrypted → plaintext |
All encrypted keys are decrypted in-place |
any → never |
All stored keys are set to NULL (irreversible) |
Changing from encrypted without a configured key results in a 400 error.
never as the default — not because it's universally right, but because it's the safer misconfiguration. An operator who needed plaintext but got never gets a clean error (404 from re-display) and a one-line config fix. An operator who wanted never but got plaintext has been silently exposing keys without knowing it. The asymmetry says: ship the strict default, let comfort be opt-in.
Baseline: never + plaintext modes, instance-wide config (the decision).
| Criterion (weight) | never + plaintext (baseline) | never only (original OQ-1 answer) | plaintext only (PiVPN) | never + encrypted + plaintext (3 modes) | Per-peer toggle |
|---|---|---|---|---|---|
| Strict N-06 alignment when chosen (4) | 0 | +1 | -1 | 0 | 0 |
| PiVPN-style re-display convenience when chosen (4) | 0 | -1 | +1 | 0 | 0 |
| Single instance-wide invariant (audit clarity) (4) | 0 | +1 | +1 | 0 | -1 |
| Time-to-v1 (3) | 0 | +1 | +1 | -1 | -1 |
| Master-key infra cost (3) | 0 | 0 | 0 | -1 | 0 |
| Misconfiguration blast radius (operator picks wrong) (3) | 0 | 0 | -1 | 0 | -1 |
Migration path to v2 encrypted (2) |
0 | -1 | -1 | n/a | 0 |
| Operator familiarity (PiVPN expectation) (2) | 0 | -1 | +1 | 0 | 0 |
| Weighted total | 0 | +2 | +2 | −6 | −8 |
Honest reading:
never only and plaintext only both score +2 because each is strictly better than the baseline on its own native axis (strict security vs. PiVPN convenience). They lose on the other axis. The baseline is a deliberate compromise — neither single-mode answer is wrong, they're answers for different operators. Picking the baseline means "let the operator decide once per instance."never + encrypted + plaintext) scores −6 mostly on encrypted's implementation cost. The encrypted path is genuinely valuable for compliance-bound operators who want neither "in the clear on disk" nor "regenerate on every loss." We chose to ship the simpler shape first and add encrypted when an actual operator demand surfaces — additive change, won't paint us into a corner.Positive
islandr.peer.privateKey.retention=plaintext? then yes, keys are on disk. otherwise no."encrypted later is purely additive — new enum value, new column-content interpretation, no schema break.Risks created
plaintext mode who forgets that setting and treats the hub as low-secret invites exactly the leak this ADR documents. Mitigation: Admin Console shows a permanent banner "Private keys are stored unencrypted on this hub" in plaintext mode. Startup log line. Settings page reads the value back to the admin in plain words, not just config-string form.plaintext → never leaves existing rows with key material in the DB. Mitigation: a one-shot CLI / admin endpoint purge-private-keys that NULLs the column for all rows. Documented in the operations guide. Audit entry per row..db snapshots, Postgres dumps) in plaintext mode contain every peer's private key. Operators backing up off-host now own that exposure. Mitigation: documented loudly in README.md; the plaintext banner repeats this point.GET /peers/{id}/conf is a re-display endpoint; if an attacker compromises a session cookie they get every peer's .conf. Mitigation: same auth requirements as the create endpoint; in v2 the system-role check restricts this to ADMINs (END_USERs can only re-display their own peers via /me/peers/{id}/conf).PEER_CONF_RESHOW events grows quickly if used in scripts. Acceptable: the audit table is append-only and the retention story already exists (F-15).Accepted trade-offs
plaintext mode is genuinely less secure than never. We name that out loud and let the operator make the call.pivpn-qr command — the reference for the plaintext mode behaviour