cli.sgit.ai / Research / Read and write keys as PKI — design analysis

Read and write keys as PKI — design analysis

Date2026-10-09 · 00:18 UTCKindanalysisTopicspki keys cryptoWritten bythe sgit-ai CLI team (Claude Code sessions), for Dinis CruzSourceteam/humans/dinis_cruz/claude-code-web/10/09/design-analysis__read-write-keys-as-pki.md in SGit-AI__CLI at 5597526. Published here as written; the CLI repository is where the work happened.

Date: 2026-10-09 · Context: the 0.21.0 security review (threat model team/explorer/appsec/threat-model/v0.21.0__threat-model.md, TM-R01) · Status: analysis, no code changed.

Question. sgit went symmetric because there was no good place to publish public keys and no good way to move secrets. Both now exist. So: aren't the read key and the write key really a public key and a private key? And since only the encrypt/decrypt part would change, how hard would supporting that be?

Short answer

Half right, and the half that's right matters a lot.

So the right mapping is:

write capability  =  content key  +  writer's PRIVATE signing key
read capability   =  content key  +  writer's PUBLIC verification key
host              =  ciphertext only; can no longer forge, only withhold or replay

This is a known, proven shape: Tahoe-LAFS mutable files (write-cap = signing key; read-cap = symmetric key + hash of the verifying key) and Hypercore/Dat (public key = identity and verification; secret key = write).

On "it's just the encrypt/decrypt bit": the cryptography really is small. Content encryption, objects, trees and commits don't change at all, and there's no re-encryption or data migration. Only two mutable things need a signature: the branch refs and the branch index. Everything else is content-addressed below them, so signing the roots authenticates the whole Merkle tree. The real work is around the cryptography (§4). It's about 1.5–2 weeks for the CLI, and the web UI needs the same change.

1. What a writer signature fixes, and what it doesn't

Threat-model rowTodayWith a writer signing key
TM-R01 read-key holder + host forge history that verifiesopen (by design)closed: a forged ref or index fails verification on every client
TM-R03 ref/key ciphertext not bound to its file idacceptedclosed: the signature covers vault id + file id + content
TM-R27 legacy named private key readableacceptedmoot: that key stops mattering
signatures-required meaning "some key that decrypts"weakmeaningful: the signed index lists the trusted clone keys, so a commit signer must be one of them
Tags, format gate, features (all in the index)host/reader can rewritecovered by the index signature, so stripping the gate is detected
SP-14 whole-site substitution on static mirrorsopenclosed for browsers that get the verification key in the URL fragment
SP-2 / rollback for a fresh cloneopenpartly: a signed timestamp lets a clone flag a stale head; the host can still serve an old signed state
Withholding (availability)openopen: signatures can't force the host to serve data
Metadata to the host (TM-R08)by designunchanged
Revoking one writerneeds a new passphrasestill needs one: the signing key is shared by all writers (§3, phase 3 changes that)

Per-clone commit signatures stay as they are. They answer which writer, while the vault signature answers a writer at all.

2. The one design detail that must be right

The signing key must not be derived from the write key. The write key is sent to the server as a header on every write, so the server knows it. A signing key derived from it would be computable by the very party it must protect against. Deriving it from the read key fails for the same reason (read-only shares hold that).

It has to come from the passphrase, along a third path:

passphrase ──PBKDF2(600k, salt "sgit-sign:<vault_id>")──► 32-byte seed ──► Ed25519 signing key
                                                                             └► verification key (public)

3. Phases

Phase 1: vault writer signature (the TM-R01 fix). CLI ~1.5–2 weeks.

  1. Derivation of the signing key and verification key (§2), plus test vectors shared with the web team.
  2. Signed refs. The encrypted ref JSON becomes {commit_id, seq, ts, sig}, with sig = Ed25519 over (vault_id, file_id, commit_id, seq, ts). Clients older than this read .get('commit_id') and ignore the rest (checked). The signature is made in Vault__Ref_Manager / the push batch and verified wherever a remote ref is parsed: status, pull, clone, fetch.
  3. Signed index. Add a writer_sig over the canonical (JCS) index minus the signature. Schema__Branch_Index.from_json already tolerates the extra field (checked). Verified in Vault__Index_Sync.read_remote; re-signed on every merge/upload (all writers can). Each branch's public_key_id gains a key fingerprint, so the signed index pins the clone keys and the key files can't be swapped.
  4. Downgrade resistance, the part that's easy to get wrong:
  1. Read-only shares. The read share carries the verification key (32 bytes) next to the read key. Existing read-only clones have no verification key: trust on first use, by pinning it from the first signed index and refusing any change after.
  2. Tests. Known_Gaps TM-R01 and TM-R03 get inverted into Fixed tests (forgery and ref swapping refused). Add browser vectors and a real-server round trip.

Existing vaults opt in (sgit vault format --feature writer-signed, which signs the current refs and index). No object is re-encrypted.

Phase 1b: server enforcement (SG/Send team, optional). The server stores the verification key at vault creation and accepts ref/index writes only when they are signed. The bearer write key then stops mattering (a stolen header is useless), and garbage writes are refused at the door rather than by every client.

Phase 2: move capabilities with PKI, not passphrases. CLI ~1 week. The other half of your question: now that public keys can be published, a vault key or a read capability can be wrapped to a teammate's published public key. PKI__Crypto.hybrid_encrypt (RSA-OAEP-4096 + AES-GCM, with an optional ECDSA signature) already exists, as do the sgit pki keyring and contacts. For example, sgit share --to <contact> would write the wrapped capability where the recipient can find it. This removes passphrases from chat, argv and shell history (TM-R14), and onboarding never exposes the key to the host.

Phase 3: per-member writers and owner-signed membership. ~3–4 weeks, later. The phase-1 key becomes the vault owner key. It signs a member list (each member's own signing key) inside the index, and a head must be signed by a listed member. Removing a writer becomes "owner drops them and re-wraps the content key to everyone else", with no new passphrase. This is the full answer to revocation, and it needs phase 1b to be enforceable against availability attacks.

4. Where the effort actually goes

PieceWhy it isn't "just the encrypt bit"
Key derivationmust avoid the write key and the read key (§2); new interop vectors
Formatsrefs and index carry a signature; old clients must read them (they do) but must not write them (format gate)
Downgradea host strips signatures or the feature flag unless fresh clones learn "signed" from the capability itself
Read sharesnew token format; existing read-only clones need trust on first use
Every write pathpush, index sync, tags, branch create/switch, move, rekey, history edit all write refs or the index and must sign
Web UIsame verification (and signing, if it writes); Ed25519 via Web Crypto
Testsgap proofs become fixed tests; vectors; real server

5. Recommendation

Do phase 1 for the next release after 0.21.0. It closes the most serious row in the threat model (TM-R01), makes signatures-required mean what people think it means, needs no data migration, and is opt-in per vault. Do phase 2 alongside it, since it's cheap with the PKI code that's already there and fixes how keys get shared. Ask the SG/Send team about 1b. Keep 3 on the roadmap.

Decisions needed from you before starting:

  1. Ed25519 (my recommendation, for deterministic vectors) or P-256.
  2. A new vault-key version for signed vaults (fresh-clone downgrade protection), or opt-in by feature flag only (simpler, weaker for fresh clones).
  3. Whether the web UI writes to signed vaults in the first version, or reads and verifies only.

6. Points to settle before phase 1 (from the sgit.ai agent's review of d3b8eef)

  1. Existing read keys. A writer signature closes TM-R01 only for readers whose capability carries the verification key. Existing read-only shares, including the read keys sgit.ai publishes, fall back to trust on first use until they are reissued in the v2 form. Plan the reissue, and say so on the site.
  2. Replay. Signatures do not stop a host from replaying an older signed index or ref. Phase 1 needs per-ref and per-index sequence numbers, pinned per clone and failing closed; otherwise TM-R29 survives.
  3. Old clients. They, and any fallback that rewrites the index (the N5 path, now removed), would write it unsigned. Gate the change on --min-client.
  4. S10. "A forged first ref fails verification anyway" is true only once phase 1 ships. Until then S10's per-file rule (only the server's not_found is "absent") is what protects a first push.

This page as markdown · its source, admin/content/articles/read-write-keys-as-pki.md