hushgit join waitlist

Architecture and diagrams

A detailed HushGit architecture map covering trust boundaries, cryptographic envelopes, keyrings, signed events, transparency witnesses, server storage, and self-custodied CI.

Trusted (native, holds keys) Convenience (read-only, no signing authority) Untrusted (server / storage / network) Crypto material

1. Overview

The product promise is operational zero-knowledge in the SaaS sense: the operator routes and stores opaque ciphertext and signed metadata, but does not possess plaintext repository data or repo content keys. The trust boundary is the user's signed native binary — a browser session is a convenience tier, not a root of trust.

What stays on the client

  • Git pack/object plaintext, refs, commit messages
  • Repo content keys and their epoch wraps
  • Device signing & wrapping private keys
  • Personal recovery kit material
  • Decrypted pull request and review state

What the server sees

  • Encrypted chunks and signed manifests (opaque ciphertext)
  • Signed append-only event log with parent-hash chaining
  • Public device and runner signing keys (per repo)
  • Opaque repo / chunk / object IDs and ciphertext sizes
  • Account billing / membership edges (acknowledged leakage)

System-level diagram

hushgit system overview Trusted native binaries and local key state on the left; the untrusted hushgit cloud — server, Postgres, blob store, CDN — on the right. All four native binaries speak authenticated HTTP across the trust boundary; the browser tier is convenience only. Trusted client side signed native binaries · all speak authenticated HTTP to the server hushgit CLI keys · repo · pr · auth · doctor apps/hushgit-cli git-remote-hushgit Git remote helper apps/git-remote-hushgit Self-custodied runner customer machine · repo-scoped key apps/hushgit-runner Desktop sidecar (Rust) native authority over IPC apps/hushgit-desktop-sidecar Desktop renderer (Electron) · Web app convenience tier — no payload-opening keys, IPC-gated apps/desktop · apps/web · packages/sdk · packages/ui Local key & profile state device signing/wrapping keys · per-account profile root personal recovery kit · session bearer (OS credential store) HUSHGIT_HOME / profiles / device-store trust boundary Untrusted hushgit cloud · opaque ciphertext only hushgit-server (Axum) HTTP API · OPAQUE auth · rate limits apps/hushgit-server PostgreSQL · metadata plane events · chunks · keyrings · sessions sqlx migrations S3-compatible blob store encrypted chunks · objects · result blobs BlobStore (object_store.rs) CDN · network transport availability + traffic metadata only Server-visible content opaque IDs · envelope headers · ciphertext sizes · timestamps · billing edges Explicitly NOT server-visible repo names · branch names · file paths · commit messages PR titles / descriptions / comments · review decisions repo content keys · plaintext CI logs (unless customer-stored) encrypted chunks · signed events · objects session + browser-safe routes only
Trust boundary. Native trust on the left holds all repository-opening keys; the server is treated as an active adversary on the right. All four signed native binaries — hushgit CLI, git-remote-hushgit, the self-custodied runner, and the desktop sidecar — speak only to hushgit-server over authenticated HTTP (encrypted chunks, signed events, ciphertext objects, OPAQUE auth); per-binary flows are detailed in the push & fetch and runner lifecycle figures (§11, §13). The browser/renderer convenience tier holds no payload-opening keys and is limited to session + browser-safe routes. The server alone reads and writes the blob store and metadata database.

2. Trust model

hushgit assumes an active server-side attacker that can read, modify, reorder, replay, or equivocate over all server-side data. Confidentiality therefore depends on client-side encryption and integrity depends on signed writes plus client-pinned state. There are four distinct trust tiers in the product:

TierWhat it can doWhat it can never do
native trusted CLI, sidecar Sign repo events, open encrypted review payloads, hold device wrapping keys, approve enrollments, rotate keyrings. Skip native bootstrap or impersonate another device — signatures bind signer_device_id.
browser convenience web, desktop renderer OPAQUE login, list opaque repos, fetch encrypted event ciphertext, render activation prompts. Approve enrollment, create a repo, upload/delete chunks, append signed events, decrypt review payloads.
recovery personal kit Sign one narrow RecoveryDeviceAdd action; receive new repo-content-key epoch wraps. Push, comment, approve, or sign arbitrary keyring changes.
runner repo-scoped CI Sign job claim, heartbeat, completion, and runner audit events for its repo; under an active lease, sign job-scoped fetches of the encrypted job spec, signed manifest, and pack chunks, and upload encrypted result chunks. Approve enrollment, create repos, upload/delete arbitrary chunks, mutate refs, or change keyrings.
untrusted server, storage, CDN, network Apply CAS preconditions and rate limits; serve opaque blobs and signed event pages. Decide Git ancestry, branch protection semantics, or read plaintext metadata.
Browser-execution boundary. The server cannot cryptographically attest the native binary at the HTTP layer. Native-only routes are kept unavailable to browsers by rejecting browser-execution headers (Origin, Sec-Fetch-*) and by deploying the web app on a distinct origin from the API. CORS gates and origin separation are browser-execution hardening, not authority checks — the actual authority is the native device signature.

3. Workspace layout

The monorepo is split by language and by what owns the security-critical path. Everything that touches keys, signatures, or ciphertext is in Rust. The TypeScript surface is the convenience UI plus a thin SDK that mirrors the protocol types.

.
├── apps/
│   ├── hushgit-cli/               Rust CLI: keys · auth · repo · pr · doctor
│   ├── hushgit-server/            Rust HTTP API (Axum + sqlx + S3)
│   ├── hushgit-runner/            Rust self-custodied CI runner
│   ├── hushgit-desktop-sidecar/   Rust IPC sidecar — native authority for Electron
│   ├── git-remote-hushgit/        Rust Git remote-helper entrypoint
│   ├── web/                       Vite + React encrypted repo/PR browser (convenience)
│   ├── desktop/                   Electron renderer + main process
│   └── landing/                   Astro static site, waitlist on Cloudflare D1
├── crates/
│   ├── hushgit-core/              Client orchestration (device store, sessions, remotes)
│   ├── hushgit-crypto/            Envelopes, ChaCha20Poly1305, Ed25519, X25519
│   ├── hushgit-events/            Signed event DAG, manifest, merge-cert verifiers
│   ├── hushgit-git/               Hardened shell-out to the git CLI, refs/packs/bundles
│   ├── hushgit-keys/              Key hierarchy, keyring draft, recipient models
│   ├── hushgit-native-client/     Account/device flows requiring native custody
│   ├── hushgit-protocol/          Shared wire types: events, manifest, keyring, HTTP, CBOR
│   ├── hushgit-server-client/     Hardened HTTP client for server calls
│   ├── hushgit-storage/           ChunkStore trait + local backend
│   └── hushgit-wasm/              Future browser bindings (placeholder)
├── packages/
│   ├── sdk/                       TS protocol mirror + browser HushgitClient
│   └── ui/                        Shared React components (PR, shell, status)
├── api/openapi.yaml               Pinned HTTP contract
├── docs/                          Threat model · whitepaper · ADRs · this file
├── infra/dev/                     Docker compose for local Postgres + MinIO
└── xtask/                         Rust workspace maintenance

4. Layered architecture

Crates form a strict three-layer stack. The foundation owns wire types and primitive crypto. A middle orchestration layer composes those primitives into device stores, keyrings, sessions, and remotes. Apps sit at the top and are the only place where I/O, argument parsing, and process boundaries live.

Layered crate architecture Four layers: foundation types and primitives, domain signed objects and key hierarchy, client orchestration, and the app process boundaries. Foundation — types & primitives hushgit-protocol events · manifest · keyring · CBOR no internal deps hushgit-crypto EncryptedEnvelope · Ed25519 · X25519 no internal deps hushgit-git git CLI runner · packs · refs · merge hushgit-storage ChunkStore trait · local backend hushgit-server-client hardened reqwest JSON client Domain — signed objects & key hierarchy hushgit-keys RepoKeyringDraft · recipients → crypto, protocol hushgit-events SignedEvent · manifest verify → crypto, protocol hushgit-wasm future browser bridge (placeholder crate, currently empty) Orchestration — client product flows hushgit-core device-store · session profile · keyring · packs · manifest · TrustedRemote → crypto, events, git, keys, protocol, storage hushgit-native-client OPAQUE login · native bootstrap · enrollment · session persistence → core, crypto, protocol, server-client Apps — process boundaries hushgit-cli interactive git-remote-hushgit git helper hushgit-desktop-sidecar stdin/stdout JSON-RPC hushgit-runner self-custodied CI hushgit-server Axum + sqlx web · desktop + sdk · ui
Crates by layer. The server depends only on the foundation layer; the runner also links hushgit-core to verify signed manifests and materialize job checkouts. Full client orchestration lives in hushgit-core, the hub used by every native client.

5. Crate dependency graph

Concrete inter-crate edges. Foundation crates have no inbound dependencies from other hushgit crates and are deliberately small. The hub is hushgit-core, which every native app links against.

Crate dependency graph Direct hushgit crate dependencies. Client apps link the orchestration crates; the server and runner also link lower-layer crates directly. Hovering a crate highlights its edges. hushgit-git git shell-out hushgit-storage ChunkStore hushgit-protocol wire types hushgit-crypto primitives hushgit-server-client HTTP client hushgit-keys key hierarchy hushgit-events signed log hushgit-core client orchestration hub hushgit-native-client OPAQUE · bootstrap · enrollment hushgit-cli interactive git-remote-hushgit git helper hushgit-desktop-sidecar IPC native authority hushgit-runner CI hushgit-server Axum API solid: app → orchestration → domain → foundation · dashed: server and runner link lower-layer crates directly client apps also link some foundation crates directly (omitted here for legibility — the table below is exhaustive)
Dependency edges between hushgit crates. The server links only to protocol, crypto, and events, deliberately skipping client orchestration so it cannot accidentally pick up client-side state. The runner links protocol, crypto, hushgit-core, and hushgit-native-client; core supports signed-manifest verification and checkout materialization, while native-client supplies shared transparency verification helpers. Hover a crate to isolate its edges; the table below is the exhaustive edge list.

Edge summary

CrateDepends on (hushgit-* only)Imported by (hushgit-* only)
hushgit-protocolkeys, events, git, server-client, native-client, core, transparency, server, runner, cli, git-remote-hushgit, desktop-sidecar, witness
hushgit-transparencycrypto, events, protocolnative-client, server, cli, git-remote-hushgit, witness
hushgit-cryptokeys, events, core, native-client, transparency, server, runner, cli
hushgit-gitprotocolcore, cli
hushgit-storagecore, git-remote-hushgit
hushgit-server-clientprotocolnative-client, cli, desktop-sidecar
hushgit-keyscrypto, protocolcore
hushgit-eventscrypto, protocolcore, transparency, server, cli, git-remote-hushgit, desktop-sidecar
hushgit-corecrypto, events, git, keys, protocol, storagenative-client, cli, git-remote-hushgit, desktop-sidecar, runner
hushgit-native-clientcore, crypto, protocol, server-client, transparencyrunner, cli, git-remote-hushgit, desktop-sidecar

Checked against each crate's runtime [dependencies] by cargo run -p xtask -- check-architecture-deps (part of make check); dev-dependencies are not edges here. Note that hushgit-runner depends on protocol, crypto, core, and native-client (signed-manifest verification, checkout materialization, and shared transparency verification); it does not link hushgit-events, so its event verification helpers come from local hushgit-protocol payload helpers plus hushgit-crypto signatures rather than the events crate.

6. Cryptographic envelopes

Every byte that crosses the trust boundary is wrapped in a single envelope type. The envelope carries the algorithm, purpose, and key identifier as plaintext fields and binds them as authenticated associated data, so the server cannot strip the header or cross-mount ciphertext between purposes.

EncryptedEnvelope (crates/hushgit-crypto/src/envelope.rs)

pub struct EncryptedEnvelope {
    pub version: u32,             // currently 1
    pub algorithm: String,        // "ChaCha20Poly1305+BLAKE3-Derive/v1"
    pub purpose: CryptoPurpose,   // domain separator (enum)
    pub key_id: String,           // logical key id, e.g. "repo-key-v1"
    pub nonce: Vec<u8>,           // 12 random bytes
    pub ciphertext: Vec<u8>,      // AEAD ciphertext + 16-byte tag
}

The algorithm string is constant in v1: ChaCha20Poly1305 for AEAD and BLAKE3-derive for purpose-domain key derivation. The parser rejects unknown algorithm strings before any crypto runs, so an attacker cannot downgrade the wire format.

CryptoPurpose (crates/hushgit-crypto/src/purpose.rs)

pub enum CryptoPurpose {
    Chunk,         // encrypted Git pack chunk
    Manifest,      // signed encrypted manifest
    Refs,          // reserved
    PullRequest,   // PR payload
    Comment,       // review comment
    EventPayload,  // generic event payload
    Keyring,       // encrypted keyring snapshot
    DeviceStore,   // local on-disk device key store
}

Each purpose derives a distinct purpose-key from the base key, so the same repo content key produces eight independent encryption keys. A chunk ciphertext cannot be replayed as a manifest or comment without rejecting on AAD mismatch.

Envelope and AAD binding EncryptedEnvelope wire fields with the versioned length-framed AAD, and the BLAKE3 purpose-key derivation producing one key per CryptoPurpose variant. Envelope on the wire versionu32 = 1 algorithm"ChaCha20Poly1305+BLAKE3-Derive/v1" purposeChunk | Manifest | Keyring | … key_id"repo-key-v1" nonce12 random bytes ciphertextAEAD( purpose-key, nonce, plaintext, AAD ) AAD = prefix "hushgit.crypto.context.v1\0" ‖ version (u32 BE) ‖ length-framed( algorithm, purpose, key_id, repo_id, object_id ) aad.rs::versioned_length_framed_aad — stripping header or swapping ids ⇒ AEAD verify fails Purpose-key derivation base key (e.g. repo content key) 32 bytes, per-epoch BLAKE3-derive( purpose ) Chunk-key Manifest-key Keyring-key Refs-key PR-key Comment-key Event-key DeviceStore-key one purpose-key per CryptoPurpose variant (8 total); AAD also pins which one was used
Envelope layout and purpose-domain separation. The header is plaintext but AEAD-bound; mixing purposes or repos invalidates the tag.

7. Key hierarchy

Every repository has a single 32-byte symmetric content key per epoch. The keyring WrappedRepoKey list wraps that key to each authorized device and each active personal recovery key (the WrappedRepoKeyRecipient enum also includes User and Org variants for future per-user / per-org wrapping). The server stores wrap blobs but never the content key itself.

Repo content key fan-out The per-epoch repo content key wrapped to each authorized device and the personal recovery key; approved runners receive key material only via owner-controlled handoff, not as keyring recipients. Repo content key epoch N · 32 bytes rotated on revoke; old epochs retained for history User A · Device 1 signing + wrapping keys native_trusted User A · Device 2 signing + wrapping keys native_trusted User B · Device 1 signing + wrapping keys native_trusted Personal recovery Ed25519 + X25519 kit + 256-bit code Approved runner out-of-band WrappedRepoKey::Device — X25519 wrap WrappedRepoKey::PersonalRecovery policy-gated runner-registration payload (not a WrappedRepoKey recipient) Key epochs Each WrappedRepoKey binds (key_epoch, recipient, wrapping_key_id, wrapping_algorithm, encrypted_repo_key). Repo content keys are versioned by KeyEpoch{ epoch: u64, key_id }. Revoking a device increments the content-key epoch, wraps the new content key to remaining recipients, and retains old epoch wraps so authorized historical readers still work.
Repo content key fan-out. The keyring's WrappedRepoKeyRecipient enum supports Device, PersonalRecovery, User, and Org — there is no Runner recipient. Approved runners receive repo-key material only through owner-controlled native-client handoff.

Device key shape

Every device holds two distinct Ed25519/X25519 keypairs:

  • Signing keypair (Ed25519). Signs repo events, signed manifests, keyring changes, chunk uploads, and runner approvals. The public key is recorded in the keyring's authorized_devices and in repo_signing_keys on the server.
  • Wrapping keypair (X25519, static). Used to wrap the repo content key with X25519-HKDF-SHA256+ChaCha20Poly1305/v1 (see REPO_KEY_WRAP_ALGORITHM). The public key is recorded in the keyring; the private key never leaves the device.

A device id is bound to its signing key — rotating signing material requires provisioning a new device, not re-registering the same id. Convenience accounts hold a browser_convenience Ed25519 key used only to identify the browser session for account-scoped reads.

8. Keyrings & epoch rotation

Keyring state is the authorization root for a repository. It is modeled as a signed encrypted snapshot plus a linear log of signed keyring-change events. Clients never trust a snapshot in isolation — they replay the change log from a locally pinned trusted head and compare the replayed state to the candidate snapshot before using any device or recovery signer.

EncryptedKeyring (snapshot)

pub struct EncryptedKeyring {
    pub signature: Vec<u8>,
    pub signer: KeyringSigner,
    pub version: u32,
    pub repo_id: RepoId,
    pub sequence: u64,
    pub keyring_epoch: u64,
    pub key_epoch: KeyEpoch,
    pub authorized_devices: Vec<AuthorizedDevice>,
    pub revoked_devices: Vec<RevokedDevice>,
    pub wrapped_repo_keys: Vec<WrappedRepoKey>,
    pub keyring_event_head_ids: Vec<EventId>,
    pub recovery_policy: Option<RecoveryPolicy>,
}

KeyringChangeDetails (log entry)

  • InitialKeyring (wire name genesis_keyring) — genesis state, signed by the first native-trusted device, carries initial authorized_devices, revoked_devices, and recovery_policy.
  • DeviceAdd — appends one AuthorizedDevice; signed by a currently authorized device.
  • RecoveryDeviceAdd — narrow add signed by a personal recovery key, carrying the recovery_key_id plus the new AuthorizedDevice.
  • DeviceRevoke — appends one RevokedDevice; in practice paired with a fresh content-key epoch and rewraps.
  • EpochRotate — pure rotation marker without a membership change.

Recovery-policy edits are not a keyring-change variant in v1; the policy carried by InitialKeyring is the only signed policy surface in the change log today. The policy_update RepoEventKind is a separate event for protected-branch policy and lives outside this enum.

Keyring change log and snapshot binding The linear signed keyring change log and how an EncryptedKeyring snapshot is validated by replaying changes from a pinned head. Keyring change log (linear, signed) InitialKeyring epoch 1 seq 0 DeviceAdd epoch 1 seq 1 DeviceRevoke epoch 2 (rotation) seq 2 RecoveryDeviceAdd epoch 2 seq 3 EpochRotate epoch 3 seq 4 single linear head — parallel heads rejected until merge semantics are defined EncryptedKeyring snapshot keyring_epoch = 3 · key_epoch = repo-key-v3 authorized_devices = { D1, D3 } wrapped_repo_keys = { D1@v1, D1@v2, D1@v3, D3@v3, recovery@v3 } keyring_event_head_ids = [ seq 4 ] signed by current native-trusted device snapshot head id = latest change Snapshot validation: 1. fetch every change from pin to head_id 2. verify each signature against replayed prior state 3. assert replayed state == snapshot fields 4. only then trust signers/recovery from snapshot
Keyring snapshots are cached materialized views of a signed linear change graph. The pin defends against a server rewriting authorized devices while reusing the same head id.
Revocation is future-access-only by default. Removing a device increments the repo content-key epoch and stops future wraps, but old epoch wraps remain so still- authorized devices can read history. A removed device retains any plaintext or key material it already obtained until the repo is rotated and history is rewritten or re-encrypted.

9. Signed event log

Every mutating action is a SignedEvent appended to a per-repository append-only log. Events form a parent-linked DAG so clients can detect truncation, reordering, replay, and equivocation. Runner audit lives in a separate per-repo repo_runner_events stream that may reference repo events as parent hints.

SignedEvent (events/repo.rs)

pub struct SignedEvent {
    pub version: u32,
    pub event_id: EventId,
    pub repo_id: RepoId,
    pub actor_user_id: UserId,
    pub signer_device_id: DeviceId,
    pub kind: RepoEventKind,
    pub encrypted_payload_chunk_id: Option<ChunkId>,
    pub parent_event_ids: Vec<EventId>,
    pub created_at_unix_ms: i64,
    pub signature: Vec<u8>,
}

RepoEventKind

pub enum RepoEventKind {
    Push,
    RefUpdate,
    PolicyUpdate,
    PullRequestCreate,
    PullRequestUpdate,
    CommentCreate,
    CommentUpdate,
    CommentDelete,
    ReviewSubmit,
    MergeIntent,
    MergeCertificate,
    KeyringChange,
}
Repository signed event DAG An excerpt of the parent-linked signed event log, with the separate runner audit stream that may reference repo events as parent hints. Repo signed event DAG (excerpt) KeyringChange e0 (genesis) Push e1 refs/heads/main PullRequestCreate e2 → chunk c_… CommentCreate e3 → chunk c_… ReviewSubmit e4 · approve MergeCertificate e5 → cert e3 parent: e2 e4 parent: e2 e5 parent: e4 Runner audit stream (separate per-repo sequence, may cross-reference repo events) RunnerJobAccept r0 · payload chunk optional RunnerJobComplete r1 · payload chunk optional parent hint → e1 (Push) Each SignedEvent is signed by a native-trusted signer_device_id with the payload chunk uploaded first. RunnerEventKind = { RunnerJobAccept, RunnerJobComplete } — signed by the approved runner key, sequenced in repo_runner_events.
Repository signed event DAG with parent-hash chaining and an independent runner audit stream.
Append order vs client time. created_at_unix_ms is owner-supplied display metadata, not a trusted clock. The server's append sequence and event ids are the authoritative ordering; clients may detect equivocation by pinning event heads after every sync.

10. Signed manifest

A signed manifest is the per-repository state snapshot: refs, pack chunks, the keyring epoch in force, and the content-key epoch used to seal everything inside the envelope. Manifest validation pins all three so an attacker cannot replay an old manifest under a newer keyring or pair new refs with an old keyring.

Signed manifest bindings What a SignedEncryptedManifest binds: keyring object id, content-key epoch, event head ids, and a monotonic sequence under CAS. SignedEncryptedManifest sequence · event_head_ids · keyring_object_id key_epoch · signer_device_id · signature envelope_json (EncryptedEnvelope) signed by native-trusted device · CBOR canonical Keyring binding keyring_object_id pins authorization state replay old manifest with new keyring ⇒ reject Content-key binding key_epoch matches envelope key_id epoch drift ⇒ envelope AEAD fails Event-head binding event_head_ids list DAG roots at snapshot truncating events ⇒ head mismatch on next sync Sequence binding monotonic per repo CAS precondition replay old manifest ⇒ sequence rejected by client Decrypted manifest payload (RemoteManifest) format · sequence · head · refs · pack records · event head ids · events · keyring_object_id · key_epoch · protected_branch_policy · protected_ref_updates
What a signed manifest binds. The combination of keyring id + key epoch + event head ids + sequence prevents every replay/equivocation flavor the server could try.

Protected branch policy

Protected-branch policy bytes are signed as part of the encrypted manifest. The server stores signed events, encrypted chunks, packs, and the signed manifest under CAS, but it does not decide Git ancestry or whether a merge satisfies policy. Clients validate protected refs against the manifest:

  • required_approvals — distinct eligible approving users, excluding the pull request author and merge certificate signer.
  • direct_pushDeny / AllowAll / Allow{ users[], devices[]? }.
  • protected_from_object_id — pins the object that protection started from, so advancement that bypasses the signed update log is detectable.
  • MergeCertificate — detached signature over PR id, source/target/result object ids, pull request author, approval event ids (up to 64), signer device, timestamp. Protected refs advance only via a valid cert or an explicit policy-authorized direct push.

11. End-to-end push & fetch

The Git remote helper (git-remote-hushgit) is the only place where Git's transport talks to the network. git push hushgit::<url> main ends up generating an encrypted pack chunk, signing the upload, signing a Push event, and CAS-updating the manifest.

End-to-end push flow Sequence diagram of git push through git-remote-hushgit: pack sealing, signed chunk upload, signed event append, and the CAS manifest update. trust boundary git push git-remote-hushgit owns the HTTP client · crypto via hushgit-core hushgit-server blob store 1 · capabilities / push refspec via stdin 2 · load profile · open TrustedRemote 3 · drive git pack-objects locally 4 · seal pack into encrypted chunk (hushgit-core): EncryptedEnvelope{ purpose=Chunk, key_id="repo-key-vN" }; AAD pins repo_id + chunk_id 5 · PUT /v1/repos/{repoId}/chunks/{chunkId} + signed body 6 · verify Ed25519 sig over (repo_id, chunk_id, sha256(ct)) · write blob 7 · 200 OK + chunk descriptor row 8 · build SignedEvent{kind=Push, parent_event_ids=[prior head]} (hushgit-core) Ed25519 over canonical CBOR of UnsignedEvent; encrypted_payload_chunk_id=None for Push 9 · POST /v1/repos/{repoId}/events (append-only) 10 · parent FK + signature check · 201 Created 11 · rebuild & sign new manifest pinning keyring + key_epoch + new event head (hushgit-core) 12 · PUT /v1/repos/{repoId}/objects/{manifest_id} If-Match: prev_etag 13 · CAS success → 200 · helper reports push ok
Push flow. git-remote-hushgit owns the HTTP client and performs every server call; hushgit-core is an in-process library that only seals, signs, and orchestrates — it never touches the network. The server never sees pack plaintext; it sees a chunk PUT, a signed event append, and a CAS manifest update, and rejects any that fail signature, parent linkage, or ETag preconditions.

Fetch reverses this: the helper fetches the manifest, validates its signature and bindings, validates the keyring change log up to keyring_event_head_ids, downloads referenced encrypted chunks, decrypts them locally, and imports the resulting pack into a temporary mirror that Git fetches from. Ciphertext reads are themselves authenticated: chunk and repo-object GETs carry a timestamped fetch statement signed by an active native-trusted repo signer (x-hushgit-chunk-fetch-signature / x-hushgit-repo-object-fetch-signature), so a session bearer alone cannot bulk-download ciphertext.

End-to-end fetch flow Sequence diagram of git fetch through git-remote-hushgit: signed manifest fetch, keyring change-log replay, signed chunk fetches, local decryption, and import into a temporary mirror. trust boundary git fetch git-remote-hushgit hushgit-server blob store 1 · fetch refspec via stdin 2 · load profile · open TrustedRemote · load pinned heads 3 · GET /objects/{manifestId} — signed fetch statement x-hushgit-repo-object-fetch-signature · timestamped · native-trusted repo signer 4 · manifest ciphertext + ETag sha256(ct) 5 · verify signature + bindings: keyring id · key epoch · event heads · sequence 6 · fetch keyring + change log · replay from pinned head 7 · GET /chunks/{chunkId} ×N — each with a signed fetch statement 8 · read blobs 9 · chunk ciphertext 10 · decrypt locally — purpose=Chunk · AAD pins repo_id + chunk_id 11 · import packs into a temporary mirror · git fetches from it
Fetch flow — the read counterpart of the push flow above. Every ciphertext read is signature-gated, so a session bearer alone cannot bulk-download chunks; all verification (manifest signature and bindings, keyring change-log replay, AEAD with pinned AAD) happens client-side before Git sees a single object. The server learns only opaque ids, ciphertext sizes, and timing.

12. Server storage model

The server is split into two planes: a relational metadata plane in Postgres (managed by sqlx migrations under apps/hushgit-server/migrations/) and a content plane on an S3-compatible blob store. All values that cross the trust boundary are opaque ciphertext or signed envelopes; the server never sees plaintext.

Server storage model The Postgres metadata plane and the S3-compatible content plane; every stored value is opaque ciphertext, a signed envelope, or an opaque identifier. hushgit-server · Axum HTTP surface (api/openapi.yaml) /v1/auth/* · /v1/repos/* · /v1/repos/{id}/chunks/* · /v1/repos/{id}/objects/* · /v1/repos/{id}/events · /v1/repos/{id}/runner-* Metadata plane — PostgreSQL users opaque user_id OPAQUE record user_device_signing_keys device pubkey · trust level revoked_at sessions / pending_* bearer · OPAQUE login state enrollment · registration repos opaque repo_id hgrp_v1_ preview · owner repo_signing_keys per-repo device authorizations revoked_at repo_chunks · repo_objects opaque chunk_id · blob_key sha256 binding · etag repo_events append-only signed log parent_event_ids FK repo_runner_* registrations · signing_keys jobs · attempts · events result_chunks · cancellations challenges · locks creation · chunk-delete push serialization durable rate-limit token buckets · pending_blob_deletions queue peppered BLAKE3 identifier digests · per-client/per-prefix/global shards Content plane — S3-compatible BlobStore encrypted pack chunks blob_key opaque · ChaCha20Poly1305 indexed by repo_chunks repo objects (manifests · PR / comment payloads) ETag = sha256(ct) · CAS via If-Match old blob replacements queued for deletion runner result chunks owner-visible audit metadata payload ciphertext only All keys opaque · server never decrypts addressed by ChunkKey / RepoObjectId blob_key
Server storage model. The metadata plane stores opaque references and signed envelopes; the content plane stores ciphertext addressed by opaque keys.

HTTP surface, grouped

GroupRoutes (selected)Notes
Auth register/start, register/finish, login/start, login/finish, recovery/start, recovery/finish, devices, devices/{id}/revoke, native-bootstrap, device-enrollments, browser/device-enrollments (read-only), session OPAQUE PAKE; password material never reaches the server. Native bootstrap consumes a one-time OPAQUE login finalization.
Repos POST /repos/challenges, POST /repos, GET /repos, DELETE /repos/{id} Creation requires a server-issued one-time challenge signed by a native-trusted device. Deletion needs confirmRepoId and cascades blob deletions.
Chunks GET /repos/{id}/chunks/{chunkId}, PUT …, DELETE …, POST …/delete-challenges Upload signature binds repo id, chunk id, ciphertext sha256. Fetch requires a timestamped fetch statement signed by an active native-trusted repo signer. Deletion is challenge-bound to chunk generation.
Repo objects GET /repos/{id}/objects/{objectId}, PUT … Manifests and review payloads. Writes take a precondition — absent (If-None-Match) or matches-sha256:{etag} (If-Match) — bound in the signature; reads require a signed fetch statement, as with chunks.
Events GET /repos/{id}/events, append via signed-event POST Owner-scoped cursors are navigation markers, not authorization tokens.
Runner pairing & admin POST /repos/{id}/runner-registrations, …/{regId}/claim, …/{regId}/approve, GET /repos/{id}/runners, POST …/runners/{runnerId}/revoke 15-minute pending TTL; 40-bit pairing code stored only as a peppered hash; bounded online attack budget. Revocation is owner-signed, idempotent, and future-authority-only.
Runner jobs POST /repos/{id}/runner-jobs, GET …/runner-jobs, POST …/runner-jobs/claim, …/{jobId}/heartbeat, …/{jobId}/complete, …/{jobId}/cancel, …/runner-events Create, cancel, and list are owner-session routes with owner-signed payloads. Claim, heartbeat, complete, and runner-event append are bearer-less and authenticate by verifying the runner signature against the active approved runner key for that repo.
Job material & results POST …/runner-jobs/{jobId}/spec-chunk, …/{jobId}/chunks/{chunkId}, …/{jobId}/repo-objects/{objectId}, PUT …/{jobId}/result-chunks/{chunkId} Lease-gated, runner-signed, job-scoped: fetch the encrypted job spec, pack chunks, and signed manifest; upload encrypted result chunks. All payloads stay ciphertext to the server.
Rate limits are part of the security model. Auth limits (registration, login, recovery, device-registration) live in Postgres as peppered token buckets so counters survive restart and are shared across instances. A separate process-local session-token guard caps invalid-bearer spray on data-plane routes. Both fail closed — the server prefers a bounded 503 over letting an unchecked attempt through.

13. Self-custodied CI runner

The first CI product is bring-your-own-machine. The runner is a repo-scoped principal, not an account device — its signing key authorizes job claim, heartbeat, completion, and runner audit events, plus lease-gated job-scoped fetches of the encrypted job spec, signed manifest, and pack chunks, and encrypted result-chunk uploads. Repo key material reaches the runner only through owner-controlled handoff: the owner CLI exports a repo content key file that the operator places on the runner host — the runner is never a keyring recipient. With that material the runner verifies the owner-signed manifest and materializes a checkout in an isolated workspace before executing the job.

Self-custodied runner lifecycle Registration and pairing, job dispatch and lease, execution under lease (material fetch, checkout, executor, completion), and the failure and control modes: lease expiry, replay, cancellation, and revocation. Registration & pairing Owner CLI signs registration intent 15-min TTL · pairing code pending row Server issues registration_id stores peppered hash of code claim Runner host generates Ed25519 + X25519 encrypted config (Argon2id + ChaCha20Poly1305) approve Owner CLI signs runner pubkeys + pairing code Job dispatch & lease Owner CLI creates runner job signed envelope · allowed_runner_ids repo_runner_jobs queued · cap 1000/repo ≤16 allowed runners · timeout_ms claim (signed) Runner claim → attempt_id + lease_id verifies owner sig locally Execution under lease Fetch material spec · manifest · chunks job-scoped · lease-gated · signed Materialize checkout verify owner-signed manifest decrypt locally · isolated workspace Executor spawn operator command cleared env · HUSHGIT_* metadata Complete (signed) PUT encrypted result chunk 25s grace · exact retry idempotent Server terminal status incl. canceled · timed_out signed monotonic heartbeats run throughout execution — 2-min staleness · cannot shorten the lease · refreshed before long steps Failure & control modes Lease expiry stale attempts re-queued 5 stale → job marked timed_out no infinite loop Replay claim sig is one-poll retry token replay → original attempt or job:null heartbeat / complete need a fresh sig Cancellation owner-signed POST · 15-min staleness queued or running → canceled attempt stops at its next poll Revocation signed POST · future-authority-only jobs with no remaining allowed runner marked timed_out by sweep
Self-custodied runner lifecycle. Pairing, claim, material fetch, heartbeat, cancellation, and completion are all signed; the server is opaque storage and enforces freshness/replay windows and lease accounting. Between claim and execution the runner fetches the encrypted job spec, signed manifest, and pack chunks over job-scoped signed routes and materializes a verified checkout in an isolated workspace. Owners can cancel queued or running jobs with a signed request; a running attempt observes the cancellation at its next poll and stops.
Runner job material and checkout sequence Sequence diagram of one CI job: out-of-band repo key handoff, owner job creation, runner claim, signed material fetches, checkout materialization, execution with heartbeats, encrypted result upload, and signed completion. trust boundary trust boundary Owner CLI hushgit-server Runner Executor 0 · ci material export → repo content key file on the runner host out-of-band, owner-controlled — never touches the server 1 · create job — signed envelope · encrypted spec chunk 2 · claim (signed) → attempt_id + lease_id 3 · POST {jobId}/spec-chunk — signed · lease-gated 4 · spec ciphertext → decrypt with repo key file 5 · POST {jobId}/repo-objects/{objectId} → manifest · verify owner signature 6 · POST {jobId}/chunks/{chunkId} ×N → materialize checkout 7 · spawn command cleared env + HUSHGIT_* metadata 8 · signed heartbeats — monotonic · lease refresh 9 · exit status 10 · PUT result-chunks/{chunkId} (encrypted) · complete (signed) 11 · list jobs · fetch + decrypt result owner cancellation: signed POST → job canceled; a running attempt observes the terminal state at its next poll and stops
One CI job end to end. The repo content key never transits the server — the owner exports it directly to the runner host. Every server interaction after claim is lease-gated and runner-signed; the spec, manifest, chunks, and result are ciphertext to the server.
PhaseSigned payload includesServer-side bounds
Intent (owner) random UUIDv4 registration id, native device id, ts 15-min staleness, per-repo pending cap
Claim (runner) pairing code, runner signing + wrapping public keys, ts 5/burst + 60s refill, 50 lifetime per registration, 40-bit code
Approve (owner) exact runner pubkeys + key id, pairing code, ts 15-min staleness, single-use per registration
Job claim repo id, runner id, lease_duration_ms, ts 2-min staleness; server-enforced lease range [1s, 900s] (the runner CLI further requires ≥60s); replay returns original attempt
Material fetch repo, runner, job, attempt, lease id, exact chunk / object id, ts 2-min staleness; lease must be active and the runner approved; serves the encrypted job spec, signed manifest, and pack chunks as ciphertext only
Heartbeat repo, runner, job, attempt, lease id, status, ts monotonic, cannot shorten lease, older heartbeats rejected
Complete repo, runner, job, attempt, lease id, result (success / failure), ts, result chunk id? accepted only while lease active and runner approved; exact retry idempotent
Cancel (owner) user id, repo id, job id, authorizing device id, ts 15-min staleness; owner session plus an active repo-signer signature; replay idempotent; running attempts observe the terminal state at their next poll and stop
Revoke (owner) repo id, runner id, ts 15-min staleness, idempotent, future-authority-only
Runner config at rest. The runner CLI encrypts its private keys with Argon2id-derived ChaCha20Poly1305 and hardens file permissions on Unix; it fails closed on non-Unix until platform ACLs are wired in. Confidentiality of a leaked config is bounded by the passphrase entropy — operators should source it from a password manager, not a memorable string, and never from .env next to the config.

14. Trust-tier matrix

Concrete summary of which client tier can do what. Anything outside this matrix is by construction either client-side computation or denied by the protocol.

Capability CLI (native) Sidecar (native) git-remote-hushgit Runner Desktop renderer Web
OPAQUE login (account auth)via sessionvia sidecar
Hold device signing private keyvia profile
Native bootstrap (mint native trust)
Approve device enrollment
Create / delete repo
Sign chunk upload / event appendrunner events · job results
Open encrypted review payload
Sign merge certificate
Rotate / revoke keyring
Claim runner job, heartbeat, complete
List opaque catalog / pending enrollmentsreadread
Fetch encrypted event ciphertextreadread
Fetch encrypted chunks / repo objectsjob-scoped

✓ allowed and authoritative · "read" allowed but ciphertext-only · "via sidecar"/"via session" delegated through native authority · "runner events · job results" the runner key signs runner job/audit routes and job-scoped result uploads · "job-scoped" fetch statements bind job, attempt, and lease ids and require an active lease

15. Threat-model notes worth memorising

The server is treated as actively malicious

It can read, modify, delete, reorder, or replay any byte it stores. It may also attempt equivocation — showing different event histories to different users. Mitigations: signed manifests, signed event log with parent-hash chaining, client-pinned heads, and snapshot replay against signed change logs.

Browser is convenience, not trust

Browser-delivered JavaScript is untrusted unless build transparency/reproducibility is solved. Browser sessions can read opaque catalog and ciphertext but cannot receive payload-opening keys, cannot create repos, and cannot sign repo events. CORS + browser-execution-header rejection enforce origin separation; the actual authority is the native device signature.

Revocation is future-access-only

A revoked device retains any plaintext or key material it already received until the repo is rotated. Repository content-key rotation creates a new epoch for future writes but preserves old epoch wraps so authorized historical readers still work — true forward secrecy requires history rewrite or re-encryption.

Native bootstrap is a one-shot trust window

Before any native-trusted device exists, password compromise plus access to the OPAQUE login flow is sufficient to mint the first native-trusted device. After native trust exists, additional devices require approval from an existing native-trusted device or a personal recovery key. Account recovery codes reset password access; they do not authorize a Git signing device.

Protected branches are client-enforced

The server stores signed events, encrypted chunks, packs, signed manifests, and CAS preconditions — it does not decide ancestry or whether a merge certificate semantically satisfies policy. A malicious server can withhold, fork, or try to advance encrypted state incorrectly; clients detect this on signed-manifest load and refuse protected refs without a valid direct-push authorization or merge certificate.

Personal recovery kit is one factor

The encrypted kit file plus the 256-bit recovery code together unwrap any repo content-key epoch wrapped to the recovery key and sign a narrow RecoveryDeviceAdd. They must not authorize ordinary repo writes or arbitrary keyring changes. Store kit and code separately; rotate or revoke after suspected exposure.

Acknowledged MVP leakage

Org and account existence, billing/membership edges, push/pull frequency, timing, approximate ciphertext sizes, and network metadata are observable. Not leaked: repo names, branch names, file paths, commit messages, PR titles/descriptions/comments, review decisions, plaintext CI logs (when stored by the customer runner).

Equivocation defense is enforced for current server state

Current server-backed account, repository, runner, catalog/list, and byte-body clients verify witnessed checkpoints, subject-map proofs, private openings, local-pin consistency, and client-verifiable operation fields before exposing trusted state. Release and web build transparency, plus future inventory or pagination APIs, must add their own proof-bound semantics before they join that claim.

16. Glossary

TermMeaning
AADAuthenticated Associated Data — header fields bound into the AEAD tag so they cannot be modified without invalidating decryption.
CASCompare-and-set. Used on object PUTs via If-Match/If-None-Match to detect concurrent updates.
ChunkId vs ChunkKeyChunkId (in hushgit-protocol) is the logical identifier shaped like chk_<32-hex-chars>; ChunkKey (in hushgit-storage) is the backend storage key used by the ChunkStore trait. The server's repo_chunks row maps logical chunk id → blob key.
Convenience deviceA browser-only Ed25519 key created during web signup. Identifies the session but cannot sign repo events or approve enrollments.
Device idOpaque identifier bound to a specific signing keypair. Rotating signing material requires a new device id.
EncryptedEnvelopeUniversal AEAD container: version, algorithm, purpose, key id, nonce, ciphertext. AAD-binds repo and object identifiers.
Epoch (key)The integer version of a repo content key. Increments on rotation; old epochs retained for historical reads.
HUSHGIT_HOMEPer-OS-user directory holding profile metadata and device-store roots. Created with 0o700 permissions.
KeyringSigned snapshot of authorized devices, revoked devices, wrapped repo keys, recovery policy, and epoch pointers. Cached materialized view of the keyring-change log.
MergeCertificateSigned object binding a PR id, source/target/result object ids, approval event ids, and signer device. Required for advancing a protected ref via merge.
Native bootstrapThe one-shot OPAQUE-finalized flow that mints the first native-trusted device for an account.
OPAQUEAugmented PAKE used for account registration and login. The server never sees raw passwords or hashes of raw passwords.
Personal recovery kitLocally encrypted file plus 256-bit code that holds repo-scoped recovery wrapping + signing keys. Single-factor offline credential.
ProfileLocal credential unit for one account on one server. Carries a stable profile_id, session revision, and credential-store account identifier.
RepoEventKindClosed protocol enum of mutating action types (Push, RefUpdate, PolicyUpdate, PullRequest*, Comment*, ReviewSubmit, MergeIntent, MergeCertificate, KeyringChange).
TrustedRemoteClient-side abstraction over a hushgit remote (local filesystem or server-backed). Owns manifest decoding, keyring validation, and chunk fetch/store.