skip to content

Why is base64-decoding a DSSE envelope's payload and parsing it not verification?

level: middleimportance: should knowfreq 42%

answer

  1. decoding is not checking
  2. the type is signed too
  3. raw bytes, no re-serialization
  4. verify, then parse, then match
  5. a length-prefixed pre-authentication encoding

basics

~20 s

Base64 is an encoding, not a signature check. A DSSE signature covers the payload bytes together with the declared payload type, so a verifier checks that signature and the type before trusting anything it decodes.

solid answer

~50 s

A DSSE envelope has `payload` (base64 of the serialized statement), `payloadType` (`application/vnd.in-toto+json` for in-toto), and one or more `signatures`. The signature is not over the base64 text and not over the JSON in isolation — it is over a pre-authentication encoding that concatenates a version prefix, the length and bytes of the payload type, and the length and bytes of the payload. That binds the type to the bytes, so a signed payload of one type cannot be relabelled as another. Decoding on its own therefore gives you attacker-supplied JSON. The correct order is: verify the signature over that encoding with a key your policy trusts, confirm `payloadType` is the in-toto media type, then parse, then check `_type` and `predicateType`, then match a subject digest to the artifact you hold. Only then read the predicate.

code

json · 7 lines
json
{
  "payloadType": "application/vnd.in-toto+json",
  "payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEi...",
  "signatures": [
    { "keyid": "...", "sig": "MEUCIQDf..." }
  ]
}

go deeper

for a junior

Know that the payload is only base64-encoded, not encrypted or protected by the encoding itself, and that a decode step proves nothing about where the content came from.

for a middle

Explain that the signature covers a length-prefixed encoding of the payload type plus the payload bytes, and why signing raw bytes avoids canonicalization bugs.

for a senior

Walk the verification order end to end and point at where real code leaks: logging, caching or routing on a field read before the signature was checked.

for a principal

Argue for an interface that makes the unsafe path unrepresentable — one verified-or-error entry point — rather than a documented convention every team is trusted to follow.

## What the envelope is An in-toto statement is not signed directly; it is wrapped in a **DSSE** envelope — a small JSON object with three parts: - `payload` — base64 of the **serialized statement bytes** - `payloadType` — a media type string; for in-toto attestations, `application/vnd.in-toto+json` - `signatures` — a list, each with a key identifier and a signature value The naive consumer does this: decode `payload` from base64, `JSON.parse` the result, read `predicate`, make a decision. Every step of that is real work and none of it is verification. Base64 is a transport encoding with no secret in it — anybody can produce a payload that decodes cleanly and parses into a well-formed statement claiming whatever they like. ## What the signature actually covers DSSE signs a **pre-authentication encoding** (PAE) of two things: the payload type and the payload bytes. The construction is a version marker followed by the byte length and content of the type, then the byte length and content of the payload, all concatenated with separators. Two properties fall out of that, and both are interview material. **The type is inside the signature.** You cannot take a blob that was signed as one media type and present it as another. Without that binding, a signature produced over some other kind of document could be replayed as if it were an attestation, and a verifier that only looks at the decoded JSON would never notice. This is exactly the failure mode where an envelope with a swapped payload type still "parses" — the parse succeeds because the bytes are still JSON, and the consumer never asked what those bytes were supposed to be. **Length prefixes remove ambiguity.** Because each field's length is signed alongside its content, no attacker can shift the boundary between type and payload to make one concatenation look like another. ## Why bytes, not canonical JSON DSSE deliberately signs the **exact serialized bytes** rather than a canonicalized form of the object. Canonicalization has an ugly history in signature systems: two parsers disagree on duplicate keys, number formatting, or unicode escaping, and a verifier that re-serializes a parsed object before hashing ends up validating bytes it never received. Signing the literal bytes means the thing you verified and the thing you parse are the same octets. The practical consequence for your code is that you must keep the raw decoded bytes around — verify over those, then parse those, and never re-serialize in between. ## The verification order that avoids the trap A correct consumer runs strictly in this sequence, failing closed at each step: 1. Parse the envelope structurally (this much is unavoidable) but treat everything inside as untrusted. 2. Recompute the pre-authentication encoding from `payloadType` and the decoded payload bytes; verify the signature over it against a key or identity your policy accepts. Count how many *trusted* signatures verified; DSSE permits several and says nothing about how many must be good — that threshold is your policy's call. 3. Check `payloadType` equals the in-toto media type. If it does not, this is not an attestation, however valid the signature is. 4. Now parse the payload as a statement, and check `_type` is a statement version you understand. 5. Match a `subject` digest against the artifact you actually hold, under an algorithm you accept. 6. Check `predicateType` is one your policy has a rule for. An unrecognised type is not a pass; it is an absence of evidence. 7. Only now read the `predicate`. Everything before step 2 is untrusted input, and any decision taken on fields read before their step is a bypass. ## The class of bug this prevents The generic shape is *time-of-parse before time-of-verify*: code that routes, logs, caches or filters on a field it has decoded but not yet authenticated. It is easy to introduce accidentally — a helper that pretty-prints the predicate for a build log, a cache keyed on `predicateType`, a metric label taken from the subject `name`. None of those looks like a security decision, and all of them let unverified attacker-controlled data steer behaviour. Keeping one function that returns *either* a verified statement *or* an error, with no path that hands back a parsed-but-unverified one, is what makes the rule enforceable rather than aspirational.

  • Why does DSSE sign raw serialized bytes instead of a canonical JSON form?
    Canonicalization is a well-known source of signature bypasses: parsers disagree about duplicate keys, number formats and escapes, so a verifier that re-serializes before hashing can end up validating bytes it never saw. Signing the literal octets keeps the verified bytes and the parsed bytes identical, at the cost of your code having to hold on to the raw payload.
  • An envelope carries three signatures and exactly one verifies against a key you trust. Is that enough?
    The envelope cannot answer that — your policy must. DSSE allows a list and defines no threshold, so you decide how many trusted signatures are required and from which identities. Treat signatures you cannot attribute as absent rather than as weak endorsements, and fail closed when the count of trusted, valid signatures is below the threshold.
  • What breaks if you log the decoded predicate before verifying the signature?
    You have let unauthenticated, attacker-shaped data into your system — into logs, and from there into dashboards, alerts and anything that parses them. It also normalises the habit: once one code path handles an unverified statement, another will make a decision on one. Structure the API so a caller can only ever receive a verified statement or an error.

saying these in an interview costs you the question

  • Treats a successful decode and parse as verification
  • Thinks the signature covers only the payload, not its type
  • Re-serializes the parsed JSON before checking the signature
  • Reads the predicate before matching the subject digest
  • Assumes any valid signature means a trusted signer

context