feat: content hashes on document versions + signed project export manifest
From the PR description
Rebase of #181 onto current main, with the manifest signing you asked for there.
What it does
Exports cannot currently prove that a document version is the file the workspace held. This stores a SHA-256 of every version's bytes at write time and adds a per-project manifest listing them, alongside the accept/reject trail. To check a file: shasum -a 256 lease.docx against the manifest.
Versions written before this shipped carry a null hash, so an old file set reads as unverifiable rather than as falsely verified.
Signing
The manifest carries a SHA-256 digest over its own body, meaning everything except digest and signature. Serialising sorts object keys, keeps array order, and emits no whitespace, so a recipient can recompute the digest from parsed JSON.
Set MANIFEST_SIGNING_KEY and that digest is signed. Optional rather than required, because Mike self-hosts and a deployment without key custody should still be able to export: it gets signature: null and the weaker guarantee. A malformed key fails the export with a clear error instead of quietly producing an unsigned manifest, which is the failure signing exists to prevent.
The signature is a raw Ed25519 signature over the bytes mike-project-manifest-v1, a NUL byte, then the digest bytes. Two consequences worth stating:
- The context prefix means a manifest signature cannot be replayed as a signature over some other object that hashes to the same 32 bytes, so one key can cover other object types later.
- There is no library to adopt. Any Ed25519 implementation checks it in three lines, shown in the README.
The public key is served at GET /manifest-signing-key, unauthenticated. Take the key from there rather than from the manifest: whoever edits a manifest can re-sign it with a key of their own, so the embedded copy shows consistency, never provenance.
Key material is a hex seed from openssl rand -hex 32, the same pattern as DOWNLOAD_SIGNING_SECRET.
Against current main
Not a git rebase. The branch on #181 predates the app.ts factory, the vitest suite, and the chatTools.ts to lib/chat/tools/ split, so I re-applied it by hand:
- Hashes at every byte-write site on current main, including the three that did not exist in June:
generateOfficeDoc, the library upload path, and the project upload path.library.tsrenames do not touch bytes, so no hash there. - Accept/reject resolution and assistant-edit reuse rewrite bytes in place, as they do today. Storage and Postgres cannot be written atomically, so the hash is cleared before the bytes change and set again after. An interrupted resolution leaves the version unhashed and therefore unverifiable, never hashed against content it no longer holds.
- Export route registered with the existing
exportLimiterandrequireMfaIfEnrolled, matching the account export routes. - Migration under
backend/migrations/with the current dated naming, nullable column, safe to re-run.
The diff adds 892 lines and removes 5. Nothing here rewrites existing behaviour.
Testing
Full suite 298 passed, 14 skipped. npm run build clean.
contentSha256against the FIPS 180-4 vector across Buffer, ArrayBuffer, and offset views, since multer hands us views into a larger buffer.- Signing: canonical serialisation, determinism, a body edited after signing, and a signature made over a different context string. The wire-format tests rebuild the payload and the key independently and verify with plain
crypto.verify, so they pin the format rather than agreeing with the implementation. - Export route: access gate, manifest shape, signed and unsigned paths, and no error leakage on failure.
GET /manifest-signing-key: serves the public key, never the seed.
Deliberately left out
- Backfill for existing rows. It would mean streaming every object out of R2, and I would rather not guess how you want that batched.
- Trusted timestamping. The manifest attests to bytes, not to when they existed;
exported_atis the server clock. RFC 3161 or a transparency log is the honest next step, and both need infrastructure a self-hosted deployment may not have.
Held back rather than bundled
This is the smallest version I could defend. Three things are already built and not included. Happy to open any of them, or none:
- A
verifyManifest()helper returning one verdict -verified,unsigned,key-mismatch,bad-signature,tampered- where checking against the key inside the manifest never returnsverified. - A
docs/page: worked manifest example, verification walkthrough, key rotation, and stated limits. - Two things that predate this PR, which I noticed and did not touch.
selectAllcan duplicate or drop rows across page boundaries whencreated_atties, because there is no tiebreaker column in the sort. And two concurrent accept/rejects on one document race on the stored bytes. Both are yours to weigh; I have no code attached to either here.
This came out of an audit layer in an IDE I have been building, where I was riffing on audit trails and what makes AI-assisted work insurable: Legalise. docs/TRUST.md is the write-up if it is useful.
Our analysis
Add tamper-evident project exports — read the full analysis →
Think the analysis missed something the PR description covers?
Commits in this PR (2)
| SHA | Subject | Author | Date | |
|---|---|---|---|---|
562a8139 | feat: content hashes on document versions + signed project export manifest | Andy | 2026-07-31 | ↗ GitHub |
commit bodyStores a SHA-256 of every document version's bytes at write time and adds a per-project export manifest listing them alongside the accept/reject trail, so an exported file set can be checked against what the workspace held. The manifest carries a SHA-256 digest over a canonical form of its body, Ed25519-signed when MANIFEST_SIGNING_KEY is set. Signing is optional so a self-hosted deployment without key custody can still export; a malformed key throws rather than silently downgrading to unsigned. The signature covers a versioned context string and a NUL byte before the digest bytes, so it cannot be replayed as a signature over another object, and it verifies with any Ed25519 library. The public key is served at GET /manifest-signing-key. Rebase of #181 onto current main, plus the signing requested on that PR. | ||||
18a00513 | Address review on the export manifest | Andy | 2026-07-31 | ↗ GitHub |
commit body- canonicalize rejects non-plain objects. A Date serialised as {}, a
Buffer enumerated its indices, a Map became {}. None can reach a
manifest body today, but a silently wrong serialisation is the same
failure class as the non-finite number already rejected, and every
digest rests on this function.
- Validate MANIFEST_SIGNING_KEY at boot. A malformed key previously
surfaced when a user's first export failed; it now stops startup with
the same message, and a working key logs its key id.
- Bulk copy takes size_bytes from the bytes being hashed rather than
inheriting it from the active version, so size and content_sha256
always describe the same content. A verifier that stats a file before
hashing it should not see a size that disagrees with the hash.
- README notes that soft-deleted versions stay in the manifest. Dropping
them would weaken the attestation, but it makes their filenames and
timestamps visible to anyone with project access, which is worth
stating rather than leaving to be discovered.
| ||||
Capture this PR into my fork
Download a Markdown prompt that tells Claude how to port every
commit in this PR into your working tree. Run it via
claude -p < capture-pull-279.md from
inside the repo you want the changes in.