Files
wireguard-admin/docs/VAULT_FORMAT.md
T

6.8 KiB

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:

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:

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.