feat: portable authority envelope schema and standalone verifier (#5055) - #5165
Merged
Conversation
The envelope records who acted, under which delegated authority, and which policy decision allowed it, in a form that carries its own recomputation inputs. Canonical form is RFC 8785 JCS; every hash it carries is derived from material the envelope itself records.
The verifier re-implements RFC 8785 JCS, the detached EdDSA JWS and every hash preimage the envelope carries, so an auditor checks an envelope with cryptography and click alone. Each recorded hash is recomputed rather than trusted, and per-section digests let a failure name the section that moved instead of only reporting a broken signature. An envelope with no coverage section is refused rather than read as complete.
The limits matter more than the guarantees for a reader deciding whether to rely on one: trust-on-first-use keys, no revocation state, and whatever the envelope's own coverage section declines to cover.
Contributor
|
VERDICT: approve Self-describing authority records — principal, grant chain, policy decisions, evidence hashes, coverage statement — verified offline by a standalone wheel with What the PR doesAdds two slices (E1–E2) of the portable authority envelope:
No VerificationRan Nits (non-blocking)
bernstein v3.19.0 - unattended review run Signed review receipt - verify with
|
9 tasks
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.
What
Adds the first two slices of the portable authority envelope: a versioned schema with a canonical serialization and committed golden vectors (E1), and a standalone verifier that validates them without the
bernsteinpackage (E2).In scope
schemas/authority-envelope-v1.json— sectionsprincipal,grants,decisions,evidence,coverage,section_digests,signature, with the hash preimages written out so an independent implementer can reproduce them.tests/fixtures/authority-envelope-vectors/— one deliberately partial envelope, one tampered copy, and a deterministic builder.verify_cli/bernstein_verify_envelope/— the verifier, its CLI, and its wheel metadata.docs/interop/authority-envelope.md— what an envelope proves and, at greater length, what it does not.Explicitly not in scope
src/bernstein/changes, and no existing decision record is rewritten to emit an envelope. That is E3.--jwk/--public-key). An envelope verified against the key it carries is reported as trust-on-first-use, and the documentation says so plainly. That is E5.Decision this PR made
The issue left the canonical form open (
cbor2/JCS). JCS (RFC 8785), no CBOR. Reasons: the envelope's sections are JSON-native and are read by humans as often as by verifiers; the repository's existing authority machinery already signs JCS bytes with a detached EdDSA JWS (core/interop/a2a_card.py,core/security/agent_card_signer.py), so the shape is one an existing reader already knows; and dropping CBOR keeps the auditor wheel at two dependencies (cryptography,click) instead of three. The audit-receipt verifier needscbor2because COSE_Sign1 is a CBOR format — nothing here is.Why
Everything the repository records about authority is shaped for its own consumption.
GovernanceDecision(src/bernstein/core/security/governance.py:217) is anchored to a run spine and stored underlineage_root/<run_id>/, so it cannot exist outside a run we scheduled. The run receipt (src/bernstein/core/replay/run_receipt.py:216) has the right shape — self-describing, verifiable offline — but carries no principal, no grant chain, and no policy decision reference. The compliance pack (verify_cli/bernstein_verify/verify.py:455) reads its signing keys out of the same zip, so the bundle certifies itself.The result an operator hits: the evidence only means something on the install that produced it. Handing it to a gateway, another agent runtime, a policy engine, or an auditor's own tooling requires re-implementing internals first.
The schema is also the thing the neighbouring work needs in order to agree on a target, which is why it lands before any producer.
How
The envelope is one JSON object.
section_digestsandsignatureare what make it checkable:alg=EdDSA) whose signing input isBASE64URL(JCS(header)) || "." || BASE64URL(JCS(body)), where the body is every top-level member exceptsignature— the same construction_sign_card_bodyalready uses for capability cards.section_digestsrecords a sha256 over the canonical bytes of each section. Without it a mutation anywhere reports only "the signature failed"; with it the verifier names the section that moved.Every other hash exists so the verifier can re-derive rather than trust, in the idiom
_access_inputs_hash(src/bernstein/core/security/governance.py:383) already establishes for role decisions:principal.id_bindingrecomputes over the principal id and its JWK.grants[].grant_hashchains to the parent's, so rewriting an ancestor invalidates every descendant. Attenuation is checked structurally: subset scope, no later expiry, issuer equal to the previous subject, chain terminating at the principal.decisions[].inputs_hashcovers the decision's own policy inputs and the cited grant's hash. Anallowfor an action outside that grant's scope is rejected, as is a decision timestamped after the grant expired.coverageis re-derived: the verifier computes which decisions carry evidence and which do not, and rejects a coverage section that does not name every gap. An envelope with no coverage section is refused outright rather than read as complete.The verifier re-implements JCS locally instead of importing
canonicalize_jcs. Sharing the code would prove the two sides agree with themselves; the committed golden vector proves an independent reader agrees with the producer.Tests
tests/unit/test_authority_envelope_verifier.py. Every one of these failed on the unmodified tree — the first run reportedImportError: No module named bernstein_verify_envelope— and each was written before the code it pins.test_golden_vector_verifies_in_a_subprocess_without_bernstein— load-bearing. Runs the verifier in a real subprocess where a meta-path finder makesimport bernsteinraise, and asserts every section passes. If the verifier ever grows abernstein.*import, this dies withImportError.test_the_independence_probe_actually_blocks_bernstein— proves the blocker in test 1 is real, so test 1 cannot pass merely because nothing tried the import.test_verifier_package_imports_neither_bernstein_nor_the_network— nobernstein.,httpx,requests,urllib,socketoraiohttpon any import line.test_one_byte_mutation_in_decisions_names_the_decisions_section— load-bearing. One flipped verdict fails, and the report namessection:decisions, not just the signature.test_one_byte_mutation_in_grants_names_the_grants_section— the section named is the one that changed, not a hard-coded first section.test_committed_tampered_vector_is_rejected— the committed negative vector stays rejected.test_envelope_without_a_coverage_section_is_refused— silence about scope is a refusal.test_coverage_that_hides_an_uncovered_decision_is_refused— a decision with no evidence must be named incoverage.uncovered.test_coverage_names_the_gap_on_the_passing_vector— the passing vector is partial and reports its own gap.test_grant_chain_that_widens_scope_is_rejected— a child link may only narrow.test_allow_verdict_outside_the_referenced_grant_scope_is_rejected— anallowmust follow from the grant it cites, not merely be signed.test_decision_inputs_hash_is_recomputed_not_trusted— editing recorded inputs breaks the recomputed hash.test_decision_taken_after_its_grant_expired_is_rejected— decisions fall inside the cited link's window.test_evidence_referencing_an_unknown_decision_is_rejected— evidence attaches to a decision the envelope carries.test_principal_id_rebound_to_another_key_is_rejected— the identifier is bound to its key material.test_schema_accepts_the_golden_vector— the vector validates against the committed schema.test_schema_requires_the_coverage_section— coverage is required by the schema, not only by the verifier.Local runs (17 passed):
--affected origin/mainselects several hundred files here, so per the contributor guidance the run above covers the modules touched plus their readers instead: nothing undersrc/changes, the new verifier package is imported only by the new test file, andmkdocs.ymlis read only bytests/unit/test_tui_render_freshness.py, which was also run. That file has one failure —test_the_committed_render_matches_what_the_dashboard_draws— which reproduces unchanged onorigin/mainin this environment and is unrelated to this branch. CI runs the full suite.Checklist
uv run ruff check src/passes — unchanged by this PR (no files undersrc/are touched); the twoUP042findings it reports here are pre-existing onorigin/mainwith this ruff version.ruff checkandruff format --checkare clean on every file this PR adds.uv run pyright src/passes — unchanged;[tool.pyright] include = ["src"], andverify_cli/is outside that scope exactly as the existing standalone verifiers are.uv run python scripts/run_tests.py -x— see the scoped runs above.Documentation duty
docs/interop/authority-envelope.md, linked from the MkDocs nav).docs/operations/<area>.mdupdated — N/A (no operator surface changes; there is no producer in this slice).docs/api/schema regenerated if a public surface changed — N/A (no public API change).uv run bernstein agents-md sync— N/A (no module undersrc/bernstein/added or changed).Part of #5055
Remaining
GovernanceDecisionand run-receipt fields.--jwk/--public-key) and a test that an envelope re-signed with a different key is rejected when pinned and reported as trust-on-first-use when not. The verifier already reports trust-on-first-use; it cannot yet be given a pin.