134 lines
6.8 KiB
Markdown
134 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
|
|
|
|
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.
|