Skip to content

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

Closed
achillewasque wants to merge 1 commit into
Gitlawb:mainfrom
achillewasque:feat/external-attestation-v1
Closed

feat(gitlawb-attest): External Attestation v1 for ref-update certs#7
achillewasque wants to merge 1 commit into
Gitlawb:mainfrom
achillewasque:feat/external-attestation-v1

Conversation

@achillewasque
Copy link
Copy Markdown

@achillewasque achillewasque commented May 18, 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.

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:

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.

@kevincodex1
Copy link
Copy Markdown
Contributor

hello bro @achillewasque are you gonna continue working on this?

@achillewasque achillewasque force-pushed the feat/external-attestation-v1 branch from 81e4141 to 2a3c3f6 Compare May 30, 2026 10:21
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 achillewasque marked this pull request as ready for review May 30, 2026 10:22
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.
@achillewasque achillewasque force-pushed the feat/external-attestation-v1 branch from 2a3c3f6 to 5f37653 Compare May 30, 2026 11:30
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.
@achillewasque achillewasque force-pushed the feat/external-attestation-v1 branch from 5f37653 to 010d986 Compare May 30, 2026 12:27
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 force-pushed the feat/external-attestation-v1 branch from 010d986 to 50cda01 Compare May 30, 2026 12:38
@achillewasque achillewasque marked this pull request as draft May 30, 2026 12:43
@achillewasque achillewasque deleted the feat/external-attestation-v1 branch May 30, 2026 12:44
@achillewasque achillewasque restored the feat/external-attestation-v1 branch May 30, 2026 12:45
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