# Ticket #000063 — Cold-object private-ciphertext mode (mesh-keyed)

**Status:** open · awaiting go/no-go · scaffold-only
**Opened:** 2026-05-26
**Scope:** add a private mode to the #000061 cold-object pack format so
  chunk bodies + manifest can be uploaded to a public-read bucket
  without leaking corpus membership or document content. Object keys
  derive from a mesh group key via HMAC, not from plaintext leaf_hash;
  bodies are ciphertext, manifest is encrypted or access-controlled.
**Audience:** dav1d (cold-object security model extension; spec is the
  load-bearing artifact, not code).
**Hard constraint:** the integrity invariant from #000061 stays —
  verifier recovers plaintext bytes, hashes via `hash_leaf`, compares
  to local `chunks.leaf_hash`. Encryption sits between disk and bucket;
  inside the verifier path, behavior is identical to public mode.

## Problem

#000061 ships `public_plaintext` mode: object key = `leaf_hash`, body =
raw UTF-8 plaintext. Safe for public-redistributable corpora (Wikipedia,
PG textbooks). **Not safe** when the bucket has any chunk whose source
is private, licensed, or membership-sensitive:

- `object_key = sha256(0x00 ‖ plaintext)` is a membership oracle. An
  attacker who guesses a chunk's plaintext can probe the bucket and
  learn whether that chunk is in the corpus.
- Plaintext bodies on a public-read bucket are a redistribution of
  every chunk to the world.

#000061 v3 sidesteps this by refusing to push private content to a
public bucket (#000061 Gap 2 — `license_class` policy gate). This
ticket adds the *other* path: ship private content under proper
ciphertext, so the bucket can stay public-read without leaking.

## Design (spec only — no code yet)

Two keying strategies:

**Strategy A — deterministic-key ciphertext** (preferred for indexing):

```
plaintext_leaf_hash = chunks.leaf_hash                # local DB value
object_key          = "blobs/" + HMAC(group_key, plaintext_leaf_hash)
ciphertext_body     = AEAD-encrypt(group_key, plaintext_bytes,
                                   aad = plaintext_leaf_hash)
```

- Deterministic key allows lookup-by-leaf_hash without a separate
  manifest fetch (given the group key).
- AAD binds the ciphertext to the plaintext identity.
- Bucket can be public-read; without `group_key`, neither key nor body
  reveals membership.

**Strategy B — random-key ciphertext + manifest map** (stronger
membership hiding):

```
ciphertext_body = AEAD-encrypt(group_key, plaintext_bytes)
ciphertext_hash = sha256(ciphertext_bytes)
object_key      = "blobs/" + ciphertext_hash
private_manifest binds plaintext_leaf_hash -> object_key
private_manifest itself encrypted with group_key
```

- Membership requires both the encrypted manifest AND the group key.
- Lookup requires manifest fetch; one extra round-trip.
- Defeats prefix-enumeration of `blobs/`.

Strategy A is the default; Strategy B is opt-in for the most sensitive
corpora.

### Mesh integration

The group key lives where it already lives for arborist:
`arborist/mesh/crypto.py` (group-key state machine, epoch rotation,
member wrap/unwrap). This ticket plumbs that key into the pack flow:

- Producer: `push_pack(..., mode='private', mesh_conn=...)` reads the
  current epoch's group key, uses it for HMAC + AEAD.
- Consumer: `hydrate_from_metadata_pack(..., mesh_conn=...)` reads the
  same key, decrypts/verifies.
- Key rotation: epoch advance means old packs use old key. Pack
  manifest carries `epoch_id` so consumers know which key to use.

### Verifier path unchanged

After decryption, the consumer holds plaintext bytes. From that point
on, the cold-object pull path is **byte-identical** to public mode:
`hash_leaf(plaintext) == leaf_hash`, restore into `chunks.content`,
populate FTS5, etc. Ciphertext does not enter `chunks.content`.

## Why scaffold-only

This ticket lands when:

1. There's a real corpus with non-public content that arborist needs
   to ship via cold-object. (No use case today — fox's corpora are
   Wikipedia + open textbooks.)
2. The mesh group-key ABI is stable enough to be referenced by pack
   code. (Today the mesh state lives behind `arborist/mesh/` with its
   own state machine.)
3. The threat-model split between Strategy A and Strategy B is settled
   by a real adversary scenario, not a paper one.

Open as scaffold so the design is in the log; defer code until at
least #1 lands.

## Out of scope

- Server-side encryption (KMS / SSE-S3). That's bucket-level
  encryption-at-rest, orthogonal to membership hiding. Mesh ciphertext
  is the client-side path that survives a hostile bucket.
- Key rotation logistics beyond reading the current epoch_id from
  `mesh_epochs`. Old packs stay decryptable with old keys (already a
  mesh primitive).
- Public/private mixed-mode shards. A shard is one mode or the other.

## Status

Scaffold. No code expected until the prerequisites above land.
