cli.sgit.ai / Research / Sealed files — a second encryption layer on top of a vault

Sealed files — a second encryption layer on top of a vault

Date2026-10-09 · 18:23 UTCKinddesignTopicscrypto sealed-files pkiWritten bythe sgit-ai CLI team (Claude Code sessions), for Dinis CruzSourceteam/humans/dinis_cruz/claude-code-web/10/09/design__sealed-files-layer.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 · Companion: design__native-pki-vault-mode.md (same folder). This layer works on either vault mode, symmetric (today) or native PKI.

Revised 2026-10-10 after the sgit.ai agent's review (dev at 0a0707d). Three corrections, applied below:

  1. No "compare by re-sealing". Sealing is randomised (a fresh file key and ephemeral key every time), so re-sealing never reproduces the stored bytes. The tree entry instead carries a keyed hash of the plaintext: HMAC-SHA256 under a key derived from the vault read key, stored under the read key. A recipient compares plaintext to that HMAC. A non-recipient compares the sealed bytes. Unlike the unkeyed content_hash, the keyed hash does not let a vault reader confirm a guess of the content.
  2. Seal-policy changes need an authorised signer. Otherwise any writer can "unseal by policy": drop a rule or a recipient, and the next commit of that path is sealed to fewer people, or not at all. .sgit/seal names its admins (public keys). A commit that changes the policy must be signed by one of them, or pull refuses it, as signatures-required does. The first policy is pinned by the clone that adds it (trust on first use, like the PKI mode's writer set).
  3. The construction is Web-Crypto-native: ECDH P-256 + HKDF-SHA256 + AES-256-GCM. That is the HPKE base mode with the P-256 KEM (RFC 9180, DHKEM(P-256, HKDF-SHA256)). Browsers open sealed files with no WASM dependency, and CLAUDE.md's byte-for-byte parity rule holds with test vectors. age is kept as an interop path only:

Revised again 2026-10-10 after the release-gate review (dev at eed8084). Two more corrections:

  1. The comparison value moves inside the sealed envelope. A keyed hash under a key derived from the vault read key (correction 1 above) does not stop guessing: every reader holds that key, so a reader who is not a recipient can still compute the HMAC of a guess and compare. The comparison value is now HMAC-SHA256(K_cmp, plaintext) with K_cmp = HKDF(FK, info="sgit seal compare v1"), where FK is the per-file key that only recipients unwrap. It is stored inside the envelope, as the first 32 bytes of the encrypted payload, never in the tree entry. A recipient's clone caches it per sealed blob id in a 0600 local file, so status does not call the identity provider on every run. The tree entry holds only the hash of the sealed bytes.
  2. The chunked AES-GCM payload has defined framing (§3.1): a per-file payload key, a counter nonce, and the chunk index and a final-chunk flag in the AAD. A reader refuses truncation, reordering, extension and a final flag in the wrong place.

1. The idea, in your words and in mechanism

As the author I can say: "here is my public key, encrypt this data with it; if you need to decrypt it, point over there". The files still exist in the vault as before, but only someone with the private key can read them. The service holding the private key can be local, remote, or anything else.

Mechanically, this is envelope encryption to recipients' public keys, with decryption delegated to an identity provider. git-crypt (GPG), SOPS (KMS/age/PGP) and age (X25519, ssh keys, hardware plugins) all use this pattern. The vault keeps doing its job, and selected files get an inner envelope:

 plaintext ──► [ inner: sealed to recipients' public keys ]  ──► [ outer: vault encryption ] ──► host
               file key FK (random) encrypts the content          read key / DK, exactly as today
               FK is wrapped once per recipient public key
WhoSees
The hostciphertext only (outer layer), as today
A vault reader (read key)that the file exists: its name, path, history, approximate size, who changed it when. Not its content.
A recipient (holds, or can ask for, a matching private key)the content

Two levels, two key systems:

2. What each party can do, precisely

PartyCreate a sealed fileRead itEdit itReplace / delete it
Recipient (with its provider)yesyesyesyes
Vault writer, not a recipientyes: only the recipients' public keys are needednono (editing needs reading first)yes, blindly: it can write new sealed bytes over the file, or delete it
Vault readernononono
Hostnononowithhold or replay, as today

One correction to "you could only edit or modify a file if you have that key". Editing needs the key, because you must read before you change. Replacing does not. Any vault writer can put new sealed bytes at the same path, because sealing needs only public keys. Stopping that is an integrity question, not a confidentiality one, and has two answers that can be combined:

  1. Signed envelopes: the sealer signs the inner envelope with its own key. Readers (recipients) check that the signer is on the path's allowed-author list (§4).
  2. Path rules enforced on commit/pull: a commit that changes a sealed path must be signed by an allowed author. Otherwise pull refuses it, as signatures-required does today. With the native PKI mode's writer set, this becomes a hard rule.

3. Format

Recommendation (revised 10-10): an HPKE envelope, DHKEM(P-256, HKDF-SHA256) + AES-256-GCM, all native to Web Crypto; age v1 as an export and plugin-bridge format only. The original reasoning for age follows, kept for the record.

Trade-off to decide. age's payload cipher is ChaCha20-Poly1305, which Web Crypto does not provide, while CLAUDE.md asks for Web Crypto byte-for-byte parity. The browser would use typage (the reference TypeScript age implementation). The alternative is a custom HPKE envelope (X25519 + HKDF + AES-256-GCM, all in Web Crypto), at the cost of the tool ecosystem and the plugins. My view: age. The plugin ecosystem is the "decryption service elsewhere" feature, and the parity rule can name typage as the browser implementation for this layer.

sealed file   = "age-encryption.org/v1\n"
                "-> X25519 <ephemeral>\n<wrapped FK>\n"        one stanza per recipient
                "-> piv-p256 <tag> <…>\n<wrapped FK>\n"        a hardware / plugin recipient
                "--- <header MAC>\n"
                <payload: ChaCha20-Poly1305 STREAM, 64 KiB chunks, under a key from FK>
              [ + optional detached Ed25519 signature by the sealer, in a sidecar stanza ]

3.1 Payload framing (HPKE envelope, revised 10-10)

header        = magic "sgit-seal/1" ‖ header_nonce (16 random bytes) ‖ recipient stanzas
                (each: HPKE enc ‖ HPKE-Seal(recipient pub, FK)) ‖ chunk_size (u32, 65536)
H             = SHA-256(header)
PK            = HKDF-SHA256(ikm=FK, salt=header_nonce, info="sgit seal payload v1")   (32 bytes)
plaintext'    = HMAC-SHA256(K_cmp, plaintext) ‖ plaintext        (the comparison value rides inside)
chunk i       = plaintext'[i·65536 : (i+1)·65536]                 (the last may be shorter;
                                                                   an empty file is one empty final chunk)
nonce_i       = u88_be(i) ‖ final_i                               (12 bytes; final_i = 0x01 on the last chunk only)
aad_i         = H ‖ u64_be(i) ‖ final_i
ct_i          = AES-256-GCM(PK, nonce_i, chunk i, aad_i)
sealed file   = header ‖ ct_0 ‖ ct_1 ‖ … ‖ ct_n

4. Policy: which files, to whom, written by whom

A committed policy file, signed like any commit. It works the way .gitattributes works for git-crypt:

# .sgit/seal      (committed; changes to it are themselves a signed, reviewable commit)
recipients:
  dinis   = age1qy…            # published key, fingerprint pinned here
  backup  = age1zz…            # offline recovery key
  ops     = age1yubikey1q…     # hardware-held
seal:
  secrets/**        -> dinis, backup
  finance/*.xlsx    -> dinis, ops, backup
  **/.env           -> dinis, backup
authors:                       # optional: who may change a sealed path (§2)
  secrets/**        -> dinis

5. On disk, in the working copy

CloneA sealed file looks like
Has a working identity for that filethe plaintext, written 0600 (secret writer). sgit status compares HMAC(K_cmp, plaintext) with the value inside the envelope (cached locally per blob, revised 10-10); never by re-sealing, which is randomised
Has no identity, or the provider is unreachablethe sealed bytes, unchanged (an age file). status treats it as unchanged, never deleted or modified: the same "keep the committed entry" rule the symlink ban uses

Integration points in the current code:

6. The decryption service ("identity provider")

Only the 16-byte file key crosses the boundary, never the content:

sgit ──(stanza: recipient tag + wrapped FK)──► provider ──(FK, or "refused")──► sgit
ProviderWhere the private key livesNotes
Local identity file~/.config/sgit/identities (0600)the simple default; written by the secret writer
ssh key / ssh-agentyour existing ssh key (ed25519/rsa)age natively supports ssh recipients; nothing new to manage
HardwareYubiKey / PIV / TPM / Secure Enclave via age-plugin-*touch-to-decrypt; the key cannot be copied
Remote servicea team service, cloud KMS, or an SG/Send service, over an authenticated APIcan log, rate-limit, require approval and revoke per unwrap, per file: an audit trail of who opened what. Offline means unreadable, by design

Configuration is per clone (.sg_vault/local/identities naming providers) or per user. The interface is the age plugin protocol, so any existing plugin works, and our own remote-service provider is just one more plugin.

Capability hand-off. The same envelope moves vault capabilities between people. sgit share --to dinis seals a read or write capability to Dinis's published key: no passphrase in chat, argv or shell history. This closes most of TM-R14 and is phase 2 of the earlier analysis.

7. What it protects, and what it does not

ThreatCovered
Host reads contentyes (outer layer, as today)
A vault member who is not a recipient reads a sealed fileyes
The vault read key leaks (a share, a log, an old clone)yes for sealed files: they need a recipient key as well
A recipient's key is revokedonly for new seals: history sealed to them stays readable to them (inherent; rotate secrets, not just files)
A non-recipient writer replaces a sealed filedetected with signed envelopes / author rules (§2); not prevented on the host
File names, paths, sizes, history from vault readersnot hidden (they live in the vault's tree). Sizes are approximate (age adds per-chunk overhead); pad if needed
Plaintext on a recipient's diskout of scope: it is the recipient's machine (written 0600)
Provider compromiseas strong as the provider: hardware or approval-based providers limit it

8. Relation to the native PKI mode

9. Effort

StepWhatCLI estimate
1HPKE envelope (DHKEM(P-256, HKDF-SHA256) + AES-256-GCM, chunked), Python and Web Crypto with shared vectors; seal export --age~1 week
2.sgit/seal policy, seal on commit, open on checkout/pull, sealed-bytes hashing, status rules1–1.5 weeks
3Providers: identity file + ssh keys, then the plugin protocol (hardware/KMS), then a remote-service plugin~1 week (+ service side)
4Signed envelopes + author rules on pull~0.5 week
5sgit share --to <contact> (capability hand-off)~0.5 week
—Web UI: open sealed files with Web Crypto (no WASM) + a provider bridgeweb team

10. Decisions for you

  1. Format (revised 10-10): the Web-Crypto-native HPKE envelope, with age as an export and plugin-bridge format. (Was: age.)
  2. Working copy: plaintext when the clone can open the file (git-crypt style), or always sealed on disk with sgit cat / sgit open to read?
  3. Integrity for sealed paths: signed envelopes + author rules in the first version, or later?
  4. First remote provider: an SG/Send-hosted unwrap service with approval and audit, or a cloud KMS plugin?

This page as markdown · its source, admin/content/articles/sealed-files-layer.md