cli.sgit.ai / Research / Native PKI vault mode — architecture map

Native PKI vault mode — architecture map

Date2026-10-09 · 18:23 UTCKinddesignTopicspki vault-format cryptoWritten bythe sgit-ai CLI team (Claude Code sessions), for Dinis CruzSourceteam/humans/dinis_cruz/claude-code-web/10/09/design__native-pki-vault-mode.md in SGit-AI__CLI at 5597526. Published here as written; the CLI repository is where the work happened.

Date: 2026-10-09 · Status: design for review, no code · Follows: design-analysis__read-write-keys-as-pki.md (same folder), which covered adding a writer signature to today's symmetric vaults. This document maps a second vault mode built on key pairs from the start. Companion: design__sealed-files-layer.md (encryption on top of either mode).

Revised 2026-10-10 after the sgit.ai agent's review (dev at 0a0707d). Sequence-number pinning is a requirement, not an option. Every signed ref and the signed index carry a monotonic seq. Each clone pins the highest seq it has accepted per ref and for the index, and refuses (fails closed) anything lower, or equal with different content. Without it, a host replaying an old signed ref or index (TM-R29) survives the signatures. A fresh clone still has nothing to compare against: it is told the seq it got, and a witness or transparency log stays optional (RFC question 5).

Revised again 2026-10-10 after the release-gate review (dev at eed8084). seq pinning closes replay for clones that have seen a state. Four things it leaves open are now specified in §3.1:

1. Your question, checked

A vault where one key lets you read and a different key lets you write, and neither key can be computed from the other. Can one public/private pair do that, used either way round?

One key pair cannot. Two key pairs can, and they give exactly what you describe.

A key pair supports two operations, and in each the direction is fixed:

OperationDone withUndone / checked withWhat it gives
encrypt → decryptpublic keyprivate keyconfidentiality to the private-key holder
sign → verifyprivate keypublic keyproof that the private-key holder wrote it

Two facts settle it:

With one pair, writers sign (private) and readers verify (public). That gives who wrote it, but nothing for keeping content secret. Add a second pair for reading and every property you asked for falls out:

 R  = read pair   (X25519, for encryption)    R.priv  decrypts      R.pub  encrypts
 W  = write pair  (Ed25519, for signatures)   W.priv  signs heads   W.pub  verifies
PartyHoldsCanCannot
ReaderR.priv, W.pubread everything; check that every head was signed by a writerwrite: needs W.priv, not derivable from anything it holds
Writer (normal)R.priv, W.privread and write—
Depositor (write-only)R.pub, W.priv*add new content readers can openread anything: needs R.priv, not derivable from R.pub
Hostciphertext, W.pubstore, serve, refuse unsigned writesread; forge a head (no W.priv); can still withhold or replay old signed states

\* or a depositor-specific signing key the writers accept for an inbox branch (§5).

So: two keys, neither derivable from the other, one for reading and one for writing. The read capability is R.priv (+ the public W.pub); the write capability is W.priv (+ R.priv for writers who also read). A write-only depositor is the one case today's design cannot express at all.

Two vault modes side by side is fine. The object store, content addressing, trees, sync, merge, scoped clones, the path guard and the CLI stay shared. The mode decides only three things: how keys come into being, how the data key reaches a reader, and what authorises a write.

2. The architecture

                         owner master seed  M  (32 bytes; or PBKDF2(passphrase) for humans)
                                │ HKDF
            ┌───────────────────┴────────────────────┐
     W.priv (Ed25519)                          R.priv (X25519)
     W.pub ──────────► vault id = base32(SHA-256(W.pub))[:26]   (self-certifying, 128 bits)
                                                   R.pub
 write capability  = M                (derives everything)
 read capability   = R.priv + W.pub   (cannot derive W.priv or M)
 deposit capability= R.pub  + a signing key the vault accepts for its inbox

 vault data key  DK (random 32 bytes, generation g)
   bare/keys/dk-<g>  = HPKE-Seal(R.pub, DK)          ← readers unwrap with R.priv
   every object      = AES-256-GCM(DK, …)            ← exactly today's object format
   every ref / index = AES-256-GCM(DK, …) + Ed25519 signature by W.priv
                        over (vault_id, file_id, seq, ts, ciphertext hash)

Why a data key, rather than encrypting every object to R.pub. Public-key encryption is randomised: the same tree encrypted twice gives different bytes. Today's design depends on deterministic tree encryption in two places:

With a symmetric data key wrapped once to R.pub, the object layer is unchanged: DK simply plays the role today's read key plays. The PKI sits in two places only:

3. What each piece fixes (threat model rows)

RowTodayNative PKI mode
TM-R01 read-key holder + host forge historyby designclosed: heads must carry W.priv's signature
TM-R03 refs not bound to their idacceptedclosed: the signature covers vault id + file id
TM-R29 host replays an old index (drops features)acceptedclosed for content (signed); freshness still needs seq pinning
TM-R13 bearer write keyby designgone: the server checks a signature, so nothing replayable crosses the wire
TM-R14 keys in argvH likelihoodreduced: capabilities can be handed over as wrapped envelopes (sealed-files doc §6), never typed
TM-R12 no read revocationby designbetter: rotate R and DK, re-wrap to remaining readers; old data stays readable to the removed reader (inherent)
Write-only contributorsimpossiblenew: depositor capability (§5)
Freshness for a fresh clone (SP-2)openpartly: signed seq + ts lets a client flag a stale head; a host can still withhold

3.1 Freshness, concurrent writers and the pin store (revised 10-10)

A host that freezes. Pinning stops a host going back; it cannot see a host that simply stops showing new states (every clone keeps its last, valid view, and teammates diverge silently). Two pieces:

  1. Signed checkpoints (heartbeat). Every write already signs {seq, ts}. In addition, any writer's client publishes checkpoint = sign_W(vault_id, index_seq, H(ref seqs), ts) when the newest one is older than checkpoint_interval (default 6 h, set by the owner in the index gate). A quiet vault therefore still shows a fresh ts. status and pull warn when the newest checkpoint is older than max_staleness (default 24 h): "this server has shown nothing newer than <ts>; it may be withholding". The check warns and does not refuse, because a vault with no active writer looks the same.
  2. Witnesses (optional). A vault can name witness URLs in the signed index: other hosts (a mirror, the sgit.ai site, a teammate's static copy) that store the latest checkpoint they saw. A clone compares the host's checkpoint with the witnesses' and refuses to push on top of a state older than one a witness holds. Withholding then requires the host and every witness to collude.

Two writers, the same next seq. Every signed ref and index state carries prev = SHA-256(previous signed state), a hash chain per file.

The pin store. .sg_vault/local/pins.json maps {file_id: (seq, state hash)} for each ref and for the index.

Fresh clones remain trust on first use. Two optional ways to give them a floor:

4. Mapping to the code

AreaStaysChanges
Vault__CryptoAES-GCM, deterministic metadata encryption, content ids+ Vault__Crypto__PKI: HKDF derivation from M; Ed25519 sign/verify; HPKE seal/open (X25519, HKDF-SHA256, AES-256-GCM: RFC 9180 base mode)
Key stringssgit_private_vault_…, sgit_public_read_…+ sgit_pki_write_… (M), sgit_pki_read_… (R.priv ‖ W.pub), sgit_pki_deposit_…; Enum__Key_Kind gains them; the prefix is the mode, so a fresh clone knows signatures are mandatory (no strippable flag)
Vault__Componentsobject store, refs, index, branchesread_key ← DK (unwrapped); + verify_key (W.pub); + signing_key (W.priv, writers only)
Refs (Vault__Ref_Manager)encrypted {commit_id}{commit_id, seq, ts} + signature; verified on every read path (status, pull, clone, fetch)
Index (Vault__Index_Sync)merge rules, tags, gatesigned on every write (all writers hold W.priv); verified on read; seq monotonic per clone
Vault__APIendpoints, batch, CASwrites carry a request signature (method, path, body hash, time) instead of x-sgraph-vault-write-key: server change
Commitsper-clone ECDSA signaturesunchanged (attribution: which writer); the vault signature answers a writer at all
Format gateformat 1/2format: 3 + mode pki; min_client set at creation
Migrationvault move rewrites everything alreadysgit vault move --to-pki makes a PKI vault from a symmetric one

5. Write-only (deposit) capability

A depositor can encrypt (it holds R.pub) but cannot read, so it cannot read the current tree to modify it. Write-only therefore means append, not edit:

This fits agents handing work to a human, form-style submissions, and log or report drops. The vault accepts the depositor's signing key for its inbox branch only: the writers add it to the signed index.

6. Rotation and the owner key

The self-certifying vault id (H(W.pub)) means a new W is a new vault. That is fine for "a writer left" today (it is what vault move does), but heavy for teams. The clean fix, and the same design as the threat model's "owner-signed membership":

Start with one shared W (simple, matches today's team model) and keep the writer-set format ready for per-member keys.

7. Interop and algorithms

8. Effort

PhaseWhatCLI estimate
1Vault__Crypto__PKI, key strings, derivation, vectors shared with the web team~1 week
2Signed refs and index + verification on every read path (also usable on symmetric vaults; = phase 1 of the earlier analysis)1–1.5 weeks
3DK sealed to R.pub; read path via DK; init --pki, clone of a PKI vault~0.5 week
4Server: store W.pub, accept signed writes (SG/Send team)server side
5Deposit capability + inbox branches~1 week
6Owner root key + signed writer set + rotation2–3 weeks

Phases 1–3 give a working native mode in which a reader cannot write and the host cannot forge. The web UI needs the same verification and unwrap code to open PKI vaults.

9. Decisions for you

  1. Vault id: self-certifying H(W.pub) now, or go straight to H(O.pub) with a writer set? My view: start with W.pub, and design the index field so O can be added later.
  2. Capability strings: random 32-byte seeds only (agents and machines), or also a passphrase form for humans (PBKDF2 → M, as today)?
  3. Deposit / inbox: in the first release or later?
  4. Server signed writes (phase 4): ask the SG/Send team now? Until then the server keeps the bearer check, but clients reject anything unsigned, so forgery is closed either way.

This page as markdown · its source, admin/content/articles/native-pki-vault-mode.md