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."""