Documentation
¶
Overview ¶
Package capsule reads and writes the suite's encrypted backup container.
The container is kycap/3: a JSON object holding the manifest and a base64 ciphertext, and nothing else. The payload is sealed to the suite recovery public key through HPKE; there is no per-capsule symmetric key and no nonce carried in the container — HPKE derives it from the encapsulated key and the key schedule. The manifest is bound in as the AEAD's additional authenticated data, so every field describing the capsule is authenticated rather than merely present.
Three containers came before it and all are retired: kysignon-server's kycap/1 and kyrecovery-server's tar, which authenticated their ciphertext and left the manifest outside the AEAD, so the recovery topology a capsule advertises could be rewritten by anyone who could reach the file; and kycap/2, which fixed that and still handed back a raw key.
The payload is a gzipped tar of the backed-up files, extracted through the hardening in extract.go.
Index ¶
Constants ¶
const ( MaxFiles = 4096 MaxFileBytes = int64(64 << 20) // 64 MiB for any single member MaxExpandedBytes = int64(256 << 20) // 256 MiB of tar stream: members plus headers, padding, list and trailer )
Extraction budgets. A capsule is written by this suite, so these bound what a hostile or corrupt archive can do rather than what a legitimate one needs.
They are memory budgets, not archive sizes: Open holds the raw container, the decrypted payload and every expanded member at once, so the real ceiling is a few times the number below. The previous 4 GiB member and 8 GiB total were larger than the machines this runs on, which meant a hostile capsule OOM-killed the process instead of receiving ErrCapsuleTooLarge. A capsule holds keys and configuration; it is kilobytes in practice.
Exported so a consumer can map ErrCapsuleTooLarge to a message naming the limit without mirroring the numbers.
ponytail: raise these only alongside a streaming Open that does not materialise every member, because that is the thing the numbers are really standing in for.
const KycapFileFormat = "kycap/3"
KycapFileFormat identifies the one container this package reads and writes.
It is kycap/3: kycap/2 with the payload sealed to the suite recovery public key through HPKE instead of a per-capsule symmetric key the caller had to protect and split. Three containers came before it and all are gone: kysignon-server's kycap/1 and kyrecovery-server's tar, which authenticated their ciphertext and left the manifest outside the AEAD — so capsule_id, service_name, threshold, total_shares and the verification recipe could all be rewritten by someone who never learned the key — and kycap/2, which fixed that and still handed back a raw key.
Reading them was retired rather than kept: a reader that half-trusts a manifest cannot tell a caller which half, and nothing is in the wild.
const MaxContainerBytes = 384 << 20
MaxContainerBytes bounds attacker-controlled input before JSON parsing and base64 decoding can make additional copies of it. The plaintext ceiling is 256 MiB; this leaves room for base64 expansion, the manifest and archive framing.
Variables ¶
var ( // ErrCorruptCapsule reports a container that parsed but failed its payload hash. ErrCorruptCapsule = errors.New("corrupt capsule container or failed hash validation") // ErrUnknownContainer reports bytes that match neither container. ErrUnknownContainer = errors.New("not a recognised capsule container") // ErrPathTraversal reports an archive member that would escape the target directory. ErrPathTraversal = errors.New("capsule contains a path that escapes the target directory") // ErrCapsuleTooLarge reports an archive that expands past the permitted budget. ErrCapsuleTooLarge = errors.New("capsule payload exceeds the permitted expanded size") // ErrCapsuleEntryType reports an archive member that is not a regular file. ErrCapsuleEntryType = errors.New("capsule contains an entry that is not a regular file") // ErrTargetNotEmpty reports a restore target that already has contents. ErrTargetNotEmpty = errors.New("restore target directory is not empty") // ErrDuplicatePath reports two members that normalise to one destination. ErrDuplicatePath = errors.New("capsule contains two paths that normalise to the same destination") // ErrWrongRecoveryKey reports a capsule sealed to a recovery key other than the one // Open was given. It is checked before any decapsulation, so a wrong kit fails cheaply // and by name. ErrWrongRecoveryKey = errors.New("capsule is sealed to a different recovery key") )
Functions ¶
func DecodeCiphertext ¶
DecodeCiphertext reads the ciphertext field of a kycap/3 container.
Standard base64 only. Decoding used to accept raw-url as well, because ky_server_base and gridlock-server encoded that way — in capsules neither of them ever persisted. With those containers retired there is one writer and one alphabet, and a second accepted spelling is a second thing that has to stay true.
func EncodeCiphertext ¶
EncodeCiphertext writes the ciphertext field.
func Open ¶
func Open(raw []byte, with recoverykey.PrivateKey, targetDir string) (Manifest, []File, error)
Open parses a kycap/3 container, decrypts it with the recovery private key, verifies the payload hash, and returns the authenticated manifest with the files. When targetDir is non-empty the files are also written there under the containment rules in extract.go; the directory must be empty or absent. When it is empty nothing is written and the files are returned in memory.
The manifest is returned because a successful Open is the proof that the container was not modified after sealing and was sealed to this recovery key. It proves nothing else: not which product sealed it, not that it is the backup the caller expected, and not that it is the newest one. Every product in the suite seals to the same public key, and that key is not secret, so anyone holding it can mint a capsule this function accepts. A caller acting on a restore must compare ServiceName, CapsuleID and CreatedAt against what it expects, and take freshness from the deposit record rather than the container. Callers that want the manifest without a key want ReadUnverifiedManifest, and should read that type's doc comment first.
A capsule names the recovery key it was sealed to. Open compares that name with the key it was given before decapsulating anything, and fails with ErrWrongRecoveryKey on a mismatch — the custodians brought the wrong kit, and that is worth saying plainly.
Types ¶
type FileEntry ¶
type FileEntry struct {
Path string `json:"path"`
Size int64 `json:"size_bytes"`
Sum string `json:"sha256"`
Mode os.FileMode `json:"mode"`
}
FileEntry describes one member of the payload. The digest lets a restore say which file is wrong; the payload hash only says that one of them is.
The list is carried inside the encrypted payload, never in the plaintext manifest — see the Files field below for why.
Path and Mode are the normalised ones — the relative path extraction writes and the owner-only clamp it applies — not the caller's spelling of them, so an entry can be matched against the File values Open returns. Seal builds them that way (TestManifestEntriesDescribeWhatExtractionProduces), and Open refuses a list that disagrees with what it extracted (TestFileListMustDescribeTheExtractedMembers).
type Manifest ¶
type Manifest struct {
UnverifiedManifest
}
Manifest is a capsule's manifest, authenticated. Only a successful Open or Seal returns one.
The embedded field is not an accident: a Manifest can be read wherever the fields are wanted, but an UnverifiedManifest cannot be passed where a Manifest is required.
A Manifest is authenticated, not validated. Open proves the manifest is the one that was sealed to this recovery key, by whoever held its public half; it does not re-apply Seal's topology rule, so a kit recorded as 0-of-0 by some other writer opens without complaint. Check the numbers you intend to act on.
func Seal ¶
func Seal(serviceName, appVersion string, files []File, deps, recipe map[string]any, threshold, totalShares int, to recoverykey.PublicKey) (raw []byte, m Manifest, err error)
Seal writes a kycap/3 container sealed to the suite recovery public key, and returns it with the manifest it sealed.
The manifest is returned because Seal is the only place CapsuleID, CreatedAt and PayloadHash exist: they are minted here and have no other source. Returning only the bytes left a caller re-parsing its own output through ReadUnverifiedManifest to recover them — reaching for the keyless reader, whose doc comment says not to decide on what it returns, to read fields it had just authored. This value is a Manifest because it is the one that went into the AEAD, not one read back out of a container.
Seal returns no key. The payload is sealed to a public key through HPKE, the shared secret exists only inside crypto/hpke for the duration of this call, and the only thing that opens the result is the recovery private key the custodians hold in shares. A product that calls Seal holds nothing afterwards that it did not hold before.
That is a statement about confidentiality, not origin: the public key is not secret, and a capsule Open accepts could have been sealed by anyone who holds it. Authenticity comes from the channel that deposits the capsule, not from the container.
type UnverifiedManifest ¶
type UnverifiedManifest struct {
CapsuleID string `json:"capsule_id"`
ServiceName string `json:"service_name"`
AppVersion string `json:"app_version"`
CreatedAt time.Time `json:"created_at"`
PayloadHash string `json:"payload_hash"`
Threshold int `json:"threshold"`
// RecoveryKeyID names the recovery key this capsule is sealed to: recoverykey.PublicKey.ID().
// Not secret — it is a hash of a public key — and in the clear on purpose, so kyrecovery
// can display it and refuse a deposit sealed to a key it did not hand out, without
// holding any key at all.
RecoveryKeyID string `json:"recovery_key_id"`
// EncapsulatedKey is the HPKE encapsulated key, standard base64 of 1120 bytes. Public by
// construction. Inside the AAD like every other field, so swapping it fails the AEAD.
EncapsulatedKey string `json:"encapsulated_key"`
// Files is not a manifest field. The manifest is stored in the clear, so a per-member
// path, size and digest published there is readable by anyone who merely holds the
// capsule: the digest is an offline confirmation oracle — no key, no rate limit — that
// recovers any member drawn from a guessable space, and equal digests across two
// backups prove a signing key was never rotated between them. The whole-payload hash
// stays in the manifest because it digests a gzip stream whose ModTimes make it
// unguessable; a per-member digest is a different object.
//
// The list travels inside the encrypted payload instead, as reservedFileList, and Open
// fills this in from there. `json:"-"` is the enforcement: no later edit can marshal it
// back into the plaintext without deleting the tag.
// TestFileListNeverAppearsInTheClear holds that.
//
// So it is empty on the keyless path, which is the correct answer for a keyless read.
Files []FileEntry `json:"-"`
Dependencies any `json:"dependencies,omitempty"`
VerificationRecipe any `json:"verification_recipe,omitempty"`
}
UnverifiedManifest is a capsule's manifest read without its key.
Every field here is attacker-controlled wherever the capsule file is. The manifest is bound into the AEAD exactly so that threshold, total_shares and the verification recipe cannot be rewritten by whoever reaches the file — that is what kycap/1 got wrong, where a 2-of-3 kit could be restated as 1-of-1 and still open. Reading it without the key gives that guarantee up.
So it is a distinct type from Manifest. The compiler stops one being passed where the other is required, which catches the accident — it cannot stop deliberate construction, because Manifest's only field is exported and a caller may write Manifest{...} itself. The type is a guard rail, not a proof. Show these fields to an operator if you must; do not decide anything on them. Anything that chooses a restore path, a quorum, or a verification rule wants Manifest, which only a successful Open or Seal produces.
func ReadUnverifiedManifest ¶
func ReadUnverifiedManifest(raw []byte) (UnverifiedManifest, error)
ReadUnverifiedManifest returns a capsule's manifest without decrypting it and without a key. Read the doc comment on UnverifiedManifest before using it.