Security · keepsake/v1
Security
The security boundary is the vault file. keepsake seals your memory with standard, well-understood primitives and runs entirely on your machine, but a security page that overclaims is worse than none. This page states the threat model, the exact construction, and the limits plainly.
Threat model
keepsake protects the contents of your memory at rest on a machine you control, and makes any change to that memory detectable. It is not an access-control system: anyone who has the file and the passphrase has the full plaintext.
What it protects against
- Confidentiality at rest. The plaintext is sealed with AES-256-GCM. Without the passphrase, the ciphertext is unreadable.
- Tampering. The GCM tag, the per-cell SHA-256 hashes, and the Merkle root make a modified, truncated, or reordered vault fail to open or verify.
- Network exposure. There is no server, no account, no network call, and no telemetry. Importing, searching, sealing, and opening run in process.
- Key escrow. The passphrase is never stored in the vault and the reference implementation never writes it to disk. No third party holds a key.
What it does not protect against
- An attacker with the passphrase. A guessed or stolen passphrase is a full read. There is no recovery and no second factor, and a weak passphrase can be brute-forced offline.
- A compromised host or browser session. Once decrypted, plaintext lives in memory and can be read by malware, a debugger, or a malicious extension.
- Metadata. The container is not encrypted. The format version, creation time, cell count, KDF parameters, and the exact ciphertext length (which approximates the plaintext length) are visible to anyone with the file.
- The Merkle commitment. The root is public: equal roots mean equal cell sets, and a low-entropy cell can be guessed and confirmed by recomputing its hash.
- Destination and copies. Where you store, sync, or send the file — a cloud drive, a backup, an email — is outside the model. Copies cannot be recalled.
- Loss, forward secrecy, and revocation. A lost passphrase means a lost vault. One long-lived passphrase protects every generation, and there is no re-wrap in place.
The cryptographic construction
Sealing draws a fresh random salt and IV, derives a key from the passphrase, and encrypts the canonical JSON of the cells array. The plaintext includes every cell, with its hash field, exactly as stored. There is no additional authenticated data.
plaintext = UTF-8( canonicalJson( cells[] ) )
key = PBKDF2-SHA256(passphrase, salt, iterations, dkLen = 32)
ciphertext = AES-256-GCM-Encrypt(key, iv, plaintext, tagLength = 128)
vault.ciphertext = base64( ciphertext · tag )
| Parameter | Value |
|---|---|
| KDF | PBKDF2-HMAC-SHA-256 |
| KDF salt | 16 random bytes per vault (crypto.getRandomValues) |
| KDF iterations | 250000 default; a reader honors the stored value and never substitutes its own |
| Derived key | 256 bits (32 bytes) |
| Cipher | AES-256-GCM |
| IV | 12 random bytes per encryption; never reused under the same key |
| Tag length | 128 bits (16 bytes), appended to the ciphertext |
| AAD | none |
| Ciphertext encoding | Base64, standard alphabet with padding |
| Plaintext | UTF-8 canonical JSON of the cells array (keys sorted, no insignificant whitespace) |
Integrity and the Merkle root
AES-256-GCM authenticates the ciphertext: a modified, truncated, or reordered file fails the tag check. A second, independent check covers the plaintext. Each cell's hash is the SHA-256 of its canonical payload (the six fields other than hash), and the vault's merkle is a SHA-256 tree over the sorted list of cell hashes. Internal nodes hash the concatenation of two lowercase-hex children; an odd node is carried up unchanged rather than duplicated.
merkle = SHA-256 tree over sorted, lowercase-hex cell hashes
empty vault root = sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Because the Merkle root commits to the plaintext rather than the ciphertext, it is re-derived after decryption and compared against the stored root. That is an independent check on the cell contents: it does not rely on the GCM tag, and it catches a plaintext change even if the ciphertext still decrypts.
The passphrase
The passphrase is never accepted on the command line. A flag would land in your shell history and in the process table. The CLI reads it only from the environment, and the reference implementation never writes it to disk. There is no recovery: a lost passphrase means a lost vault.
| Variable | Used by |
|---|---|
KEEPSAKE_PASSPHRASE | Every command that opens a vault — import, search, export, verify, stats, mcp, context, forget, pack, unpack, and the first vault of merge and diff. |
KEEPSAKE_PASSPHRASE_B | The second vault in merge and diff. If it is unset, the same value as KEEPSAKE_PASSPHRASE is used for both. |
KEEPSAKE_NEW_PASSPHRASE | rotate: the new passphrase. The current passphrase still comes from KEEPSAKE_PASSPHRASE. |
KEEPSAKE_PASSPHRASE='correct horse battery staple' keepsake export --vault memory.keepsake
KEEPSAKE_PASSPHRASE='old' KEEPSAKE_NEW_PASSPHRASE='new' keepsake rotate --vault memory.keepsake
Offline posture
keepsake contains no network code. It never opens a socket, uploads a vault, fetches a remote resource, or checks for updates, and it collects no telemetry about your vaults, usage, or machine. The vault is opened in process: the CLI and the MCP server read the file directly and make no network calls.
The browser app's optional semantic recall is self-hosted. It ships the Transformers.js v3 runtime, the Xenova/all-MiniLM-L6-v2 model at dtype: q8, and the ONNX Runtime Web WASM binaries from its own assets/, models/, and wasm/ directories, served from the same origin. The app sets env.allowLocalModels = true and env.allowRemoteModels = false, so no memory or query leaves the machine and no external request is made. The feature is optional; the CLI and the MCP server stay lexical (BM25) and do not load a model at all.
What we validate
- The vault parses as UTF-8 JSON and declares
format: keepsake/v1,kdf.name: PBKDF2-SHA256, andcipher.name: AES-GCM. - The AES-256-GCM tag verifies on decrypt. A wrong passphrase, truncation, or modification fails with an error and never emits partial plaintext.
- Every cell's recomputed SHA-256 hash equals its stored
hash. - The Merkle root of the cell hashes equals the vault's
merkle. - The published conformance vectors: L1 parse, L2 integrity, and L3 crypto round-trip, run by
keepsake conformance. - In CI, the composite action in
action.ymlrunskeepsake verifyagainst a committed vault and fails the job when any check fails.
What we do not validate
- The container metadata. The base64 container and
createdAtare informational and are not covered by the GCM tag. - Passphrase strength. The format cannot tell a strong passphrase from a weak one; PBKDF2 raises the cost of guessing but cannot rescue a weak secret.
- Authorship. There are no signatures or sender identities. A vault does not prove who sealed it.
- Freshness. There is no anti-rollback: an attacker who replaces a vault with an older valid one is not detected.
- Metadata secrecy. Format, creation time, cell count, KDF parameters, ciphertext length, and the Merkle root are all visible.
- The plaintext in memory. Once opened, cells are ordinary in-memory strings; a memory pack written by
packis plaintext and not encrypted. - Recovery, forward secrecy, and revocation. No escrow, no recovery path, no per-generation keys, and copies cannot be recalled.
- Semantic recall. The browser embeddings are a convenience layered over the same cells; they are not part of the format and are not validated by a vault.
Reporting a vulnerability
The full policy — supported versions, scope, and the reporting process — lives in SECURITY.md in the repository. Report suspected vulnerabilities privately through GitHub Security Advisories. Do not open a public issue.