feat(gitlawb-attest): External Attestation v1 for ref-update certs#7
Closed
achillewasque wants to merge 1 commit into
Closed
feat(gitlawb-attest): External Attestation v1 for ref-update certs#7achillewasque wants to merge 1 commit into
achillewasque wants to merge 1 commit into
Conversation
Contributor
|
hello bro @achillewasque are you gonna continue working on this? |
81e4141 to
2a3c3f6
Compare
achillewasque
added a commit
to achillewasque/node
that referenced
this pull request
May 30, 2026
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 JCS-encoded `{type, payload, cert_hash}`.
`Registry` maps type to verifier. `Policy::AcceptKnown` (default) lets
unknown types pass without trust so adoption stays incremental.
`RequireAll` enforces a per-repo allowlist; `RejectUnknown` is strict.
Hashing and signing inputs use JCS (RFC 8785), so the cert hash and
attestation signatures are reproducible across implementations
regardless of struct field order or JSON library.
Crate layout:
crates/gitlawb-attest/
src/lib.rs — module roots, public re-exports
src/attestation.rs — Attestation, AttestationPayload, sign + verify_signature
src/cert.rs — AttestedRefUpdateCert envelope, cert_hash helper
src/verifier.rs — AttestationVerifier trait, Registry, Policy
src/error.rs — typed AttestError
tests/with_gitlawb_core.rs — end-to-end against the real RefUpdateCert
Test coverage (18 tests, all green):
- empty envelope ≡ bare cert byte-for-byte
- bare cert parses as envelope with no attestations
- cert hash stable across countersignatures, changes when body changes
- sign + verify roundtrip
- tampered payload fails signature check
- cross-cert replay fails (binding to one cert is enforced)
- JSON round-trip preserves signature
- payload type mismatch errors on extract
- empty / whitespace discriminator rejected at sign time
- registered type verified through Registry
- AcceptKnown lets unknown types pass with fully_verified=false
- RejectUnknown blocks unregistered types
- RequireAll enforces presence of named types
- payload verifier failure rejects (signature still good)
- cross-crate integration: wrap a real `RefUpdateCert` built with
`gitlawb-core::cert::RefUpdateCert::new`, sign a `covenant/exec/v1`
attestation with a separate party, verify through a strict
Registry, JSON round-trip, then unwrap and re-verify the bare cert
through gitlawb-core
- cross-crate: cert hash stable when a maintainer countersigns the
underlying RefUpdateCert after the attestation is signed
- cross-crate: cross-cert replay rejected against two real certs
`cargo fmt --all -- --check` clean.
`cargo clippy --workspace --all-targets -- -D warnings` clean.
`cargo test --workspace` green (188 workspace tests + 18 gitlawb-attest).
Not in this PR (intentional):
- Node integration: storage, GraphQL, register flow.
- UI surfacing.
- Trust-score weighting policy for typed attestations.
Reference implementation:
open-covenant/covenant on feat/gitlawb-bridge:
- agent-os/crates/covenant-gitlawb/ — `covenant/exec/v1` provider
- agent-os/examples/gitlawb-attest-demo/ — end-to-end runnable demo
Closes Gitlawb#7.
achillewasque
added a commit
to achillewasque/node
that referenced
this pull request
May 30, 2026
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.
## Crate layout
crates/gitlawb-attest/
src/lib.rs module roots, public re-exports, protocol notes
src/attestation.rs Attestation, AttestationPayload, sign + verify
src/cert.rs AttestedRefUpdateCert envelope, cert_hash, CERT_TYPE
src/verifier.rs AttestationVerifier trait, Registry, Policy
src/error.rs Error enum, Result alias
tests/with_gitlawb_core.rs
end-to-end against the real RefUpdateCert
## Tests
37 pass: 33 unit (sign/verify roundtrips; tampered payload, cross-cert
replay, JSON roundtrip, cross-protocol replay rejected by the domain
tag; case-sensitive cert_hash compare; multibase base58btc enforcement
plus four DID parse-error branches; signature length and base64 errors;
discriminator grammar including length/ASCII/segment checks; wire
shape pinned; AttestedRefUpdateCert non-object, missing-type,
wrong-type rejections; cert_hash non-object reject; mutation
invalidates the hash; Registry policy matrix including RequireAll
lenient on unknowns and rejecting present-but-unverified required
types; double-register returns the prior verifier) plus 4 integration
tests against the real `gitlawb_core::cert::RefUpdateCert`.
`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.
2a3c3f6 to
5f37653
Compare
achillewasque
added a commit
to achillewasque/node
that referenced
this pull request
May 30, 2026
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.
5f37653 to
010d986
Compare
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.
010d986 to
50cda01
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an optional
attestationsfield 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
registerhands 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 owncert_hashfield does not shadow the outer binding; the envelope'scert_hashis the only field verification consults.cert_hash—SHA-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.signer—did: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 overb"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.Registrymaps type to verifier.Policy::AcceptKnown(default) lets unknown types pass without trust so adoption stays incremental.Policy::RequireAllenforces a per-repo allowlist but stays lenient on unknown types in the batch — anyone can attach an attestation, so short-circuiting on an unrelatedattacker/spam/v1would be a DoS vector.Policy::RejectUnknownis strict.AttestedRefUpdateCert::verify_attestations(&Registry)is the one-call convenience: it equalsregistry.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,signerDID, andsigacross every conforming implementation.tests/canonical_vectors.rspins those values: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_VERSIONand 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
attestationsfield becauseRefUpdateBodydoes not setdeny_unknown_fields; signers and verifiers can roll out independently.from_certrejects any cert that already carries anattestationsfield — catches double-wrap and peer-supplied "pre-attested" certs that would otherwise leave the envelope in an inconsistent in-memory state.attestations: nullandattestations: "string"on the wire are rejected as type errors rather than silently treated as empty.attachis 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
RefUpdateCerttests/with_gitlawb_core.rsadds four integration tests that compose the attest envelope with the realgitlawb_core::cert::RefUpdateCertrather than hand-written JSON literals.gitlawb-coreis a dev-dep only; the crate's runtime dep graph stays standalone.wraps_real_cert_signs_and_verifies_attestation_then_unwraps_cleanly— build a realRefUpdateCertthroughgitlawb-core, wrap it, sign acovenant/exec/v1attestation with a separate party, verify through a strict-policyRegistry, round-trip the wire bytes through JSON, then unwrap the attestations and re-verify the bare cert throughgitlawb-core::RefUpdateCert::verify_all.cert_hash_is_stable_when_a_maintainer_countersigns— an attestation signed before a maintainer countersigns the underlyingRefUpdateCertstill verifies after the countersignature lands. The cert hash stripssignaturesbefore hashing, by design.cross_cert_replay_is_rejected_against_a_real_cert— negative case across two realRefUpdateCerts.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
cargo fmt --all -- --checkclean.cargo clippy --workspace --all-targets -- -D warningsclean on the whole workspace.cargo test --workspacegreen; no existing suite breaks.Not in this PR
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 acovenant/exec/v1attestation, prints the wire envelope, verifies it throughgitlawb_attest::Registry.