Handle client keys, encrypted vault
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
# Encrypted vault format
|
||||
|
||||
The application stores the vault as UTF-8 TOML, schema version 1. The bundle is
|
||||
an opaque encrypted unit suitable for persistence, backup, and restoration
|
||||
without the vault password. Parsing is bounded and rejects unsupported versions,
|
||||
unknown fields, malformed labels, invalid Base64, and oversized bundles.
|
||||
|
||||
## Version 1 structure
|
||||
|
||||
For M4, the current encrypted bundle is intended to live at
|
||||
`ResolvedSettings::data_directory.join("vault.toml")`. This stable filename is
|
||||
relative to the resolved data directory, whose default is `Backup/data` beside
|
||||
the executable. M3 only prepares and serializes vault bytes in memory; it does
|
||||
not create this file or install application state. M4 persistence will own that
|
||||
write.
|
||||
|
||||
The top level contains:
|
||||
|
||||
| Field | Content |
|
||||
| --- | --- |
|
||||
| `version` | Integer `1`. Other values are rejected. |
|
||||
| `recipient` | Canonical age x25519 public recipient (`age1...`). |
|
||||
| `identity_ciphertext` | Standard Base64 of an age binary file encrypted with the passphrase recipient format (scrypt). Its plaintext is the age x25519 secret identity. |
|
||||
| `records` | TOML table keyed by each client's canonical WireGuard public key. |
|
||||
|
||||
Each record table contains `ciphertext`, standard Base64 of an age binary file
|
||||
encrypted to `recipient`, and `reissue_blocked`, an advisory status snapshot.
|
||||
The snapshot is not authorization. Retrieval decrypts the record and enforces its
|
||||
authenticated `reissue_blocked` value; editing the TOML boolean cannot authorize
|
||||
a blocked key. Record insertion rejects an existing public-key label and never
|
||||
silently replaces ciphertext.
|
||||
|
||||
The TOML shape is:
|
||||
|
||||
```toml
|
||||
version = 1
|
||||
recipient = "age1..."
|
||||
identity_ciphertext = "<base64 age passphrase-encrypted identity>"
|
||||
|
||||
[records."<canonical WireGuard public key>"]
|
||||
ciphertext = "<base64 age recipient-encrypted record>"
|
||||
reissue_blocked = false
|
||||
```
|
||||
|
||||
The plaintext of a decrypted client record is UTF-8 TOML with this schema:
|
||||
|
||||
```text
|
||||
version = 1
|
||||
label = "<canonical WireGuard public key>"
|
||||
private_key = "<canonical Base64 WireGuard private key>"
|
||||
client_addresses = ["<original client interface Address item>"]
|
||||
interface_dns = "<optional original DNS value>"
|
||||
interface_mtu = "<optional original MTU value>"
|
||||
server_public_key = "<canonical WireGuard server public key>"
|
||||
endpoint = "<optional original Endpoint value>"
|
||||
allowed_ips = ["<original client AllowedIPs item>"]
|
||||
persistent_keepalive = "<optional original PersistentKeepalive value>"
|
||||
preshared_key = "<optional canonical Base64 preshared key>"
|
||||
reissue_blocked = true
|
||||
```
|
||||
|
||||
Optional fields are omitted when absent. Unknown input directives such as hooks
|
||||
are not interpreted or executed, and are not part of the retained delivery
|
||||
parameters. The record label is authenticated by age encryption and repeated
|
||||
inside the ciphertext. On retrieval the implementation checks the embedded label,
|
||||
derives the public key from the decrypted private key, and enforces the encrypted
|
||||
conflict bit. Swapping a ciphertext to another public-key table entry therefore
|
||||
fails closed. No alias, filename, or server peer key is used as client identity.
|
||||
|
||||
Client imports derive identity only from `[Interface] PrivateKey`. The `[Peer]
|
||||
PublicKey` is retained as the server identity. Address discrepancies, a confirmed
|
||||
server-key mismatch, an unresolved authoritative server identity, file-only
|
||||
clients, unsupported address projection, invalid inputs, and duplicate candidates
|
||||
are separate report outcomes. An unresolved identity is not treated as a match.
|
||||
A valid key may be encrypted while a conflict is present, but password-authorized
|
||||
retrieval remains blocked. Clearing that authenticated state is deferred until an explicit reconciliation operation
|
||||
can verify both client material and a fresh authoritative server configuration;
|
||||
the current API intentionally provides no public-boolean or metadata-only bypass.
|
||||
|
||||
## Read-only source scan
|
||||
|
||||
The configured client source directory is scanned non-recursively. Immediate
|
||||
regular files ending in `.conf` (ASCII case-insensitive) are candidates; other
|
||||
names and nested directories are ignored. Symlinks and non-regular files are
|
||||
not read. Each candidate is capped at 1 MiB, a scan at 4,096 directory entries,
|
||||
1,024 candidates, and 32 MiB of input. File/open/read/size errors are reported
|
||||
by safe category and do not stop other candidates. A directory or enumeration
|
||||
failure and any per-file read/limit failure mark the scan incomplete. Initial setup returns
|
||||
the encrypted in-memory bundle and report; installing it and committing remain
|
||||
separate operations. Explicit later scans encrypt to the existing public
|
||||
recipient and preserve the protected identity. Source files are never modified.
|
||||
If a later import finds an existing public-key record, the prior ciphertext is
|
||||
preserved and the report identifies that duplicate; the scanner cannot establish
|
||||
plaintext equality without decrypting the prior record, so it does not claim the
|
||||
files are equal.
|
||||
|
||||
## Limits and failure behavior
|
||||
|
||||
- Serialized TOML bundle: 16 MiB maximum.
|
||||
- Records: 4,096 maximum.
|
||||
- One encrypted record: 64 KiB maximum, before Base64 encoding.
|
||||
- Password-encrypted identity: 16 KiB maximum, before Base64 encoding.
|
||||
- The serialized representation must fit within the bundle limit.
|
||||
|
||||
Malformed/truncated ciphertext, a recipient that differs from the protected
|
||||
identity, unknown versions, missing records, and relabelled records return
|
||||
secret-free typed errors. An unsupported decrypted record schema returns the
|
||||
version error, while a payload whose label does not match the requested public
|
||||
key returns the record mismatch error. There is no fallback to plaintext files.
|
||||
The bundle loader does not unlock the identity, so backup and restore require no
|
||||
password.
|
||||
|
||||
## Secret lifetime during encoding
|
||||
|
||||
New-client encryption needs only the public age recipient. Retrieval constructs
|
||||
an age scrypt identity for one call, decrypts the protected x25519 identity,
|
||||
verifies it against the public recipient, decrypts one record, and drops the
|
||||
temporary identity and plaintext on return. The application API does not expose
|
||||
an unlocked vault identity or retain a password between calls.
|
||||
|
||||
The WireGuard key's explicit Base64 representation and the record plaintext
|
||||
containing it must exist briefly in process memory before age can encrypt them.
|
||||
Age also creates and buffers its file key, stream state, and output ciphertext.
|
||||
These copies are inherent to the selected age streaming API; no plaintext is
|
||||
written into the TOML bundle. The in-memory private-key type, passphrase type,
|
||||
and temporary plaintext buffers use zeroizing secret containers where supported.
|
||||
Age's standard passphrase format uses its normal scrypt work factor; the
|
||||
application does not override or weaken it. The caller remains responsible for
|
||||
keeping the input password string short-lived.
|
||||
|
||||
The password protects only the vault identity. It does not encrypt server
|
||||
configuration, retained original client files, the entire backup archive, or
|
||||
WireGuard server secrets stored elsewhere.
|
||||
Reference in New Issue
Block a user