Files
wireguard-admin/docs/VAULT_FORMAT.md
T

133 lines
6.8 KiB
Markdown

# 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
The current encrypted bundle lives 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. The application installs the opaque serialized bytes through
the saved-file transaction after completing its mandatory pre-Commit backup.
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.