Security Reference
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)flagsbit 0 in the vault entry header is set to 1.unsealauto-detects the mode from that flag — no--portableflag 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 blobPer-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 tagN 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.