01. Architecture
A sealed clone only. An unsealed clone, the default, runs as sgit 0.24.0 does: one process, the key on disk (05).
Four components, each with one job, each under its own Process Behaviour Policy, talking only over mutual TLS along three allowed edges.
1. The picture
person (broker: carries QR images, supplies policy hash)
│
k1 public key ◄────┤────► m1 sealed vault key
│
┌───────────────────────────────── session (ephemeral VM / pod / host) ─────────────────────────────────┐
│ │
│ agent (LLM, root) ── runs ──► sgit CLI command │
│ ▲ transcript sees only: commands, k1, m1, status output - never a clear key │
│ │ │
│ ┌─────┴──────────────┐ mTLS ┌────────────────────┐ │
│ │ sgit-cli │─────────►│ sgit-fs │──► clone folder + .sg_vault/ (only writer) │
│ │ uid sgit-run │ edge 1 │ uid sgit-fs │ rules: paths, sealed formats only, │
│ │ orchestrates │ │ no keys, no net │ no symlinks out, no exec bits │
│ │ NO keys │ └────────────────────┘ │
│ │ NO network │ │
│ │ NO disk writes │ mTLS ┌────────────────────┐ TLS client, ┌──────────────────┐ │
│ │ no fork │─────────►│ sgit-net │─ allowlisted ─►│ platform egress │──► send.sgraph.ai
│ └────────────────────┘ edge 2 │ uid sgit-net │ host names │ proxy port │ (vault server)
│ │ holds DERIVED keys│ └──────────────────┘ │
│ │ for one command │ │
│ │ does vault crypto │ │
│ └─────────┬──────────┘ │
│ │ mTLS edge 3 │
│ ┌─────────▼──────────┐ │
│ │ sgit-kms │ private key in memfd_secret page │
│ │ uid sgit-kms │ frozen between calls (cgroup freezer) │
│ │ no net, no disk │ only op: open sealed → re-seal to E.pub │
│ └────────────────────┘ (or: remote - Lambda/KMS/HSM/platform) │
│ │
│ launcher/runtime: makes uids, session CA, certs, applies PBPs, verifies policy hash, starts/freezes │
└───────────────────────────────────────────────────────────────────────────────────────────────────────┘
2. Components
2.1 sgit-cli: orchestration
| Job | parse the user's command, call sgit-fs and sgit-net, render output |
| Holds | after the full split (§7 step 3), no key of any kind; until then, derived keys for one command, sealed to its own one-time key. Sees file contents (it must, to show diffs) |
| Talks to | sgit-fs (edge 1), sgit-net (edge 2). Nothing else |
| Files | read-only on the clone folder; write: none (all writes go through sgit-fs) |
| Network | none: a network namespace with only loopback, or Landlock net with only the fs/net ports |
| Lockdown | uid sgit-run, all capabilities dropped, no_new_privs, RLIMIT_NPROC=0, Landlock, seccomp allowlist |
| Refuses | show-key on a sealed clone without --reveal; when the session policy says so, a plain vault key on argv (all 14 commands that take one). By default those commands keep accepting a key, as in 0.24.0 (05) |
2.2 sgit-fs: the only writer
| Job | apply writes to the working tree and .sg_vault/ under rules |
| Holds | no keys |
| Talks to | nobody; only answers sgit-cli |
| Business rules | writes only under the clone root; no symlink or .. escapes; in .sg_vault/local/ only accepts sealed formats and refuses anything that parses as a clear vault key, a PEM private key or a bearer token (best effort for vault keys, whose passphrase part can be chosen by their owner; the real control is that sgit-fs never receives one); strips exec bits from pulled files unless policy allows; size and count limits per command |
| Implementation | step 1: a process owning the folder (uid sgit-fs, others have no write permission). Later: a FUSE mount enforcing the same rules for any tool (FUSE measured available) |
2.3 sgit-net: network and vault crypto
| Job | every byte to and from the vault server; the KMS client; (full split) all vault encryption/decryption and signing |
| Holds | derived read/write keys for one command, in a memfd_secret page; never the vault key |
| Talks to | sgit-kms (edge 3); the vault server via the platform egress proxy |
| Business rules | host allowlist (e.g. send.sgraph.ai, KMS host); endpoint allowlist (sgit API paths); vault-id allowlist; read-only vs read-write per policy; no redirects to other hosts; IP literals refused |
| Why it is the TLS client | the kernel only sees 127.0.0.1:<proxy port>. sgit-net sends CONNECT host:443, sets SNI and Host, and verifies the certificate for that name. A pure relay would let the inner process choose SNI/Host (domain fronting) |
2.4 sgit-kms: one operation
| Job | open an m1 addressed to its identity, check it, derive keys, re-seal them to the caller's one-time key E |
| Holds | the session identity's private key (made inside, never exported); nonce record |
| Talks to | answers sgit-net only |
| Files / network | none locally (Landlock with no fs rights after start; empty network namespace). So everything it judges arrives through sgit-net: sender trust is checked against fingerprints pinned in its policy (02 §3.3) |
| Lifetime | frozen between requests; thaw → answer → freeze (see 02 §6) |
3. Edges and transport
3.1 Edge matrix (deny by default)
callee → fs net kms vault server
caller ↓
cli ALLOW ALLOW deny deny
fs - deny deny deny
net deny - ALLOW ALLOW (allowlisted names only)
kms deny deny - deny
Measured with a session CA and role certificates: cli→fs, cli→net, net→kms accepted; fs→kms, cli→kms, fs→net refused.
3.2 Mutual TLS with a session CA
launcher each component
──────── ──────────────
1. make CA key (P-256), CA cert (2h, pathlen 0)
2. for role in cli, fs, net, kms:
make key, issue cert
SAN URI spiffe://<session-id>/<role>
SAN DNS localhost
validity = session
3. DESTROY CA private key ───────────────► set of identities is now closed
4. CA cert hash → into policy manifest 5. on connect, BOTH sides:
- verify chain to session CA
- read peer role from SAN URI
- check (caller, callee) ∈ edges
- reject otherwise
Rules:
- TLS 1.3 only. P-256 keys. 24 ms to make the CA plus four certs (measured).
- Both sides check roles. The callee checks the caller; the caller checks the callee, so a compromised component cannot pose as sgit-net to the CLI.
- Use SPIFFE IDs (
spiffe://<trust-domain>/<role>). This is the SPIFFE/SPIRE and service-mesh pattern; on Kubernetes it lets SPIRE or a mesh issue the identities instead of the launcher. - Each component's TLS key is not its sealing identity. sgit-kms's TLS key and its RSA identity are separate keys.
- Set
TCP_NODELAYon both ends. Without it on the server, the TLS handshake measured 46 ms (delayed-ACK stall) instead of 3.2 ms. - Same protocol on any transport. Unix sockets locally, TCP across machines. Measured costs are the same (§3.3). On Unix sockets add
SO_PEERCREDuid checks as an independent third layer. - Wire format: small HTTP/1.1 (or length-prefixed JSON) API over the TLS connection; one connection per command, reused for all requests in that command; TLS session resumption between commands.
3.3 What it costs (measured, Python ssl, TLS 1.3)
| Transport | Connect + handshake | 600 B round trip | Bulk |
|---|---|---|---|
| TCP plain | 0.10 ms | 15 µs | ~300 MB/s |
| TCP mutual TLS | 3.2 ms | 67 µs | ~320 MB/s |
| Unix plain | 0.09 ms | 56 µs | ~320 MB/s |
| Unix mutual TLS | 3.4 ms | 74 µs | ~420 MB/s |
Per sgit command: three handshakes ≈ 10 ms, against 146 ms of sgit imports and 108 ms of PBKDF2 already paid today. Microsecond and bulk figures varied by up to ~25% between runs (server thread shared the interpreter lock with the client); handshake times were stable. Details in 04 §6.
4. Command flows
4.1 sgit clone --sealed handover.png work
agent cli fs net kms vault server
│ clone --sealed │ │ │ │ │
│───────────────►│ parse QR JSON, strict schema (handover.md refusal rules) │
│ │ open(m1)────────────────►│ │ │
│ │ │ │ make E (P-256) │ │
│ │ │ │ unwrap{m1,E.pub, │ │
│ │ │ │ nonce,ts,op=rw}─►│ thaw │
│ │ │ │ │ check: to=self, sig, │
│ │ │ │ │ expiry, sender trust,│
│ │ │ │ │ nonce unused │
│ │ │ │ │ open → vault key │
│ │ │ │ │ derive read/write,id │
│ │ │ │ │ scrub vault key │
│ │ │ │◄─sealed_to_E{keys}│ freeze │
│ │ │ │ open with E.priv; drop E │
│ │ │ │ fetch packs ─────────────────────────────►│
│ │ │ │◄──────────────────────────── encrypted ───│
│ │ │ │ decrypt objects │ │
│ │◄─────────── plain file contents ────────────│ │
│ │ write(tree)──►│ apply rules, write files │ │
│ │ write(.sg_vault/local/vault_key.sealed = re-sealed)►│ sealed format │
│ │ │ │ scrub derived keys; exit │
│◄── "cloned, sealed to sha256:… from sha256:… (chains to root), expires 30 Oct" ─────│
The transcript sees: the command, the m1 (it is ciphertext), fingerprints, progress. Never a key.
4.2 Read commands (status, pull, ls, cat)
Same as 4.1 from "make E", reading vault_key.sealed through sgit-fs and asking the KMS with op=read. The KMS returns only the read key (and ids). sgit-net cannot push in this command even if told to.
4.3 push
op=write: the KMS returns read + write keys. Policy may require a fresh approval (second factor) for op=write; the KMS is where that check lives.
4.4 vault show-key
Refused on a sealed clone. With --reveal it is a policy decision (default: denied in agent sessions). sgit vault seal --to <record fp> --qr out.png replaces every legitimate use: the KMS re-seals to another identity without the key appearing anywhere.
5. Deployment topologies
Same components, same protocol, same policies. Only the launcher changes.
A. one VM, separate uids (what was measured)
┌──────────────────────── VM ────────────────────────┐
│ sgit-run sgit-fs sgit-net sgit-kms (4 uids) │
│ └─ Unix sockets + mTLS + SO_PEERCRED + iptables │
└────────────────────────────────────────────────────┘
B. Docker / compose: one container per component
┌─cli─┐ ┌─fs──┐ ┌─net─┐ ┌─kms─┐ network: internal only, except net
│ │──│ │ │ │──│ │ volumes: clone folder mounted rw in fs, ro in cli
└─────┘ └─────┘ └──┬──┘ └─────┘ kms: no network except the internal one, read-only rootfs
└──► egress
C. Kubernetes: one pod, four containers (or kms as its own Deployment)
identities from SPIRE / mesh (spiffe://<td>/<role>); NetworkPolicy mirrors the edge matrix;
seccomp + readOnlyRootFilesystem + runAsNonRoot per container from the PBP
D. remote KMS
net ──mTLS/HTTPS──► Lambda / Cloud Function ──► cloud KMS asymmetric key (HSM)
caller bound to the session's auth key (02 §5); nonce store e.g. DynamoDB conditional put
E. platform-provided (end state)
net ──► local platform endpoint (vsock / proxy) ──► platform KMS; session identity
= the platform's own knowledge of the session; no auth key in the container
6. The launcher
Responsibilities, in order:
- Read the policy manifest; compute its hash; refuse to continue unless it matches the hash the person supplied or the manifest carries a valid signature from the root key.
- Create uids
sgit-run,sgit-fs,sgit-net,sgit-kms(measured: create user + firewall rule + locked launch + teardown ≈ 49 ms each). - Create the session CA and four certificates; destroy the CA key; record the CA cert hash.
- Start sgit-kms under its PBP; it makes the session identity and prints only the k1 public bundle; freeze it.
- For each sgit command: start sgit-fs, sgit-net, sgit-cli under their PBPs (namespaces ≈ 4 ms), let them run, tear down.
- At session end: thaw and kill sgit-kms, delete users, firewall rules and sockets.
Who runs the launcher matters. In the measured environment the agent is root, so every lock the agent sets up is a Setting from the agent's own point of view (it could undo it); it is a Boundary only for the components below it. Making the locks binding on the agent requires the platform to run the launcher. See 03 §2.
7. Refactor path in sgit
sgit 0.24.0 reads the vault key directly in many places (the article counts 27 read sites in 17 files; a broad grep for _read_vault_key|load_vault_key|vault_key_path finds 65 references in 23 files, including imports). After derivation almost nothing needs the clear key except show-key, rekey and move.
| Step | Change | Effect |
|---|---|---|
| 0 | Fix independently, compatibly: new private keys from pki keygen use Argon2id instead of PBKDF2 at 2,048 iterations (measured, 0x0800), and existing keys keep opening; new Secrets__Store files get a per-user random salt, and existing ones keep opening | removes two weaknesses whatever else happens, and breaks nothing already on disk |
| 1 | The door: one Key_Source interface, "vault id + derived keys for this folder". Its default backend reads vault_key exactly as today | no behaviour change; all call sites go through it; the existing test suite and a golden run against 0.24.0 prove it (05) |
| 2 | A second Key_Source backend, chosen per clone by the presence of vault_key.sealed, never globally. First in-process (the local pki key opens it), then backed by the KMS protocol. clone --sealed, vault_key.sealed, vault seal-local, vault seal --to. sgit-net filters traffic; cli receives derived keys sealed to its own E | key never on disk/argv/stdout; derived keys in cli memory for one command |
| 3 | Full split: vault crypto and server I/O move into sgit-net; cli asks for operations, not keys | cli holds no keys; cli has no network |
| 4 | sgit-fs as the only writer; then optional FUSE | business rules on disk enforced outside the cli |
| 5 | P-256 ECDH envelope for hardware keys (TPM / PIV / Secure Enclave); commit signing with the identity | hardware-backed KMS; commit author is a register record |
Lazy imports per subcommand belong with step 1: sgit --version loads 529 modules and opens 1,104 files (measured). Smaller footprint = smaller policy.
This page as markdown · its source, admin/content/dev/sealed-vault-keys/01-architecture.md