Format · keepsake/v1
The keepsake/v1 format
keepsake is an open, encrypted, portable format for personal AI memory. A memory is a Cell; a Vault is a single JSON file that holds any number of cells, sealed with authenticated encryption, plus a Merkle root that commits to the exact plaintext. There is no server, no account, and no network call.
Abstract
This document specifies the wire format, the canonical encoding, the hashing and Merkle rules, the key-derivation and encryption parameters, import rules, and three conformance levels. Everything here is normative unless marked otherwise. The format identifier is exactly keepsake/v1.
Goals
- Ownership. A vault is one file the user can copy, back up, and carry between tools. No vendor holds the only copy.
- Portability. The format is self-describing JSON with documented algorithms, so any implementation can read it without the reference code.
- Confidentiality. The plaintext never touches disk unencrypted by default; the vault leaks only a small, documented amount of metadata.
- Integrity. A vault detects any change to the ciphertext, and a cell hash and Merkle root detect any change to the plaintext.
- Determinism. Two implementations given the same cells, passphrase, salt, and IV produce byte-identical plaintext and ciphertext.
Non-goals
- Not a semantic memory. Version 0.1 has no embeddings, vector search, or model inference. Recall is exact and lexical.
- Not a sync protocol. Version 0.1 defines a file, not a service or a multi-writer merge.
- Not a key-management system. Version 0.1 has no key rotation, no escrow, and no recovery path.
- Not an access-control system. Anyone with the file and the passphrase has the full plaintext.
- Not a general archive format. It stores cells, not files or arbitrary blobs.
Terminology
| Term | Meaning |
|---|---|
| Cell | One unit of memory: an id, text, source, role, timestamp, tags, and a content hash. |
| Payload | A cell without its hash field: the six fields that are hashed. |
| Vault | The sealed file defined here, with the .keepsake extension by convention. |
| canonical JSON | The deterministic JSON encoding defined below. |
| Merkle root | A single SHA-256 hash that commits to the vault's cell hashes, independent of cell order. |
| KDF | Key-derivation function; here always PBKDF2-SHA256. |
| AEAD | Authenticated encryption with associated data; here always AES-256-GCM. |
The Cell
A cell is a JSON object with exactly seven members.
{
"id": "3f7c9d2e-1a4b-4c8d-9e0f-1a2b3c4d5e6f",
"text": "I want to move my chat history out of the cloud.",
"source": "chatgpt-export",
"role": "user",
"createdAt": "2026-01-05T09:12:00.000Z",
"tags": ["privacy", "memory"],
"hash": "32003353cb5403744610e2570bd435030b4aaa5d930e272c8723a382f7526516"
}
| Member | Type | Rules |
|---|---|---|
id | string | A UUID, canonical lowercase hyphenated form (8-4-4-4-12). Version 4 is recommended for generated ids. Unique within a vault. |
text | string | The cell content. UTF-8. MUST be non-empty after trimming. Imports MUST normalize to NFC and trim surrounding whitespace. |
source | string | A stable, human-meaningful identifier such as chatgpt-export or notes.md. MUST be non-empty. MUST NOT contain an absolute path or machine-local identifier. |
role | string | One of user, assistant, note, system. Case-sensitive. |
createdAt | string | An ISO 8601 timestamp in UTC with millisecond precision, exactly YYYY-MM-DDTHH:MM:SS.sssZ. |
tags | array of string | Zero or more tags. Each MUST be non-empty after trimming and unique within the array. Order is significant and preserved. |
hash | string | Lowercase hex SHA-256 (64 characters) of the canonical payload. |
A cell with no tags uses an empty array, never null or a missing member.
The canonical payload
The canonical payload of a cell is its six members other than hash: { id, text, source, role, createdAt, tags }. The hash is SHA-256(canonicalJson(payload)), encoded as lowercase hex.
{
"id": "8b1e5a7c-2d3f-4a6b-8c9d-0e1f2a3b4c5d",
"text": "Portable memory means one encrypted file that you control.",
"source": "chatgpt-export",
"role": "assistant",
"createdAt": "2026-01-05T09:12:04.000Z",
"tags": ["memory"]
}
Canonical JSON (keys sorted, no whitespace):
{"createdAt":"2026-01-05T09:12:04.000Z","id":"8b1e5a7c-2d3f-4a6b-8c9d-0e1f2a3b4c5d","role":"assistant","source":"chatgpt-export","tags":["memory"],"text":"Portable memory means one encrypted file that you control."}
Hash:
e7aef9cdcd7d685e874fff697be0357afa79fc5b47f3c83ca57d288261128ab5
Canonical JSON
canonicalJson(value) is a deterministic encoding of a JSON value. It is the exact string that is hashed and encrypted, so every implementation must produce the same bytes.
- Objects. Members are emitted in ascending order of their key, compared by Unicode code point. Members whose value is
undefinedare omitted. There is no insignificant whitespace. - Arrays. Element order is preserved. Arrays may be empty (
[]). - Strings. Encoded with the standard JSON escapes; non-ASCII characters are emitted literally as UTF-8.
- Numbers. Emitted as JSON numbers with no leading zero and no trailing fraction zero. Only integers appear in the fields this format defines.
- Booleans and null. Emitted as
true,false, andnull. - undefined. Has no JSON form. As an object member it is omitted, and as an array element it becomes
null.
The sort is over the raw key strings, not a locale-aware comparison. Reference implementation:
function canonicalJson(value) {
if (value === null || value === undefined) return "null";
if (typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
}
Hashing
SHA-256 is the only hash. It is applied to UTF-8 bytes and rendered as lowercase hex with no 0x prefix and no separators.
- Cell hash:
sha256(canonicalJson(payload)). - Empty hash: the SHA-256 of the empty byte string, used as the Merkle root of an empty vault.
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
The Merkle root
The Merkle root commits to the set of cell hashes.
- Take the
hashof every cell as a lowercase hex string. - Sort the list of hex strings in ascending lexicographic order. The root does not depend on cell order in the vault.
- While more than one value remains: walk the list left to right, taking adjacent pairs; for a pair
(left, right), computesha256(left + right), concatenating the two hex strings and hashing the ASCII text as UTF-8. If the list has an odd length, the final value is carried to the next level unchanged, not duplicated. - The single remaining value is the root, lowercase hex.
An empty cell set has root sha256(""). Worked example using the three hashes from the vectors:
32003353cb5403744610e2570bd435030b4aaa5d930e272c8723a382f7526516
e7aef9cdcd7d685e874fff697be0357afa79fc5b47f3c83ca57d288261128ab5
c2ead5e68cafbe7bb91ee118aa5ac67dffe1e3981a11ddfad2e47fe6a5d140ff
Sorted, the sequence becomes 32003353…, c2ead5e6…, e7aef9cd…. The pair (32003353…, c2ead5e6…) hashes to 7941ca79…, and the odd trailing e7aef9cd… is carried, so the final root is:
4e540078fb922de39e689dcf86a9828e4e274986fd6fe5a03da2333724c96d2d
The Vault container
A vault is a UTF-8 JSON file with exactly seven members.
{
"format": "keepsake/v1",
"createdAt": "2026-03-01T12:00:00.000Z",
"cells": 3,
"kdf": {
"name": "PBKDF2-SHA256",
"iterations": 100000,
"salt": "AAECAwQFBgcICQoLDA0ODw=="
},
"cipher": {
"name": "AES-GCM",
"iv": "AAECAwQFBgcICQoL"
},
"ciphertext": "…base64, includes the 16-byte GCM tag…",
"merkle": "4e540078fb922de39e689dcf86a9828e4e274986fd6fe5a03da2333724c96d2d"
}
| Member | Type | Rules |
|---|---|---|
format | string | Exactly keepsake/v1. |
createdAt | string | ISO 8601 UTC with milliseconds. The time the vault was sealed. Informational. |
cells | integer | The number of cells in the plaintext array. Non-negative. MUST equal the decrypted array length. |
kdf | object | Key-derivation parameters, defined below. |
cipher | object | Cipher parameters, defined below. |
ciphertext | string | Base64 (standard alphabet, with padding) of the AEAD output: the ciphertext followed by the 16-byte GCM tag. |
merkle | string | Lowercase hex Merkle root (64 characters) of the plaintext cells. |
The kdf object
| Member | Rules |
|---|---|
name | Exactly PBKDF2-SHA256. |
iterations | Positive integer. The default is 250000; a reader MUST honor whatever value is present and MUST NOT silently substitute its own. |
salt | Base64 of exactly 16 random bytes. MUST differ between vaults. |
The cipher object
| Member | Rules |
|---|---|
name | Exactly AES-GCM. |
iv | Base64 of exactly 12 random bytes. MUST NOT be reused under the same key. |
Plaintext
The plaintext is the canonical JSON of the cells array, where each element is a complete cell including its hash, encoded as UTF-8. The array preserves the cell order the sealer chose; the Merkle root does not depend on that order.
plaintext = UTF-8( canonicalJson( [ cell0, cell1, … ] ) )
Key derivation and encryption
Sealing and opening use only standard primitives. Let P be the passphrase as UTF-8 bytes.
key = PBKDF2-SHA256(password = P,
salt = base64decode(kdf.salt),
iterations = kdf.iterations,
dkLen = 32)
ciphertext = AES-256-GCM-Encrypt(key, iv = base64decode(cipher.iv),
plaintext, tagLength = 128)
The output is the ciphertext with the 16-byte authentication tag appended, following the WebCrypto convention. No additional authenticated data is used, and the stored ciphertext is base64(ciphertext). Opening is the inverse: derive the same key, base64-decode the IV, and decrypt. A failed GCM tag check means the file is corrupt, truncated, or the passphrase is wrong; the reader MUST report that as an error and MUST NOT emit partial plaintext.
| Parameter | Value |
|---|---|
| KDF | PBKDF2-HMAC-SHA-256 |
| KDF salt | 16 random bytes per vault |
| KDF iterations | 250000 default; reader honors the stored value |
| Derived key | 256 bits (32 bytes) |
| Cipher | AES-256-GCM |
| IV | 12 random bytes per encryption |
| 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 |
Import rules
An importer turns a source (a chat export, a notes file, a JSONL dump) into cells. These rules keep imports deterministic and privacy-preserving.
- One record, one cell. Each source message, note, or line becomes exactly one cell. Do not merge or split.
- Identity. If the source provides a stable UUID, it MAY be used. Otherwise generate a UUIDv4. Ids MUST be unique within a vault.
- Text. Normalize to Unicode NFC, trim leading and trailing whitespace, and drop the record if the result is empty.
- Source. Set a short, stable origin label that does not reveal the local machine, for example
chatgpt-exportornotes.md. - Role. Map the source's speaker to
user,assistant, orsystem. Map anything that is not a chat turn tonote. Unknown roles becomenote. - Timestamp. If the source has a timestamp, parse it and emit UTC with milliseconds. If it has none, use the Unix epoch
1970-01-01T00:00:00.000Zso that re-running the import is reproducible. - Tags. Normalize each to NFC, trim, and lowercase. Drop empty tags and deduplicate. Preserve the first occurrence's order.
- Hash last. Compute
hashafter every other member is final. - Deduplicate. Two cells with the same
hashare the same content; an importer SHOULD drop exact duplicates and MUST keep cells that differ in any payload member. - No rewriting. Importing into an existing vault produces a new vault with a new Merkle root; it MUST NOT mutate a sealed vault in place.
Import is lossy by design: it records text and metadata, not attachments, images, or tool calls.
Conformance levels
An implementation declares which levels it supports. A higher level includes the lower ones. The conformance page runs all three against the published vectors.
-
L1 — Parse
Read a vault's structure without a passphrase: valid UTF-8 JSON,
formatiskeepsake/v1, the KDF and cipher parameters decode to the right byte lengths, the ciphertext is at least 16 bytes, andmerklematches^[0-9a-f]{64}$. -
L2 — Integrity
Given the passphrase, confirm the plaintext is exactly what the vault commits to: decryption succeeds, each cell's recomputed hash matches, ids are unique, and the Merkle root of the cell hashes equals the vault's
merkle. -
L3 — Crypto round-trip
Reproduce the exact bytes: opening the example vault yields a plaintext byte-for-byte equal to its
plaintextCanonicalJson, and re-encrypting with the same derived key and IV reproduces the ciphertext.
Security notes
This section is informative. It describes the security model and its honest limits; it is not a formal audit.
Passphrase handling
- The passphrase is never stored in the vault and is never written to disk by the reference implementation. There is no recovery: a lost passphrase means a lost vault.
- The passphrase is used only to derive the key in memory. It should not be logged, placed in shell history where avoidable, or passed on a command line when an environment variable or prompt is available.
- Use a high-entropy passphrase. PBKDF2 with 250,000 iterations raises the cost of guessing but cannot rescue a weak passphrase.
Keys and algorithms
- Keys are derived per vault from the stored salt. A fresh 16-byte salt and a fresh 12-byte IV MUST be drawn from a cryptographically secure random source for every encryption. Reusing an IV under the same key breaks AES-GCM and is catastrophic.
- AES-256-GCM authenticates the ciphertext: a modified, truncated, or reordered file fails the tag check. It does not authenticate the base64 container or the
createdAtmetadata, which are informational. - There is no key escrow, no server-side key, and no network fallback.
What the vault reveals
The container is not encrypted. An observer of the file can see the format version, the creation time, the cell count, the KDF parameters, and the exact ciphertext length, which approximates the plaintext length. The merkle root is also public: equal roots mean equal cell sets, and a low-entropy cell could be guessed and confirmed by recomputing its hash. Version 0.1 does not pad the plaintext.
Honest limits
- No semantic recall. Version 0.1 has no embeddings and no vector search; recall is lexical.
- No sync. There is no server, account, multi-writer merge, or conflict resolution.
- No key rotation. Changing the passphrase means opening the vault and sealing a new one; there is no re-wrap-in-place.
- No forward secrecy. A single long-lived passphrase protects every generation of the vault.
- No revocation. Copies cannot be recalled once they exist.
Report suspected vulnerabilities privately through GitHub Security Advisories. Do not open a public issue.