Skip to content

feat(gitlawb-attest): External Attestation v1 for ref-update certs#20

Merged
kevincodex1 merged 1 commit into
Gitlawb:mainfrom
achillewasque:feat/external-attestation-v1
May 31, 2026
Merged

feat(gitlawb-attest): External Attestation v1 for ref-update certs#20
kevincodex1 merged 1 commit into
Gitlawb:mainfrom
achillewasque:feat/external-attestation-v1

Conversation

@achillewasque
Copy link
Copy Markdown
Contributor

Adds an optional attestations field on ref-update certs plus a small crate to sign and verify them. An envelope with no attestations serializes to identical bytes as a bare cert, so nothing on the wire changes for nodes that don't opt in.

The current trust path in register hands every new agent a flat 0.05 with nothing else to anchor to. Attestations give it something to work with: SLSA or Sigstore for human-pushed code, sandbox + capability digests for agent-pushed code, in-toto for multi-step pipelines. This PR is the protocol crate only; provider implementations live elsewhere.

Protocol

Each attestation carries five fields:

  • type — discriminator, e.g. covenant/exec/v1, slsa/v1.0, sigstore/dsse/v1. Grammar: nonempty, ASCII, slash-separated segments of [a-zA-Z0-9._+-], capped at 128 bytes.
  • payload — opaque JSON, type-specific. A payload that happens to carry its own cert_hash field does not shadow the outer binding; the envelope's cert_hash is the only field verification consults.
  • cert_hashSHA-256(JCS(cert_body \ {signatures, attestations})) as lowercase hex. Strict lowercase is the spec because the JCS signing input includes this field verbatim — accepting mixed case would force every signer and verifier to normalize before computing the bytes, an easy spec rule to miss across language ports.
  • signerdid:key:z6Mk... (multibase base58btc, per the W3C DID:key spec). Other multibase encodings of the same key bytes are rejected on verify so allowlists and signer-pinning policies stay consistent across peers.
  • sig — base64url-no-pad ed25519 over b\"gitlawb-attest-sig/v1\n\" || JCS({type, payload, cert_hash}). The domain tag lives in the byte stream rather than the canonical JSON, so an attestation cannot be confused with a same-shape JCS document signed for a different protocol that uses the same Ed25519 key.

Registry maps type to verifier. Policy::AcceptKnown (default) lets unknown types pass without trust so adoption stays incremental. Policy::RequireAll enforces a per-repo allowlist but stays lenient on unknown types in the batch — anyone can attach an attestation, so short-circuiting on an unrelated attacker/spam/v1 would be a DoS vector. Policy::RejectUnknown is strict.

AttestedRefUpdateCert::verify_attestations(&Registry) is the one-call convenience: it equals registry.verify_all(&env.attestations, env.cert_hash()?) with one less binding to plumb through.

Canonical test vectors

Ed25519 signatures are deterministic (RFC 8032). JCS is deterministic (RFC 8785). SHA-256 is deterministic. So a fixed seed plus a fixed cert body produce exactly the same cert_hash, signer DID, and sig across every conforming implementation. tests/canonical_vectors.rs pins those values:

```text
SIGNER_SEED = 0x0123456789abcdef … 0xccddeeff
EXPECTED_CERT_HASH_HEX = 5898d7c7a7d32a5faaaf9a52bfc92e0eff98027554adeefba0c575816f43ce56
EXPECTED_SIGNER_DID = did:key:z6MkqkKY69DYo23W4YFDoCNgjr7cMvmJqHtJVoQkEzrbaopZ
EXPECTED_SIGNATURE_B64URL = 4L3ONbhrksB9pn--u86lMeAdePSiZZplAsxuN2Cy6vgI30ndle7gDUVuoGRJr4Ylhz7ipQvUzXaDTvZGFLPXBQ
```

A port to Go, JS, Python, or any other language has a concrete oracle to check against without reading Rust. If a vector drifts, that is a wire-protocol break — bump ATTEST_ENVELOPE_VERSION and document the migration, or it's a regression.

JCS

RFC 8785 pins object key order, number formatting, and string escaping. Without it the cert hash and attestation signatures depend on whichever JSON library the next implementor picks (BTreeMap vs IndexMap, struct field order, etc.), and interop breaks the first time a non-Rust signer shows up.

Backwards compatibility

Two unit tests cover the wire shape:

  • empty_envelope_serializes_as_bare_cert — an envelope with no attestations produces the same top-level keys as a bare cert.
  • bare_cert_parses_as_envelope_with_no_attestations — today's bare cert JSON round-trips into the envelope.

Old decoders silently drop the new attestations field because RefUpdateBody does not set deny_unknown_fields; signers and verifiers can roll out independently.

from_cert rejects any cert that already carries an attestations field — catches double-wrap and peer-supplied "pre-attested" certs that would otherwise leave the envelope in an inconsistent in-memory state. attestations: null and attestations: \"string\" on the wire are rejected as type errors rather than silently treated as empty. attach is intentionally not deduplicating — duplicate-attestation policy belongs to the verifier, not the wrapper.

Tampered payloads, cross-cert replays, mid-cert countersignature changes, unknown types under strict policy, cross-protocol signatures defeated by the domain tag, multibase impostors, malformed DIDs, signature length/base64 errors, and discriminator grammar violations all fail in tests.

End-to-end against the real RefUpdateCert

tests/with_gitlawb_core.rs adds four integration tests that compose the attest envelope with the real gitlawb_core::cert::RefUpdateCert rather than hand-written JSON literals. gitlawb-core is a dev-dep only; the crate's runtime dep graph stays standalone.

  • wraps_real_cert_signs_and_verifies_attestation_then_unwraps_cleanly — build a real RefUpdateCert through gitlawb-core, wrap it, sign a covenant/exec/v1 attestation with a separate party, verify through a strict-policy Registry, round-trip the wire bytes through JSON, then unwrap the attestations and re-verify the bare cert through gitlawb-core::RefUpdateCert::verify_all.
  • cert_hash_is_stable_when_a_maintainer_countersigns — an attestation signed before a maintainer countersigns the underlying RefUpdateCert still verifies after the countersignature lands. The cert hash strips signatures before hashing, by design.
  • cross_cert_replay_is_rejected_against_a_real_cert — negative case across two real RefUpdateCerts.
  • cert_hash_is_identical_across_the_deserialize_boundary — a cert deserialized from JSON hashes identically to one built directly through the core API. Pins that JCS canonicalization is what delivers the additive-on-the-wire promise.

Validation

  • 49 tests pass: 40 unit + 4 cross-crate integration + 4 canonical vector pins + 1 rustdoc quick-start doctest.
  • cargo fmt --all -- --check clean.
  • cargo clippy --workspace --all-targets -- -D warnings clean on the whole workspace.
  • cargo test --workspace green; no existing suite breaks.

Not in this PR

  • Node integration: storage, GraphQL, register flow. Happy to follow up once the protocol shape settles.
  • UI.
  • Trust-score weighting policy for typed attestations.
  • Rollout ordering: old nodes silently drop the field; signers and verifiers roll out independently.
  • wasm / no_std: not targeted; ed25519-dalek and serde_json are std-only here.

Reference implementation

End-to-end demo at open-covenant/covenant@64ec780, agent-os/examples/gitlawb-attest-demo/ — generates two ed25519 keys, builds a cert, attaches a covenant/exec/v1 attestation, prints the wire envelope, verifies it through gitlawb_attest::Registry.

Adds an optional `attestations` field on ref-update certs plus a small
crate to sign and verify them. An envelope with no attestations
serializes to identical bytes as a bare cert, so nothing on the wire
changes for nodes that don't opt in.

Each attestation carries a `type` discriminator (`covenant/exec/v1`,
`slsa/v1.0`, `sigstore/dsse/v1`, ...), an opaque payload, a `cert_hash`
that binds it to one specific cert, a `did:key` signer, and a base64url
ed25519 signature over a domain-separated JCS-encoded input.

## Wire shape

  cert_hash = SHA-256(JCS(cert_body \ {signatures, attestations}))
              encoded as lowercase hex.

  signing_input = b"gitlawb-attest-sig/v1\n"
                  || JCS({type, payload, cert_hash})

  sig = base64url-no-pad(Ed25519(signing_input))

The cert hash is reproducible across implementations because JCS pins
key order, number formatting, and string escaping (RFC 8785). Lowercase
hex is required on the wire: the JCS signing input includes `cert_hash`
verbatim, so accepting mixed case would force every signer and verifier
to normalize before computing the signing bytes — an easy spec rule to
miss across language ports.

The domain tag lives in the byte stream, not in the canonical JSON, so
an attestation cannot be confused with a same-shape JCS document signed
for a different protocol that uses the same Ed25519 key.

Signers are `did:key` over Ed25519 with the multibase base58btc (`z`)
prefix. Non-base58btc encodings of the same public key are rejected on
verify so allowlists and signer-pinning policies stay consistent across
peers.

## Verification

`Registry` maps type to verifier. `Policy::AcceptKnown` (default) lets
unknown types pass without trust so adoption stays incremental.
`Policy::RequireAll` enforces a per-repo allowlist but stays lenient on
unknown types in the batch — anyone can attach an attestation, so
short-circuiting on an unrelated `attacker/spam/v1` would be a DoS
vector. `Policy::RejectUnknown` is strict.

`AttestedRefUpdateCert::verify_attestations(&Registry)` is the one-call
convenience over `registry.verify_all(&env.attestations, env.cert_hash()?)`.

## Canonical test vectors

Ed25519, JCS, and SHA-256 are all deterministic, so a fixed seed plus a
fixed cert body produce exactly the same `cert_hash`, `signer` DID, and
`sig` across implementations. `tests/canonical_vectors.rs` pins these
values so a port to Go, JS, Python, or any other language has a concrete
oracle to check against without reading Rust. If a vector drifts, that
is a wire-protocol break.

## Tests

42 pass: 33 unit + 4 cross-crate integration against the real
`gitlawb_core::cert::RefUpdateCert` + 4 canonical vector pins + 1
rustdoc quick-start doctest.

`cargo fmt --all -- --check` clean.
`cargo clippy --workspace --all-targets -- -D warnings` clean.
`cargo test --workspace` green; the new crate breaks no existing suite.

## Not in this PR

  - Node integration: storage, GraphQL, register flow.
  - UI surfacing.
  - Trust-score weighting policy for typed attestations.
  - Rollout ordering: old nodes silently drop the new `attestations`
    field (`RefUpdateBody` does not set `deny_unknown_fields`); signers
    and verifiers roll out independently.
  - wasm / no_std: not targeted; ed25519-dalek and serde_json are
    std-only.

## Reference implementation

End-to-end demo at open-covenant/covenant@64ec780,
`agent-os/examples/gitlawb-attest-demo/` (`covenant/exec/v1`).

Closes Gitlawb#7.
@achillewasque achillewasque marked this pull request as ready for review May 30, 2026 12:46
@kevincodex1
Copy link
Copy Markdown
Contributor

nice thank you so much for this.

@achillewasque
Copy link
Copy Markdown
Contributor Author

Hey @kevincodex1, thanks.

The crate is deliberately just the protocol so the node-side can land whenever you're good with the shape. An SLSA or Sigstore reference verifier would be a natural next crate if there's appetite.

The canonical vectors in tests/canonical_vectors.rs are deterministic across Ed25519 + JCS + SHA-256, so a Go or JS port has a fixed oracle to match against.

Copy link
Copy Markdown
Contributor

@kevincodex1 kevincodex1 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. All good with this. thank you for working. we can always enhance this in the next versions.

@kevincodex1 kevincodex1 merged commit 924bccd into Gitlawb:main May 31, 2026
1 check passed
@achillewasque
Copy link
Copy Markdown
Contributor Author

Hey @kevincodex1, thanks for the fast merge! I've sent you a dm on Telegram (@mizuki0x), please check when you've got a minute. Wanted to bounce something off you that goes beyond this PR.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants