Skip to content

An attestation's issued_at and attester_id are outside the signature, and the spec never says so #185

Description

@macanderson

An attestation's issued_at, attester_id, key_id and algorithm are outside the signed preimage. A host that renders any of them beside a verified signature is presenting unauthenticated, rewritable text as though the signature covered it.

In plain words

A cryptographic attestation's signature only covers a small, fixed set of bytes — it does not cover who claims to have made the attestation (attester_id) or when they claim to have made it (issued_at). Anyone who touches the message in transit — a relaying host, a caching proxy, a compromised distribution step — can rewrite those two fields to say anything, and the signature will still verify as Valid. The spec never warns a reader of this, even though §6.5.2 sets the precedent of spelling out exactly this kind of boundary for a neighboring field.

§6.5.2 defines the frame commitment as exactly:

frame_commitment = SHA256(
    "contextgraph/attest/1/frame"
  ‖ enc_str(provider_id) ‖ enc_str(frame.id)
  ‖ enc_opt(frame.content_digest)
  ‖ chain_head )

contextgraph-types/src/attest.rs:615 implements precisely that. Every other member of ProvenanceAttestationsigned_commitment aside — is metadata the signature does not reach.

What follows, stated plainly

issued_at is forgeable. It is the attestation's only temporal claim, and anyone in the path — a relaying host, a caching proxy, a registry, a compromised distribution step — can rewrite it to any value and the signature still verifies. has_well_formed_issued_at (attest.rs:187) checks its shape, which is the part an attacker has no reason to get wrong. A deployment that reasons about attestation freshness at all is reasoning about a number nothing protects.

attester_id is forgeable. AttestationState::Attested carries it forward verbatim (contextgraph-host/src/trust.rs), documented as "the attesting authority the attestation names — who is accountable for the claim, as distinct from the key that produced it". Accountability is exactly what an unsigned field cannot carry: it can be edited to name a different authority and the signature still verifies, because the verifier resolves the key by key_id and never reads attester_id.

key_id and algorithm are forgeable, but harmlessly — altering either makes verification fail or report UnknownAlgorithm, which is the safe direction. They are listed for completeness.

The spec says none of this. §6.5.4 is careful and thorough about verdict distinctions, F15 is explicit that a verifier must distinguish an attestation that binds content from one that does not — and then the same section is silent about the four fields a reader is most likely to assume are covered. §6.5.2 already sets the precedent for stating this kind of thing out loud: it devotes a paragraph to "What a signature binds when content_digest is absent", "because it is not what the presence of a signature suggests". The same sentence applies here and was not written.

Reproduce

let mut a = sign_frame_attestation(pid, &frame, &seed, "k1", "acme", "2026-08-29T00:00:00Z");
a.issued_at   = "2099-01-01T00:00:00Z".into();   // any value
a.attester_id = "someone-else".into();            // any value
assert!(verify_frame_attestation(pid, &frame, &a, &pk).binds_content()); // still Valid

Why this needs a decision rather than a patch

There are two answers and they are not interchangeable.

  1. Document it. Add a paragraph to §6.5.2 in the register of the content_digest one, and a rule — in the shape of F15 — that a verifier must not report unsigned attestation metadata as attested, plus guidance that a host surfacing issued_at or attester_id marks them as unverified. Cheap, non-breaking, honest.

  2. Bind them. Put issued_at and attester_id into the preimage. This changes the commitment for every attestation ever produced, which under §13 U4 and §3.1 is a new major familycontextgraph/2. It cannot ride a 1.x minor.

Option 2 also raises a question option 1 does not: whether the protocol wants attestations to carry an authenticated timestamp at all, which is the door to expiry, replay windows, and revocation (see #136 on key lapse). That is a protocol-direction decision, not a bug fix.

Pillar

Stability. An attestation is evidence, and the boundary of what a signature covers is the whole of what evidence means. Leaving it unstated means two implementations will draw it differently, and the one that draws it generously will render forged metadata as signed.

Done looks like

  • §6.5.2 or §6.5.4 states which members the commitment covers and which it does not, in a list, with the consequence spelled out for issued_at and attester_id specifically.
  • A requirement in the F15 family: a verifier MUST NOT present unsigned attestation metadata as covered by the signature, and a host SHOULD mark it as unverified where it surfaces it.
  • attest.rs module docs carry the same boundary, since they are what an implementer in another language reads.
  • A decision recorded — an ADR — on whether an authenticated issued_at is wanted in a future major family, so the question is closed rather than rediscovered.
  • A regression test that pins today's behavior (verify_frame_attestation still reports Valid/binds_content() for a tampered issued_at/attester_id) and is updated once the F15 MUST NOT rule ships, so the boundary decision has a fail→pass witness rather than only prose.
  • Lands in contextgraph-types (attest.rs) and contextgraph-host (trust.rs) without growing either into a new god file under this workspace's size conventions.
  • Any new SPEC.md prose cites code by symbol (sign_frame_attestation, AttestationState::Attested) rather than a pinned line number, matching this org's citation convention.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions