Conversation
Wiring #2607 makes EventBridge sign the requests it publishes and verify the responses it consumes, so it needs the signing primitives. They lived in `eventrunner/`, which EventBridge has never imported from. This is not tidiness. `Dockerfile-eventbridge` COPYs only `shared/` and `eventbridge/` — never `eventrunner/` — so an `eventbridge -> eventrunner` import would pass every local test and then ImportError inside the container. `shared/` already holds `ce.py` and `keyset.py`, the two modules both services use, so signing belongs beside them. Pure move plus four reference updates. Verified behaviour-neutral by diffing `pytest --collect-only` before and after: the two test sets are identical. 496 passed, 5 skipped; ruff clean under the pinned 0.11.4. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
Four pure functions in `shared/signing.py`, plus their tests. Nothing is wired to a producer or consumer yet — that follows in separate commits, so a review of the policy is separable from a review of the plumbing. * `sign_into(event, seed, kid)` — the one place that decides what "signing is enabled" means. `sign_event` does not mutate, so every producer would otherwise repeat the same three lines. Never raises: a bad seed degrades to publishing unsigned rather than rejecting 100% of traffic, which is safe only because the verifying side refuses unsigned events when enforcement is on. * `verify_with_keyset(event, ks, expect_kid=)` — the composition `tests/test_keyset.py` previously had to hand-wire: read the `kid`, select the key it names, let the signature decide. `expect_kid` pins a class of event to one identity; group lifecycle events need it because the keyset is otherwise flat and any approved runner could forge a `group.completed`. * `verify_request(event, cfg, ks)` — keyset when configured, else the existing single-key `verify_event`, so `ER_REQUIRE_SIGNATURE=true` with only `ER_VERIFY_KEY_PATH` set keeps behaving as it does today. * `response_decision(event, ks, require=, bridge_kid=)` — collapses "is verification configured?", "did it verify?" and "do we enforce?" into one answer, leaving the caller no policy to get wrong. Pure and non-mutating, which is what makes the responses path testable without a Kafka consumer at all. Every rejection returns a distinct reason. A rejection nobody can explain gets diagnosed as "signing is broken" and switched off. Found while testing: `sign()` never validated seed length — only `public_key()` did — so a truncated key file produced a well-formed signature that no verifier could ever match, reported to the operator as success. `sign_into` now checks, where production signing enters. 15 new tests. 511 passed, 5 skipped; ruff clean under the pinned 0.11.4. (The baseline is 496, not the 492 an older handoff recorded; verified by collecting the pristine tree.) Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
`sign_event()` finally has production callers. Until now `ER_REQUIRE_SIGNATURE=true` rejected 100% of traffic — a kill switch, not a feature — because nothing on either side ever signed. Signing (opt-in, off by default): * EventBridge signs requests in `publish_request`, after `new_event` fills `id` and `time` (both signed) and before serialisation. * EventBridge signs group lifecycle events under the same kid. Skipping them would have left the hole worth closing: a forged `group.completed` ends a batch early and fires a "finished" notification for work that never ran. * EventRunner signs **terminal responses only**. `emit()` runs per stdout frame and a signature costs ~150-200 ms, so signing every frame would add minutes to a chatty run. The seed lives on the Emitter, so none of the seven `emit()` call sites changed. Verification: * EventRunner resolves the key by the token's `kid` when a keyset is configured, making it an allowlist of several agents; falls back to the existing single-key path so `ER_REQUIRE_SIGNATURE` + `ER_VERIFY_KEY_PATH` is unchanged. Rejections increment `rejected_unsigned` and keep the existing commit-and-do-not-retry semantics. * EventBridge verifies responses and stores a failure as `phase="error"` rather than dropping it — dropping is indistinguishable from an agent that never answered, while `phase=error` reuses a red transcript card, ntfy priority 5 and `raw_json` audit retention. Group events are pinned to EventBridge's own kid. Two hazards in `kafka_in.py` handled explicitly. `from_kafka_binary` and `insert_response` are not inside a try, so a raise in the verification path would end the consume loop permanently while the pod still reported healthy — hence the guard, which fails closed only where enforcement is on. And the error payload uses `data["text"]` because that is what ntfy renders; anything else shows up on the phone as "(error, see raw)". Rollout is two flags so enforcement is never the first step: a keyset alone verifies and logs while storing events unchanged (audit mode); `EB_REQUIRE_RESPONSE_SIGNATURE=true` is what rewrites them. EventBridge refuses to start with a keyset but no `EB_SIGNING_KID`, since there would be nothing to attribute a group event to. Key material loads once at startup and is deliberately uncaught: a service that believes it is signing but is not fails silently, whereas one that will not start says so in `kubectl logs`. Approved kids are printed, because a rejection caused by a stale ConfigMap is otherwise invisible. Verified with real key files outside the test suite: an EB-signed request verifies at the runner, a runner-signed terminal response verifies at the bridge, an unsigned forgery is refused and then accepted once verification is switched off, and an approved runner cannot forge a group event. 511 passed, 5 skipped — every pre-existing test unchanged; ruff clean. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
…nt's key forms `consume.py`'s verification branch had no tests at all. Until signing had a producer there was no way to reach the accept side, which left the reject side indistinguishable from "always rejects" — the same property a kill switch has. Ten tests, each asserting the three things the stale-request guard asserts, because a rejection that loses the offset is a poison pill that blocks the partition forever: the request did not reach the router, a named counter says why, and the offset still advanced. Covered: an approved agent runs; unsigned is refused; a real signature from an unapproved key is refused (the forgery the demo turns on — being unforgeable was never the point, being unapproved is); a rogue key claiming an approved kid is refused; an unnamed token is refused when the set is ambiguous but accepted when there is exactly one key; the single-key path still works with no keyset; the default path skips verification entirely; a stale request is dropped by the age guard *before* costing a ~150 ms verification; and the consume loop survives a rejection and goes on to process the next record. Writing the back-compat test surfaced a pre-existing bug in `verify_event`: a hex- or base64-encoded **public key** never verified. A 32-byte file is ambiguous between a seed and a public key and the code already tried both readings for the raw case, but for an encoded file it only tried `public_key(load_seed(path))`, which derives the wrong key when the file already holds a public one. Since `ER_VERIFY_KEY_PATH` is documented to accept either, all six combinations now do, pinned by a parametrized test. Widening the accepted *encodings* does not widen the accepted *keys* — a separate test asserts an unrelated key is still refused. 528 passed, 5 skipped; ruff clean. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
`eventbridge/kafka_in.py` had no behavioural test at all — it was only grepped as source text by `test_groups.py`. That was the gap worth closing before adding a verification path to it, because the module decodes and writes to SQLite outside any `try`: anything raising between the poll and the store write ends the `for`, ends the `while`, and the thread is gone while the process stays up and the pod reports healthy. Eleven tests. The two that matter most assert the loop survives a verifier that raises — processing both records rather than dying on the first — and that it fails closed while enforcing but open in audit mode, because a broken check has no opinion to act on when nothing is being enforced. Both were confirmed load-bearing by removing the guard and watching exactly those two fail with the exception escaping. The rest: the default path stores an unsigned event unchanged; an approved agent's response is untouched; a forged response is stored as `phase=error` rather than dropped, with the forged text absent from what a reader sees as the answer; an unapproved key and a tampered payload are both rejected; audit mode reports without rewriting; a bridge-signed group event is accepted while an approved runner forging one is not. Driving the loop needed care worth recording: `run()` checks `stopping` before the `while` and again at the top of each record, so the fake consumer sets the flag when iteration resumes after the last record — stopping any earlier skips the record, any later spins forever on an exhausted iterator. 539 passed, 5 skipped; ruff clean. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
…ature Last step of #2607, deliberately last: adding these before a producer signed would have invalidated canonicalisation twice for no benefit. `groupid` is required by DESIGN_PHASE1.md §21.9.9 and its absence was the sharpest gap — a signature said nothing about batch membership, so a forged `groupid` could move a response into another batch and corrupt its fan-in counts. Tests now pin all three on the wire, including that *adding* a `groupid` to a signed ungrouped event is detected: the absence of an attribute is signed too, or an ungrouped response could be adopted into a batch it was never part of. No compatibility flag. Nothing had ever published a signed event, and both services ship from this repo against one ConfigMap, so no supported configuration has a signing producer meeting a verifying consumer at different versions. The release note that matters: if signing is already enabled, upgrade both together, because every grouped request now carries a signed `groupid` an older verifier omits when it recomputes. `test_canonical_changes_when_any_signed_attribute_changes` now derives its list from `SIGNED_ATTRS` instead of hardcoding it — the hardcoded version silently stopped covering whatever was added next, which is exactly what happened here. Documentation corrected rather than left contradicting the code. Four places claimed the old state: `shared/ce.py` said submitter was unsigned and forgeable, `eventbridge/auth.py` said proving it "needs a producer that actually signs (today nothing does)", `shared/keyset.py` said nothing consulted it, and `agentdocs/README.md` summarised Phase 2 as blocked on nothing signing. Each now states the bounded claim instead: a signature proves *EventBridge asserted this submitter*, not that the submitter is who they say, and on an unsigned event the attribute stays forgeable — so the property only holds where verification is on. Also revised `_scalar_mult`'s side-channel note, which justified non-constant-time scalar multiplication partly on "nothing exposes a remote timing oracle over sign()". With EB_SIGNING_KEY_PATH set, EventBridge signs on the HTTP request path, so that is no longer strictly true; the note now says what still makes it acceptable rather than resting on a premise this change removed. 543 passed, 5 skipped; ruff clean. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
…ng policy Caught by running the demo against the live broker, which is the only reason it was caught: every test until now used terminal events, so all of them passed. `emit()` signs terminal events only — a signature costs ~150-200 ms and `emit()` runs for every stdout frame. But `response_decision` verified all-or-nothing, so a genuine run's streamed frames carry no signature and were all rewritten to `phase="error"`. With enforcement on, every real run would have rendered as a column of red cards with a single valid answer at the end. An event that carries **no** signature and is **not** terminal is now passed through. The security property is unchanged where it matters: * an unsigned **terminal** event is still refused — that is the one the transcript presents as the answer, and refusing it is the entire control; * a non-terminal frame that *presents* a signature is still verified, because the exemption is for absence, not for failure; * group lifecycle events count as terminal. They carry no `final` attribute at all, so testing `final` alone would have classified one as an unsigned intermediate frame and waved it through — exactly the forged `group.completed` the bridge kid exists to catch. Verified on the real broker end to end: a genuine run (one streamed frame plus a signed terminal) renders unchanged while a forged terminal on the same correlation is rejected. Also verified the signed-request path with all four signed attributes travelling as `ce_` headers, and that tampering with `submitter` in flight fails verification. Six new tests, including the realistic shape — a forgery arriving alongside real streaming output, where only it is rewritten. 549 passed, 5 skipped; ruff clean. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
aslom
left a comment
There was a problem hiding this comment.
Reviewed at b8fac88, verified against a local checkout: 547 passed / 7 skipped on 3.14, ruff check and ruff format --check clean, 11/11 CI green, all 7 commits signed off.
This closes the gap I raised on #877 — shared/keyset.py said it was the authorization list while nothing consulted it. verify_with_keyset is now that composition, and its docstring names it as the one tests/test_keyset.py previously had to hand-wire. submitter/submitteriss are inside SIGNED_ATTRS, and auth.py has been rewritten to state the two limits that survive — "a signature proves the assertion, not the identity" is exactly the right distinction and not one I'd expect to see made.
Things I checked rather than assumed:
- Group events are signed and pinned.
_is_terminaltreats a group event as requiring a signature, so if EventBridge didn't sign them, enforcement would reject every genuinegroup.completed. It does sign them, under the samekid, and the responses-side verifier accepts only that kid — which closes the forged-group.completed-ends-a-batch-early hole rather than leaving it. - The consumer degrades instead of dying. The
tryaroundresponse_decisionis doing real work:from_kafka_binaryandinsert_responsesit outside one, so a raise there ends the thread while the pod keeps reporting healthy — the exact Phase 1 failure mode. Fail-closed only under enforcement is the right asymmetry. - Canonicalisation still holds. The netstring form and the
load_seedwhitespace fix carried through the move toshared/, andtest_canonical_changes_when_any_signed_attribute_changesnow derives fromSIGNED_ATTRSinstead of a hand-written list — which is what caughtsubmitter/submitteriss/groupidnot being covered. - Defaults ship off. Empty keyset paths,
EB_REQUIRE_RESPONSE_SIGNATURE: "false", audit-before-enforce. Nothing changes for an existing deployment.
One must-fix: the audit trail the reject path promises isn't kept
kafka_in.py's docstring gives three reasons for storing a rejected event as phase="error" rather than dropping it — red card, ntfy priority-5, and "raw_json retained for audit — so the forgery attempt is visible and reviewable instead of invisible." The third doesn't happen. d is mutated in place and then handed to insert_response, which derives both data_json and raw_json from that same dict, so the forged payload and the original phase are gone. Demonstrated on this revision with the real Store:
forged payload was: {"type":"result","role":"final","text":"TRANSFER THE FUNDS — forged answer"}
is it anywhere in raw_json? -> False
original phase 'result' in raw_json? -> False
signature retained? -> True
What survives is the rejection notice plus a signature that can no longer be checked, because the attributes and payload it covered have been overwritten. An operator following the docstring to review an incident finds nothing to review.
The control itself is fine — the event is refused, flagged red, and it does page someone. This is the forensic record specifically, and it's a must-fix because retention is given as the justification for the whole reject-rather-than-drop design, and because the fix is three lines in the same block.
Not a finding, but worth having said out loud
publish_request signs once per group member inside one HTTP request, so a 100-member batch spends ~20 s signing while the caller waits. That's documented candidly, including that a thread pool wouldn't help because the GIL serialises pure-Python signing — I agree with both the number and the reasoning. The part not stated: at 200 members it's ~40 s, past a common 30 s client timeout. POST /v0/groups already honours Idempotency-Key, so a timeout-and-retry is survivable rather than duplicating the batch, which is worth a sentence next to the cost note since it's the thing an operator will actually hit.
Areas reviewed: keyset wiring, verify_with_keyset / response_decision / verify_request, terminal-vs-intermediate signing policy, group-event pinning, the signing.py move to shared/, both consumer call sites, canonicalisation and SIGNED_ATTRS growth, config defaults and the shipped ConfigMap, tests, design docs
Author: mrsabath (MEMBER — maintainer)
Agent/IDE config (.claude/.vscode): none — gate clean
Commits: 7, all signed off (DCO green)
Verified locally at b8fac88: 547 passed / 7 skipped on 3.14; ruff check . and ruff format --check . clean
CI: 11/11 green
Requesting changes only for the raw_json retention. Everything else here I'd merge as-is — and the honesty about what terminal-only signing does not prove ("proves who finished a run, not what the run said along the way") is the right way to ship a partial control.
@aslom's must-fix on #879, and he was exactly right: the audit trail the reject path promises was not kept. `Store.insert_response` derives BOTH `data_json` and `raw_json` from the single dict it is handed, so mutating the envelope in place overwrote the evidence with the notice about it. The refused payload, the phase it claimed, and the attributes the signature covered were all gone — leaving a signature that could no longer be checked against anything, and nothing for an operator following the docstring to review. Reproduced before fixing, matching his output exactly: forged payload was: {"text":"TRANSFER THE FUNDS — forged answer"} is it anywhere in raw_json? -> False original phase 'result' in raw_json? -> False signature retained? -> True The envelope is now copied rather than mutated, and the original is preserved under `data["rejected"]` — attrs, payload, claimed source and signature. What a reader sees is unchanged: `phase="error"`, the rejection notice in `data["text"]`, and the forged text never presented as the answer. Two tests, the second being the sharper one: a retained signature is only worth keeping if the attributes it covered are kept with it, so a validly-signed event rejected for naming an unapproved kid is rebuilt from the stored record and verified offline against the key that signed it. That is what makes an incident reviewable rather than merely logged. Two existing assertions were checking `"Ship the goods" not in json.dumps(data)`, which asserted the bug. They now assert the real property — absent from `data["text"]`, so not presented as the answer — while retention is asserted separately. Also added the number an operator actually hits, per his second note: ~200 batch members is ~40 s of signing, past a common 30 s client timeout. `POST /v0/groups` honours `Idempotency-Key` (verified: `HTTP_IDEMPOTENCY_KEY` in handlers.py, and a key hit returns the original group), so the retry is survivable — but only if the caller sends the header, which is the part worth saying. 551 passed, 5 skipped; `ruff check` and `ruff format --check` clean. Verified against the live broker: a forgery published with raw Kafka access renders as a rejection while its payload, claimed source and claimed phase survive in `raw_json`. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Mariusz Sabath <mrsabath@gmail.com>
|
Fixed in The must-fix. Your framing pushed the test further than I'd have taken it. "Reviewable" only means something if the retained signature can still be checked, so the second test rebuilds a rejected event from the stored record and verifies it offline against the key that signed it: replayed = ce.CloudEvent(attrs=dict(kept["attrs"]), data=kept["data"])
ok, why = S.verify_signature(replayed, S.public_key(SEED_ROGUE))
assert ok, "the retained record must still verify against the key that signed it"
assert S.token_kid(kept["signature"]) == "runner-99"Two existing assertions were checking On the batch cost. Added, with your number — ~200 members is ~40 s, past a common 30 s client timeout. I verified the idempotency claim rather than taking it on trust: 551 passed / 5 skipped here (5 cluster-gated; your 7 skips on 3.14 will be the two that need a local Kafka). Dzięki za wyłapanie tego — the promise was in the docstring I wrote, so it's the kind of gap I was least likely to spot myself. (Thanks for catching this.) |
aslom
left a comment
There was a problem hiding this comment.
Re-review at 3264e8dd. Nothing blocking — approving.
The raw_json finding is fixed, and fixed better than I suggested. I asked for the original payload to be stashed; 3264e8dd copies the envelope (dict(d, …)) instead of mutating it at all, and retains attrs, data, phase, final, source and signature from the CloudEvent rather than from the flattened dict. That last choice is what makes the record verifiable rather than merely present — the full signed attribute set is kept, so the signature can be checked against what it actually covered.
Verified independently, not just by reading the commit:
forged text retained : True
claimed phase retained : result
claimed source retained : rossoctl://attacker
claimed kid : runner-99
re-verifies OFFLINE : True
row presented as : error
So an operator can now prove which key signed a forgery, from the stored row alone, while the transcript still shows only the red card. That is what I meant by reviewable and I did not expect the offline re-verification to be covered so directly.
The accompanying test does the same thing — rebuilds the event from data["rejected"]["attrs"] and verifies it against the rogue key. That is the right assertion: a test checking only that the forged text is present would pass on a record too incomplete to verify.
The docstring is also correctly rewritten rather than patched. The false "raw_json retained for audit" line is gone, replaced by an explanation of why in-place rewriting would have destroyed the evidence — which is more useful to the next reader than the claim ever was.
Also picked up the non-blocking note from my last review: kafka_out.py now records that ~200 members is ~40 s, past a common 30 s client timeout, and that Idempotency-Key makes the retry return the original batch rather than launching a second one — with the caveat that it only helps if the caller sends the header. That caveat is the part that matters and I had not spelled it out.
Verified at 3264e8dd: 549 passed / 7 skipped on 3.14 (up from 547 — the two new forensic tests), ruff check . and ruff format --check . clean, 11/11 CI green, all 8 commits signed off, no .claude/.vscode changes.
Nothing further from me. My earlier thread on kafka_in.py shows as outdated because the lines moved under the fix; it can be resolved.
For the record, the three eventing PRs each closed the previous one's gap: #877's keyset that nothing consulted became #879's verify_with_keyset; #878's unsigned submitter became an entry in SIGNED_ATTRS; and the audit record that the docstring promised is now one an incident can actually be reconstructed from. The remaining limit is stated plainly in emit.py — terminal-only signing proves who finished a run, not what it said along the way — and leaving that written down rather than implied is the right way to ship a partial control.
Goal 2 of the Event Identity exercise: only approved agents may execute requests and publish responses. Goal 1 (user identity) shipped in #878.
Closes rossoctl/rossoctl#2607. Subsumes rossoctl/rossoctl#2590 (response signing), which can be closed.
The problem
sign_event()had no production caller.ER_REQUIRE_SIGNATURE=truerejected 100% of traffic — a kill switch, not a feature — because nothing on either side ever signed. Thekidand approved-key-set groundwork from #877 was in place and tested, but unreachable.Concretely: anything with write access to the
responsestopic got its output stored, rendered in the HTML transcript, and pushed to the operator's phone as a legitimate agent answer.What this does
Signing, all opt-in and off by default:
publish_request, afternew_eventfillsid/time(both signed) and before serialisation.kid. Skipping them would have left the hole worth closing: a forgedgroup.completedends a batch early and fires a "finished" notification for work that never ran.emit()call sites changed.Verification:
kidwhen a keyset is configured, making it an allowlist of several agents rather than a single-key check. Falls back to the existing single-key path, soER_REQUIRE_SIGNATURE+ER_VERIFY_KEY_PATHis unchanged. Rejections incrementrejected_unsignedand keep the commit-and-do-not-retry semantics — a bad signature is still bad on redelivery.phase="error"rather than dropping it. A drop is indistinguishable from an agent that never answered;phase=errorreuses a red transcript card, ntfy priority 5, andraw_jsonaudit retention, so the forgery attempt is evidence rather than absence.kid. A flat keyset would let any approved runner forge one.submitter,submitterissandgroupidjoinedSIGNED_ATTRSlast, deliberately: covering them before a producer signed would have invalidated canonicalisation twice.DESIGN_PHASE1.md§21.9.9 requiresgroupid— without it a signature said nothing about batch membership, so a forgedgroupidcould move a response into another batch and corrupt its fan-in counts.Things worth a reviewer's attention
The
shared/move was mandatory, not cosmetic.Dockerfile-eventbridgeCOPYs onlyshared/andeventbridge/— nevereventrunner/. Aneventbridge → eventrunnerimport would have passed every local test and thenImportErrord inside the container. Verified behaviour-neutral by diffingpytest --collect-onlybefore and after.Both hazards in
kafka_in.pywere real.from_kafka_binaryandinsert_responseare not inside atry, so anything raising in the verification path would end the consume loop for the life of the pod — silently, with the process still healthy. The guard fails closed only where enforcement is on: if the verifier itself is broken, an unverifiable event is not evidence of anything. Confirmed load-bearing by removing it and watching exactly the two intended tests fail with the exception escaping.Three bugs found along the way, each with a test:
sign()never validated seed length (onlypublic_key()did), so a truncated key file produced a well-formed signature no verifier could match — reported to the operator as success.verify_eventrejected hex/base64-encoded public keys: for encoded files it only triedpublic_key(load_seed(path)), which derives the wrong key when the file already holds a public one. All six documented forms now work; a separate test confirms accepting more encodings did not accept more keys.emit()signs terminal events only, butresponse_decisionverified all-or-nothing — so under enforcement every streamed frame of every genuine run would have been rewritten tophase="error": a wall of red cards with one valid answer at the end. Found by running against a live broker, not by any unit test, because every test until then used terminal events. Unsigned non-terminal frames now pass; unsigned terminals, bad signatures, and unsigned group events are all still refused.Two costs and limits, stated rather than buried:
submit_membersloopspublish_requestper member, synchronously, inside one HTTP request. At ~199 ms per signature (measured; the docs said ~150) a 100-agent batch spends ~20 s signing while the caller waits. Accepted — a batch launch is an operator action, not a hot path — and a thread pool would not help, since the pure-Python Ed25519 is GIL-bound. If signing becomes mandatory at scale the fix is thecryptographydependency conversation, not concurrency.final=falseframes still render. Closing that needs cheap signatures or a signed digest chain across frames.Documentation was corrected rather than left contradicting the code:
shared/ce.pysaidsubmitterwas unsigned and forgeable,eventbridge/auth.pysaid proving it needs "a producer that actually signs (today nothing does)",shared/keyset.pysaid nothing consulted it, andagentdocs/README.mdsummarised Phase 2 as blocked on nothing signing. Each now states the bounded claim: a signature proves EventBridge asserted this submitter, not that the submitter is who they say — and on an unsigned event the attribute stays forgeable, so the property only holds where verification is enabled._scalar_mult's side-channel note also no longer rests on "nothing exposes a remote timing oracle oversign()", which this change made untrue.Rollout
Two flags, so enforcement is never the first step:
falsetruephase="error"Enforcement mutates persisted rows and pages a phone, so there is a step where the reject rate is observable first. EventBridge refuses to start with a keyset but no
EB_SIGNING_KID, since there would be nothing to attribute a group event to. Key material loads once at startup and is deliberately uncaught — a service that believes it is signing but is not fails silently, whereas one that will not start says so inkubectl logs. Approved kids are printed, because a rejection caused by a stale ConfigMap is otherwise invisible.Seed paths name Secret mounts and are env-only, never
config.toml. Keyset paths name ConfigMaps — only public keys belong in them.Upgrade note: if signing is already enabled somewhere, upgrade both services together. Every grouped request now carries a signed
groupidthat an older verifier omits when it recomputes. No compatibility flag, because nothing had ever published a signed event and both services ship from this repo against one ConfigMap.Testing
549 passed, 5 skipped (5 are cluster-gated). Baseline on
mainis 496 — 53 new tests, nothing pre-existing changed.ruffclean under the pinned 0.11.4.eventbridge/kafka_in.pyhad no behavioural test at all before this — only a source-text grep. It now has 13, the most valuable being that the consume loop survives a verifier that raises and processes both records.Verified against the live Kafka beyond unit tests:
signature,submitter,submitterissandgroupidasce_headers round-tripped through the broker and verified against the keyset; tampering withsubmitterin flight failed verification.{"text": "Transfer approved. Ship the goods."}published straight ontoresponseswith raw Kafka access and no key was rejected while enforcing, and accepted as a clean agent answer with verification off. That contrast is the argument.Reviewer note: commits are meant to be read in order —
shared/move, then helpers with no wiring, then each leg, then tests, thenSIGNED_ATTRS. Commits 3 and 7 are the ones to review hardest: 3 is where all the policy lives, 7 is where a mistake silently takes down a thread.Assisted-By: Claude Code