Security Reference

v1.0.0 — archived Earlier v0.x (Zig) docs →

Archived: Amulet is no longer maintained and is not recommended for new production use. This lifecycle decision does not assert a specific known vulnerability in the cryptographic implementation. Existing users should follow the migration guide.

Security boundary

Amulet may reduce accidental exposure by encrypting secret values at rest and keeping plaintext out of ordinary project files and command arguments. It does not solve secret zero. It cannot protect decrypted secrets from same-host or same-user access, root, the Docker socket, process memory, plaintext output, or access to the unlock credential. An AI agent that can invoke Amulet or access the decryption credential can read the secret. A compromised CI job can do the same when a passphrase or identity key is injected into that job.

Modes

Locked Mode (default)

The vault entry can be decrypted when the passphrase and machine_id input match those used when sealing. The machine identifier is mixed into the Argon2id password input together with the passphrase:

AEAD key = Argon2id(passphrase ‖ 0x00 ‖ machine_id, salt)
OS Machine ID source
Linux /etc/machine-id (fallback: /var/lib/dbus/machine-id)
macOS IOPlatformUUID via ioreg
Windows HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid via reg query

Stability: survives reboots, not OS reinstalls (Linux) or logic board swaps (macOS); on Windows, MachineGuid usually survives hardware changes but may change on a clean OS install or image restore.

The machine ID is an identifier, not a secret. Locked mode mainly adds a boundary when the vault file alone reaches a host with a different machine ID. It does not protect against same-host access, duplicate IDs, or a party that has both the vault and unlock inputs.

Portable Mode (--portable on seal)

machine_id is not mixed into the Argon2id password input — the passphrase alone (with salt) derives the key, on any machine. The passphrase is the primary cryptographic boundary for Portable entries.

AEAD key = Argon2id(passphrase, salt)
  • flags bit 0 in the vault entry header is set to 1.
  • unseal auto-detects the mode from that flag — no --portable flag is accepted on unseal.
  • A warning is printed to stderr at seal time because security is reduced.

Portable mode may be needed for recovery and migration across machines. If a CI job receives the passphrase or identity key, Amulet does not protect against compromise of that job. See deployment for operational boundaries.


Vault file format

A vault file is a flat sequence of entries. There is no global file header; an empty file is a valid empty vault.

Outer entry envelope (repeated for each stored key):

[2 byte big-endian]  key name length
[key name length]    key name (plaintext)
[4 byte big-endian]  blob length
[blob length]        encrypted blob

Per-entry encrypted blob (v2, current):

[1 byte]  version  = 0x02
[1 byte]  flags    (bit 0 = portable mode)
[16 byte] Argon2id salt  (CSPRNG random, per seal)
[24 byte] XChaCha20-Poly1305 nonce (CSPRNG random, per seal)
[4 byte]  ciphertext length (big-endian u32)
[N byte]  ciphertext (N = length from the preceding field)
[16 byte] Poly1305 authentication tag

N is the byte length of the ciphertext field only; the Poly1305 tag is not included. Ciphertext length equals plaintext length, so N is also the size of the sealed secret in memory.

Backward compatibility: v1 blobs (version = 0x01, ChaCha20-Poly1305, 12-byte nonce) sealed by Amulet v0.x are transparently supported on unseal. New seals always produce v2.

Key names are stored in plaintext in the outer envelope. Only the secret value is encrypted.


Crypto spec

Item Spec
KDF Argon2id (m=64 MiB, t=3, p=1)
Encryption XChaCha20-Poly1305 (AEAD)
Key length 256 bit (32 bytes)
Salt 16-byte CSPRNG, generated per seal, stored in vault entry
Nonce 24-byte CSPRNG, generated per seal, stored in vault entry, never reused
AAD version byte — format change detection

Implementation controls

Principle Implementation
Vault storage Sealed values are stored in the vault rather than as plaintext values there
Silent failure Any decryption error → no stderr output, exit code 1
Reduced diagnostic exposure Designed not to include secret values, machine_id, or key material in its own logs and errors
Buffer erasure zeroize applied to secret buffers before drop
Stdin only Secret values are never accepted via argv or environment variables
Symlink protection Vault opened with O_NOFOLLOW on POSIX
File permissions Vault created with mode 0600 on Unix

Threat model

Threat Mitigation
Accidental repository-file exposure Values are encrypted in the vault instead of stored as ordinary plaintext project files
Process list / argv sniffing Secret read from stdin, not argv
Locked vault copied by itself to a host with a different machine_id Argon2id includes machine_id in Locked Mode
Weak passphrase Argon2id with 64 MiB memory cost
Cold-boot / memory dump zeroize after use; mlock on secret buffers; minimal heap exposure
Log injection / exfiltration No logging of secret material; silent failure
Symlink attack on vault file O_NOFOLLOW on open
Nonce reuse Fresh CSPRNG nonce per seal call

VM clones: Amulet treats any two hosts sharing the same machine_id as equivalent. A vault sealed on one instance can be decrypted on any clone with a duplicate ID. Regenerate machine-id on each instance after cloning. See deployment.md for details.

Out of scope: same-host or same-user access, root, Docker-socket control, agents with unlock access, compromised CI jobs, plaintext capture after unseal, and downstream compromise. Encryption at rest remains useful, but Amulet must not be treated as the sole production secret-management mechanism.