From 2fc5f1bdc53f7f160f9a964f6785ee0911a23c67 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 29 Apr 2026 17:49:31 -0400 Subject: [PATCH] feat(canary): auto-deregister fingerprint slug after first valid beacon Once a fingerprint canary's HTTP beacon passes all 4 validation layers and the trigger row lands, the token is immediately set to state=revoked and canary..revoked is published on the bus. The slug lookup is tightened to only return planted tokens, so subsequent requests to the same URL silently return the transparent GIF without persisting anything (stealth posture preserved). Plain http/dns canaries with no fingerprint_nonce are not affected. Changes: - sqlmodel_repo/canary.py: add state == "planted" filter to get_canary_token_by_slug so revoked slugs resolve to None - worker.py: after record_canary_trigger, if parsed_fp survived all layers and token has a fingerprint_nonce, call update_canary_token_state("revoked") + publish CANARY_REVOKED; errors are best-effort (trigger row already landed) - test_worker_http.py: assert state=revoked in test_fp_valid_nonce_persists; new test_fp_deregisters_slug_after_valid_hit (second hit records nothing); new test_plain_http_canary_not_deregistered (env_file stays planted) --- decnet/canary/worker.py | 16 +++++++++ decnet/web/db/sqlmodel_repo/canary.py | 3 +- tests/canary/test_worker_http.py | 48 +++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/decnet/canary/worker.py b/decnet/canary/worker.py index 1152b1da..9ce390ca 100644 --- a/decnet/canary/worker.py +++ b/decnet/canary/worker.py @@ -339,6 +339,22 @@ async def _record_hit( except Exception as e: # noqa: BLE001 — best effort log.warning("canary.triggered publish failed slug=%s err=%s", slug, e) + # Auto-deregister fingerprint canaries after the first valid fingerprint + # is collected. Slug goes dark; the stealth posture means the attacker + # sees the same 200 + GIF on the next hit — nothing reveals the revocation. + # Guard: only fingerprint tokens have a non-NULL fingerprint_nonce; plain + # http/dns canaries are NOT auto-revoked. + if parsed_fp is not None and token.get("fingerprint_nonce") is not None: + try: + await repo.update_canary_token_state(token["uuid"], "revoked") + await bus.publish( + topics.canary(token["uuid"], topics.CANARY_REVOKED), + {"token_id": token["uuid"], "trigger_id": trigger_id, + "reason": "fingerprint_collected"}, + ) + except Exception as e: # noqa: BLE001 — trigger row already landed; best effort + log.warning("canary.deregister failed token=%s err=%s", token["uuid"], e) + # ---------------------------- DNS surface -------------------------------- diff --git a/decnet/web/db/sqlmodel_repo/canary.py b/decnet/web/db/sqlmodel_repo/canary.py index 6682f5bf..fb351e78 100644 --- a/decnet/web/db/sqlmodel_repo/canary.py +++ b/decnet/web/db/sqlmodel_repo/canary.py @@ -110,7 +110,8 @@ class CanaryMixin: async with self._session() as session: result = await session.execute( select(CanaryToken).where( - CanaryToken.callback_token == callback_token + CanaryToken.callback_token == callback_token, + CanaryToken.state == "planted", ) ) row = result.scalar_one_or_none() diff --git a/tests/canary/test_worker_http.py b/tests/canary/test_worker_http.py index 1a5e20d5..b17c794d 100644 --- a/tests/canary/test_worker_http.py +++ b/tests/canary/test_worker_http.py @@ -256,6 +256,9 @@ async def test_fp_valid_nonce_persists(repo: SQLiteRepository, bus: FakeBus) -> headers = json.loads(triggers[0]["raw_headers"]) assert "_fp" in headers assert "_fp_invalid_nonce" not in headers + # Valid fingerprint → token auto-revoked. + tok = await repo.get_canary_token("tok-n1") + assert tok["state"] == "revoked" @pytest.mark.asyncio @@ -367,6 +370,51 @@ async def test_fp_rate_limited_on_excess_submissions( _worker._fp_rate_buckets.clear() +@pytest.mark.asyncio +async def test_fp_deregisters_slug_after_valid_hit( + repo: SQLiteRepository, bus: FakeBus, +) -> None: + """After a valid fingerprint beacon the slug goes dark — second hit records nothing.""" + import json + + blob, _ = _make_fp_blob("slug-DEREG1") + await repo.create_canary_token({ + "uuid": "tok-dereg1", "kind": "http", "decky_name": "web1", + "generator": "fingerprint_html", "placement_path": "/x", + "callback_token": "slug-DEREG1", "secret_seed": "s", "created_by": "u1", + "fingerprint_nonce": "deadbeef01234567", + }) + app = _build_app(repo, bus) + with TestClient(app) as client: + # First hit — valid FP, deregisters slug. + client.get(f"/c/slug-DEREG1?d={blob}&k=deadbeef01234567") + # Second hit — slug is revoked, stealth 200 but nothing persisted. + client.get(f"/c/slug-DEREG1?d={blob}&k=deadbeef01234567") + triggers = await repo.list_canary_triggers("tok-dereg1") + assert len(triggers) == 1 # only the first hit landed + + +@pytest.mark.asyncio +async def test_plain_http_canary_not_deregistered( + repo: SQLiteRepository, bus: FakeBus, +) -> None: + """Plain HTTP canaries (no fingerprint_nonce) are NOT auto-revoked on a hit.""" + await repo.create_canary_token({ + "uuid": "tok-plain1", "kind": "http", "decky_name": "web1", + "generator": "env_file", "placement_path": "/x", + "callback_token": "slug-PLAIN1", "secret_seed": "s", "created_by": "u1", + # fingerprint_nonce intentionally absent / NULL + }) + app = _build_app(repo, bus) + with TestClient(app) as client: + client.get("/c/slug-PLAIN1") + client.get("/c/slug-PLAIN1") + triggers = await repo.list_canary_triggers("tok-plain1") + assert len(triggers) == 2 # both hits recorded — no deregistration + tok = await repo.get_canary_token("tok-plain1") + assert tok["state"] == "planted" + + @pytest.mark.asyncio async def test_no_decnet_strings_in_response(repo: SQLiteRepository, bus: FakeBus) -> None: """Stealth posture: nothing in the HTTP surface mentions DECNET."""