{"id":51008398,"url":"https://github.com/voidd0/tells-encryption-spec","last_synced_at":"2026-06-20T23:31:01.618Z","repository":{"id":355200134,"uuid":"1227187246","full_name":"voidd0/tells-encryption-spec","owner":"voidd0","description":"Public spec of tells' AES-256-GCM + HKDF + AAD encryption","archived":false,"fork":false,"pushed_at":"2026-05-02T10:31:33.000Z","size":5,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-02T12:24:01.055Z","etag":null,"topics":["aead","aes","encryption","gdpr","hkdf","privacy","security","tells"],"latest_commit_sha":null,"homepage":null,"language":null,"has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/voidd0.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"custom":["https://voiddo.com/contact/","https://scrb.voiddo.com/"]}},"created_at":"2026-05-02T10:31:30.000Z","updated_at":"2026-05-02T10:31:36.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/voidd0/tells-encryption-spec","commit_stats":null,"previous_names":["voidd0/tells-encryption-spec"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/voidd0/tells-encryption-spec","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/voidd0%2Ftells-encryption-spec","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/voidd0%2Ftells-encryption-spec/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/voidd0%2Ftells-encryption-spec/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/voidd0%2Ftells-encryption-spec/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/voidd0","download_url":"https://codeload.github.com/voidd0/tells-encryption-spec/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/voidd0%2Ftells-encryption-spec/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34589204,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-20T02:00:06.407Z","response_time":98,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aead","aes","encryption","gdpr","hkdf","privacy","security","tells"],"created_at":"2026-06-20T23:31:00.552Z","updated_at":"2026-06-20T23:31:01.613Z","avatar_url":"https://github.com/voidd0.png","language":null,"funding_links":["https://voiddo.com/contact/","https://scrb.voiddo.com/"],"categories":[],"sub_categories":[],"readme":"# tells-encryption-spec\n\n\u003e Public specification of the at-rest encryption used by [tells](https://tells.voiddo.com) — text-first analysis for what people leave unsaid.\n\nThis repository documents — exactly — the cryptographic primitives that\nprotect tells user data at rest. It is published so privacy-conscious users,\npractitioners, journalists, and security researchers can verify our claims\nwithout needing access to the application source.\n\nThe implementation lives in the (private) tells backend. This repo is the\nspec; the spec is the contract.\n\n## Why publish the spec, not the code\n\nWe split the privacy surface from the business surface:\n\n- **Public** — security primitives, prompt templates, cultural framing files.\n- **Private** — backend application code, database schemas, business logic.\n\nYou can audit what we promise; we keep what we built. This repo is one of the\nthree public privacy components:\n\n- [voidd0/tells-encryption-spec](https://github.com/voidd0/tells-encryption-spec) — this repo.\n- [voidd0/tells-prompt-templates](https://github.com/voidd0/tells-prompt-templates) — the public prompt contracts behind tells analysis.\n- [voidd0/tells-cultural-framing](https://github.com/voidd0/tells-cultural-framing) — per-language framing layer.\n\n## Specification — version 1.0 (effective 2 May 2026)\n\n### 1. Algorithm\n\n**AES-256-GCM** (authenticated encryption with additional data, AEAD).\n\n- 256-bit key → AES-256 block cipher\n- 96-bit nonce → mandatory GCM mode parameter\n- 128-bit auth tag → tamper detection\n\nGCM was chosen over alternatives (XChaCha20-Poly1305, ChaCha20-Poly1305) for\necosystem maturity, native AES-NI hardware acceleration on the production VPS,\nand the Python `cryptography` library's `AESGCM` wrapper exposing exactly the\ncontract we need.\n\n### 2. Master encryption key\n\nThe master key is a single 32-byte (256-bit) value stored in the\n`MASTER_ENCRYPTION_KEY` environment variable on the production server.\n\n**Storage rules:**\n\n- Stored in the env var only.\n- **Never** persisted to the database.\n- **Never** committed to any repository (this one or the private backend).\n- **Never** logged.\n- Backed up out-of-band by the operator only.\n\nIf both the database and the master key were leaked simultaneously, encrypted\ndata would be decryptable. The threat model (see\n[tells.voiddo.com/legal/threat-model](https://tells.voiddo.com/legal/threat-model))\nexplicitly addresses this: an attacker needs both.\n\n### 3. Per-user key derivation — HKDF-SHA256\n\nEach user has their own 32-byte AES key, **derived from** the master key:\n\n```\nper_user_key = HKDF-SHA256(\n    ikm  = master_key,                # 32 bytes from env var\n    salt = utf8(user_uuid),           # canonical lowercase UUID string\n    info = b\"tells:patterns:user-key:v1\",\n    length = 32,\n)\n```\n\nDerivation is deterministic: given the same master key and the same user UUID,\nthe same per-user key is produced. This means we never store per-user keys —\nthey are recomputed when needed and discarded.\n\nThe `info` string namespaces the derivation to the per-user-patterns purpose;\nfuture derivations for unrelated purposes get distinct `info` strings so\nkey separation is preserved.\n\n### 4. AAD — Additional Authenticated Data\n\nEvery ciphertext binds three context fields via AES-GCM's AAD parameter:\n\n```\naad_dict = sorted({\n    \"u\": str(user_id),\n    \"p\": str(tracked_person_id),    # if applicable\n    \"t\": iso_8601(created_at),       # if applicable\n})\naad_bytes = utf8(json_dumps(aad_dict, sort_keys=True, separators=(',', ':')))\n```\n\nAAD is supplied at encryption time and **must be reconstructed identically**\nat decryption time — if any field has drifted, the GCM auth check fails and\ndecryption raises `InvalidTag`.\n\nThis protects against:\n\n- **Cross-user ciphertext shuffling** — an attacker swapping rows between\n  users. The user_id in AAD changes; auth check fails.\n- **Tracked-person re-pointing** — re-attributing one subject's snapshot to\n  a different subject within the same user.\n- **Timestamp spoofing** — re-dating a snapshot to look fresher or older.\n\n### 5. Nonce\n\nEvery encryption operation generates a fresh **12-byte (96-bit) random nonce**\nvia `os.urandom(12)`. Nonces are never reused under the same key.\n\nThe output ciphertext is the URL-safe base64 of `nonce(12) || ciphertext_with_tag`.\n\n### 6. Storage envelope\n\nEncrypted JSON payloads are wrapped in a versioned envelope so the\nplaintext-mode and encrypted-mode storage rows share the same JSONB column\nshape:\n\n```json\n{ \"_enc\": \"\u003curlsafe-base64 nonce + ciphertext + tag\u003e\", \"_v\": 2 }\n```\n\n- `_v: 2` — the AAD-bound, per-user-key envelope (this spec).\n- `_v: 1` (or absent) — legacy envelope using the master key directly. Read\n  path falls back to the legacy decryption automatically; new writes use v2.\n\nPlaintext rows (no opt-in, or pre-opt-in legacy data) hold the raw JSON value\nand are returned as-is by the decrypt path.\n\n### 7. Master key rotation\n\nThe master key may be rotated on a 90-day cadence. Rotation is performed by:\n\n1. Generating a new 32-byte master key.\n2. Walking every encrypted row.\n3. Decrypting each row's ciphertext under the **old** master.\n4. Re-deriving the per-user key under the **new** master.\n5. Re-encrypting under the new per-user key, preserving the same AAD.\n6. Writing the new envelope back.\n\nOnce all rows have been re-encrypted, the old master is destroyed.\n\nThe rotation orchestrator is storage-agnostic: it accepts an iterable of\n`(descriptor, envelope, context, write_back)` tuples so the caller drives DB\niteration. Counters returned: `{rotated, skipped_plain, v1_legacy}`.\n\n### 8. Cryptographic deletion (GDPR Article 17)\n\nWhen a user requests account deletion, the per-user HKDF salt — the user UUID\nitself — is destroyed by the user-row hard-delete. Without the salt,\n`derive_user_key` cannot reproduce the original per-user key. Any retained\nciphertext (e.g. in a backup) becomes unrecoverable.\n\nThe deletion audit log records:\n\n- The deletion timestamp.\n- The SHA-256 fingerprint of the destroyed user UUID — non-reversible, but\n  stable enough that a future audit can verify the deletion took place if the\n  same UUID surfaces in a backup.\n- The crypto scheme: `\"HKDF-SHA256 + AES-256-GCM\"`.\n\nThe user UUID itself is **never** retained in the audit log.\n\n### 9. Out of scope (v1)\n\nThis spec defends against the eight threats enumerated in the\n[tells threat model](https://tells.voiddo.com/legal/threat-model). It does\n**not** defend against:\n\n- Government-level adversaries (NSA-class actors).\n- Compromise of the upstream model provider at provider level.\n- Side-channel attacks on model-provider response timing.\n- Hardware-level extraction of the master key from the VPS.\n\nIf your threat model includes any of the above, tells is not the right tool.\n\n### 10. Audit path\n\nWithin 90 days of public launch, an external freelance security auditor\nverifies this spec against the running backend. The audit report will be\npublished at `tells.voiddo.com/legal/audit-2026.html`. Subsequent annual\naudits maintain the trust signal.\n\n## Reporting issues\n\nCryptographic concerns: open an issue on this repo, or email\n[hi@voiddo.com](mailto:hi@voiddo.com). Disclosure SLAs: 48-hour\nacknowledgement, 14-day fix-or-explain, public diff to this spec when the\nimplementation changes.\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n\n---\n\nBuilt by [vøiddo](https://voiddo.com/) — a small studio shipping AI-flavoured products, free dev tools, Chrome extensions and weird browser games.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvoidd0%2Ftells-encryption-spec","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fvoidd0%2Ftells-encryption-spec","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fvoidd0%2Ftells-encryption-spec/lists"}