provenance/envelope.json carries the signatures that bind the capsule to one or more keys at one moment in time. The schema and signing procedure below replace the prior format's signing_hash construction.
Schema
{
"version": "0.6",
"capsule_id": "<64-hex>",
"first_event_hash": "<64-hex>",
"entry_hash": "<64-hex>",
"manifest_hash": "<64-hex>",
"content_index_hash": "<64-hex>",
"encrypted_blob_hash": "<64-hex> | null",
"cipher": "none | ChaCha20-Poly1305",
"signed_at": "2026-05-07T12:00:00Z",
"signers": [
{
"role": "originator",
"public_key": "<64-hex ed25519 raw>",
"signature": "<128-hex ed25519 sig>"
}
]
}
Field rules
version:"0.6". Readers reject unknown versions; no silent upgrade path.capsule_id: matchesmanifest.id.first_event_hash: 32-byte SHA-256 hex; equals chain event 1's hash.entry_hash: 32-byte SHA-256 hex; equals the final event's hash at seal time. Together withfirst_event_hash, commits to the chain range covered by the seal.manifest_hash: SHA-256 of the JCS-canonical bytes of the manifest withidpopulated and no other modifications.content_index_hash: matchesmanifest.content_index.index_hash. Bound separately so a verifier can cheaply check the index without reparsing the manifest.encrypted_blob_hash: SHA-256 ofcontent.encfor encrypted capsules;nullfor plain capsules.cipher: enumerated value. Unknown values fail verification closed. Reserved values that are not implemented today (e.g.AES-256-GCM) are not in this enum. Adding a cipher is a v0.7 schema change.signed_at: ISO 8601 UTC, no fractional seconds. Self-attested by the signer at seal time.signers[]: at least one entry. See "Signing" below.
Signing
The signed payload is the JCS-canonical serialization of the envelope minus the signers field. There is no separate "signing hash" or intermediate hash construction.
canonical_payload = JCS(envelope minus "signers") // bytes
domain_sep = utf8("capsule-provenance-v0.6:" + role + "\x00")
signing_input = domain_sep || canonical_payload // bytes
signature = Ed25519.sign(signing_input) // 64 bytes
signature_hex = hex(signature)
Each signer in signers[]:
- supplies their own
role(free-form string; conventional roles areoriginator,creator,approver,notary,compliance,legal). - signs with their own private key over
domain_sep || canonical_payloadwhereroleis their role. - the resulting signature lands in
signers[i].signature.
The signing input is raw bytes. Hex strings, lowercased or otherwise, never appear in the signed input. This is the v0.6 fix for the prior Ed25519.sign(utf8(hex_string)) interop bomb.
Domain separation per role prevents replay of a signature across roles: a creator signature is not also a valid notary signature even over identical envelope bytes.
Verification
For each signer:
- Reconstruct
canonical_payloadfrom the envelope minussigners. - Reconstruct
domain_sepfrom the signer'srole. - Reconstruct
signing_input = domain_sep || canonical_payload. - Convert
signers[i].public_key(hex) to 32 raw bytes. - Convert
signers[i].signature(hex) to 64 raw bytes. Ed25519.verify(public_key, signing_input, signature).- Record per-signer
valid: true | false.
Then:
- Recompute
manifest_hashfrom the manifest as actually stored. Compare toenvelope.manifest_hash. - Recompute
content_index_hashfrommanifest.content_index.files. Compare. - Recompute
first_event_hashandentry_hashfrom the chain. Compare. - For encrypted capsules: recompute SHA-256 of
content.enc. Compare toencrypted_blob_hash.
The verifier reports an L2 result with per-signer outcomes. The verifier does not return trusted: true. Trust is a host concern, not a verifier concern — see trust.md.
Encryption
Encrypted capsules use the outer/inner shape from format.md. The encryption procedure:
content_key = random(32)
content_nonce = random(12)
aad = JCS({
"version": "0.6",
"capsule_id": <hex>,
"first_event_hash": <hex>,
"originator_public_key": <hex>,
"manifest_hash": <hex>,
"cipher": "ChaCha20-Poly1305"
})
content.enc = ChaCha20-Poly1305(content_key, content_nonce, aad, inner_zip_bytes)
For each recipient X25519 public key:
ephemeral_priv, ephemeral_pub = X25519.keygen()
shared = X25519(ephemeral_priv, recipient_pub)
wrap_key = HKDF-SHA256(
ikm = shared,
salt = recipient_pub,
info = utf8("capsule-key-wrap-v0.6"),
length = 32
)
wrap_nonce = random(12)
wrapped_key = ChaCha20-Poly1305(wrap_key, wrap_nonce, aad="", content_key)
The recipient bundle stored in skills/decryption/decryption.json:
{
"cipher": "ChaCha20-Poly1305",
"content_nonce": "<24-hex>",
"key_bundles": [
{
"recipient_public_key": "<64-hex x25519>",
"ephemeral_public_key": "<64-hex x25519>",
"wrap_nonce": "<24-hex>",
"wrapped_key": "<hex>"
}
]
}
This file is metadata. It is not a markdown skill. The prior format's skills/decryption/SKILL.md is removed in v0.6 because a markdown instruction surface for crypto-adjacent operations is a prompt-injection vector aimed at a recipient with their private key in scope.
L2 / L3
- L2 (encrypted outer verification, no recipient key required): envelope signatures verify, manifest hash matches, content index hash matches, encrypted blob hash matches, chain anchors match. The verifier reports per-signer outcomes. Does not require decryption.
- L3 (decrypted content verification, recipient key required): decrypt
content.encwith AAD and recipient flow above. Open the inner ZIP as a normal capsule. Recompute first/entry event hashes, manifest hash, content index hash. Compare to the outer envelope.
There is no L1 in v0.6. L1 (ledger-anchored existence) is parking-lot.
What the envelope does not prove
- That the keys in
signers[]belong to whom they claim. The envelope proves the math; trust is the host's responsibility. - That
signed_atis the real time of sealing. Self-attested time is trivially backdatable. External anchoring (Rekor / RFC 3161) is parking-lot for v0.7+. - That the contents are correct, true, or non-malicious. Integrity is not authority.