feat: content hashes on document versions + signed project export manifest

✅ merged · #279 · open-legal-products/mike ← b1rdmania/mike · opened 3mo ago by b1rdmania · merged 3mo ago by willchen96 · +942-6 across 17 files · ↗ on GitHub

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.ts renames 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 exportLimiter and requireMfaIfEnrolled, 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.

  • contentSha256 against 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_at is 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 returns verified.
  • 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. selectAll can duplicate or drop rows across page boundaries when created_at ties, 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 body
Stores 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.

⬇ Download capture-pull-279.md