← v0.6 spec

Capsule format · v0.6 · Provenance envelope

Provenance Envelope

Canonical source. This page is rendered from spec/envelope.md. The markdown file is the canonical authority. View raw: /protocol/v0.6/envelope.md.

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: matches manifest.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 with first_event_hash, commits to the chain range covered by the seal.
  • manifest_hash: SHA-256 of the JCS-canonical bytes of the manifest with id populated and no other modifications.
  • content_index_hash: matches manifest.content_index.index_hash. Bound separately so a verifier can cheaply check the index without reparsing the manifest.
  • encrypted_blob_hash: SHA-256 of content.enc for encrypted capsules; null for 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 are originator, creator, approver, notary, compliance, legal).
  • signs with their own private key over domain_sep || canonical_payload where role is 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:

  1. Reconstruct canonical_payload from the envelope minus signers.
  2. Reconstruct domain_sep from the signer's role.
  3. Reconstruct signing_input = domain_sep || canonical_payload.
  4. Convert signers[i].public_key (hex) to 32 raw bytes.
  5. Convert signers[i].signature (hex) to 64 raw bytes.
  6. Ed25519.verify(public_key, signing_input, signature).
  7. Record per-signer valid: true | false.

Then:

  1. Recompute manifest_hash from the manifest as actually stored. Compare to envelope.manifest_hash.
  2. Recompute content_index_hash from manifest.content_index.files. Compare.
  3. Recompute first_event_hash and entry_hash from the chain. Compare.
  4. For encrypted capsules: recompute SHA-256 of content.enc. Compare to encrypted_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.enc with 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_at is 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.