Research · Capsule v0.6 · Cryptographic redesign

Cryptographic redesign from v0.5 to v0.6

The v0.5 envelope had real footguns: hex-string signing inputs, a derived signing-hash that drifted from the envelope schema, no per-role domain separation, a chain that hashed UTF-8 strings instead of raw bytes, and a capsule_id that any third party could squat. v0.6 is the redesign that gets those corrected before a second independent implementation locks the format. Each change below follows the same structure: v0.5 had / v0.6 has / why / what breaks.

2026-05 · Protocol v0.6 · SDK

Why this redesign exists

The v0.5 format was a working JavaScript reference implementation. It served its purpose — round-tripping the canonical demos, signing the envelope, walking the chain, decrypting the inner content — well enough that we shipped it and demoed it. What it did not survive was a small number of partners attempting independent implementations. Each one surfaced a different ambiguity, a different footgun, or a different place where the JS code was the spec rather than the document being the spec. None of them shipped a second working implementation, and the reason in each case was the same: the format had silent interop bombs that the JS reference happened not to step on.

v0.6 is the redesign that locks the envelope shape before a second implementation lands. The goal is narrow and deliberately defensive: remove every interop bomb a second implementer is likely to hit, before they hit it. The kill criterion for milestone 1 of the roadmap is "two independent reviewers each find a different envelope flaw we missed." The work below is what we found, what we replaced it with, and the explicit acknowledgement that v0.6 capsules are not backward-compatible with v0.5 capsules at the signing or chain layer. Anything that round-trips both is doing two different things.

Signature input is raw bytes, not a hex string

This is the largest single interop bomb in v0.5. Ed25519 signing is a pure function of bytes in to bytes out. Any two implementations that disagree about which bytes are signed will produce signatures that fail to verify against each other — even though both implementations are correctly implementing "Ed25519."

v0.5 had
Ed25519.sign(utf8(hex_string)) — the signing input was the UTF-8 bytes of a hex-encoded hash. The JS reference happened to UTF-8-encode the hex string. A Python or Rust port could just as reasonably hex-decode it back to raw bytes first and produce a different, equally well-formed signature.
v0.6 has
Ed25519.sign(domain_sep || canonical_payload) — raw bytes only. No hex string appears anywhere in any signing input. See envelope.md · Signing.
Why
This is the kind of detail that doesn't show up until a second implementation tries to verify the first one's signatures and fails for reasons the original author didn't predict. It's the most likely silent-failure mode in any envelope-signing format, and it was real.
What breaks
Any v0.5 envelope is unverifiable under v0.6 verifiers. The v0.6 spec is intentionally not backward-compatible at the signing layer.

Signing payload is the full canonical envelope minus signers, not a derived signing-hash

v0.5 constructed a signing hash: a SHA-256 over the concatenation of three independently-derived hashes. The envelope schema described the fields; a separate paragraph described which fields were folded into the signing input and in what order. Two specs for one signature.

v0.5 had
signing_hash = SHA-256(checkpoint_hash || ciphertext_hash || skill_hash). Three hashes derived elsewhere, concatenated, and re-hashed. The signature was over that hash.
v0.6 has
JCS(envelope minus "signers") — the JCS-canonical bytes of the actual envelope object, with the signers array removed, signed directly. No intermediate "signing hash" of any kind.
Why
The derived signing_hash had three problems. (1) It depended on which fields the spec chose to enumerate, which meant adding a new envelope field that wasn't on the enumerated list was a covertly unsigned field. (2) It made it impossible for a future field added to the envelope to be covered by the signature without a spec-incompatible change. (3) It duplicated the contract: the envelope schema said one thing, the signing_hash construction said another, and they could drift. Signing the canonical envelope payload directly makes the envelope schema the single source of truth.
What breaks
Same answer as above. v0.5 envelopes don't verify under v0.6.

Domain separation per role

A signature is a fact about bytes. It is not a fact about intent. v0.5 signed a single shared input across all signer roles, which meant a signature produced in one role was a valid signature in any role over the same bytes.

v0.5 had
A single signing input shared across all signer roles. A signature by the creator over an envelope was a valid signature by a hypothetical notary over the same envelope bytes — cross-role replay was trivially available to anyone who could collect one signature.
v0.6 has
domain_sep = utf8("capsule-provenance-v0.6:" + role + "\x00") prepended to the canonical payload. The signature is over domain_sep || canonical_payload. Each role signs a distinct byte sequence.
Why
Cross-role signature replay. A pure-math signing operation that's valid over bytes is valid over bytes regardless of who you "intended" the signer to be. Without per-role separation, an attacker who collects one signature over one envelope by one role can claim it as a signature by any other role on the same envelope.
What breaks
Even if you reconstruct the canonical payload exactly, a v0.5 signature won't validate under v0.6 verification, because you also have to reconstruct the missing role prefix — which can't be done since v0.5 didn't include role in the input. The roles are real fields now (originator, creator, approver, notary, compliance, legal) and each one signs its own bytes.

Chain hashing uses raw bytes everywhere

The same family of footgun as Section 02, this time at the chain layer. Hex strings have no business being inside hash inputs.

v0.5 had
SHA-256(prev_hash_hex_utf8 || JCS(event)) — the previous event's hash hex string was UTF-8-encoded and included in the next event's hash input. The genesis "previous hash" value was the literal string '0' * 64 (64 ASCII zero characters).
v0.6 has
SHA-256(prev_hash_raw32 || JCS(event)) — the previous event's hash is hex-decoded to 32 raw bytes before concatenation. Genesis previous-hash is 32 zero bytes, not 64 ASCII zeros. See chain.md · Hashing.
Why
Same root cause as the signing-input bomb. A second implementation reading "prev_hash goes into the hash input" has no way to know whether the JS reference hashed raw bytes or hex bytes without reading the JS source. The genesis representation is even more brittle: '0' * 64 and \x00 * 32 are textually similar enough that an implementation can get it wrong silently and chain a few events before anyone notices a divergence.
What breaks
Every v0.5 chain hashes differently under v0.6 reading. Migration is not byte-compatible. A v0.5 chain re-hashed under v0.6 rules produces a different first_event_hash — which, in turn, changes the capsule_id (next section).

Capsule identity bound to a key

v0.5 derived the capsule's identity purely from the first event of the chain. The identity carried no commitment to who produced it, which meant anyone could declare the same identity for an unrelated capsule whose first event happened to hash the same way.

v0.5 had
capsule_id = first_event_hash. The id was squattable: any third party could declare a capsule_id matching another capsule's first-event hash, because the id didn't depend on the producer at all.
v0.6 has
capsule_id = SHA-256("capsule-id-v0.6\x00" || originator_pubkey_raw_bytes || first_event_hash_raw_bytes). The prefix is a 16-byte domain separator (15 ASCII bytes plus a trailing NUL). All concatenations are raw bytes. See manifest.md · Capsule identity.
Why
Any future ledger of capsule identities (which is explicitly parking-lot, but anticipated) needs identities that can't be claimed by a third party. Binding to the originator's public key is the cheapest path: squatting on a capsule_id now requires the squatter to possess the private key whose public key they want to claim. The math says no — we don't have to.
What breaks
Every v0.5 capsule_id is invalid under v0.6. Migration tools that round-trip the chain would need to write new capsule_id values from the originator's key. The id is a derived quantity, not free-form input.

Fail-closed cipher enum — drop the AES-GCM placeholder

v0.5 advertised three ciphers; the third was never implemented in the reference SDK. A capsule could lawfully declare cipher: "AES-256-GCM" and there was no compliant implementation that could open it.

v0.5 had
Cipher enum was none | ChaCha20-Poly1305 | AES-256-GCM, where the third value was advertised but never implemented in the reference SDK.
v0.6 has
Cipher enum is none | ChaCha20-Poly1305 — exactly what ships. Unknown values fail verification closed. Adding a cipher is a v0.7 schema change, not a per-implementation choice.
Why
Advertising a cipher you don't ship is a security claim you can't honor. A compliant implementation might accept an AES-256-GCM-labeled blob, refuse to decrypt it (because nothing in the SDK can), and look ambiguous to the caller — pass or fail? v0.6 makes the answer "fail closed."
What breaks
Any v0.5 capsule with cipher: "AES-256-GCM" is unreadable under v0.6. (It would not have been readable under v0.5 either, because no SDK shipped that path. v0.6 just stops pretending.)

What v0.6 doesn't fix

v0.6 closes the integrity gaps. It does not close the trust gaps, and it does not claim to. The threat model in trust.md is explicit about what stays open:

Self-attested time

The envelope's signed_at is whatever the signer wrote at seal time. Nothing outside the capsule witnesses it. An originator with a valid key can trivially backdate or post-date a seal. External time anchoring — RFC 3161 timestamping, a Rekor-style transparency log — is parking-lot for v0.7+. The format documents the gap rather than pretending to fill it.

Identity binding is out of scope

The envelope proves that a particular private key signed a particular envelope. It does not prove anything about whose key that is. Mapping a public key to a real-world entity is a host concern — allowlist, organizational directory, identity registry, KMS — not a format concern. The verifier reports per-signer outcomes; it does not return trusted: true.

An originator can still produce a verifying capsule

If the originator's private key is compromised, an attacker holding that key can produce capsules that verify. By construction. Capsule proves the math — this content, signed by this key, at the time the signer wrote down — not the authority of the key.

The test vectors are the contract from v0.6 onward

The change that matters most isn't any individual fix above. It's the shift in where the spec lives.

Under v0.5 the JavaScript reference implementation was, in practice, the spec. The document described the format; the code resolved the ambiguities. Every footgun above existed because a second implementer asking "which bytes get signed?" had to read the JS to find out. Two specs for one signature, but only one was authoritative.

Under v0.6 the spec is the document plus signed test vectors. A second implementation is "compliant" exactly when it round-trips those vectors bit-identically — same canonical bytes, same hashes, same signatures. Once that gate is passed, v0.6 is locked. Any future v0.7+ change goes through the same gate: ship the change, publish the vectors, get parity from at least one second implementer, then lock.

The vectors are the boundary between "the v0.5 reference SDK was the spec" and "the spec is documents plus vectors, and implementations are accountable to both."

Milestone 1 of the roadmap kills if two independent reviewers each find a different envelope flaw we missed. That's the standard the redesign is held to: not "no flaws," but "no flaws an outside engineer surfaces that we should have surfaced first."

Where this leaves the format

The reference JavaScript SDK ships against v0.6 with signed test vectors. A Python implementation is upgrading from v0.5; a Rust verifier is in flight. The format is intentionally not backward-compatible with v0.5 at the envelope or chain layer — a v0.5 capsule will fail v0.6 verification, and that is the correct outcome. The goal of v0.6 was to publish the format that won't need another break of this kind, by removing every interop bomb a second implementer is likely to hit before they hit it. It is a prototype. It is not yet v1.0. The path to v1.0 runs through a second implementation, signed test vectors, and an outside review of the crypto — in that order.

Specification: envelope, chain, manifest, trust. Reference SDK: capsules.run/sdk.