mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-14 12:28:04 +02:00
93 lines
3.2 KiB
Python
93 lines
3.2 KiB
Python
"""Nullifiers — one-time-use markers per (key, operator) pair.
|
|
|
|
Source of truth: ``infonet-economy/IMPLEMENTATION_PLAN.md`` §4.4
|
|
piece 2.
|
|
|
|
For each Function Key + operator combination, the nullifier is
|
|
|
|
nullifier = SHA-256(secret || operator_id)
|
|
|
|
Properties this gives us:
|
|
|
|
- **One-time-use per operator.** The operator records the nullifier
|
|
on first use; subsequent attempts with the same nullifier are
|
|
rejected (denial code ``NULLIFIER_ALREADY_SEEN``).
|
|
- **Cross-operator unlinkability.** Different ``operator_id``s
|
|
produce different nullifiers for the same secret. Two operators
|
|
comparing notes cannot determine that the same key was used at
|
|
both — they see two unrelated 32-byte strings.
|
|
- **No identity leakage.** The nullifier is a hash; the secret is
|
|
never exposed.
|
|
|
|
Operators MUST commit ``operator_id`` publicly so its non-forgeability
|
|
is anchored on chain. Nullifier derivation depends on a
|
|
non-forgeable ``operator_id`` (an attacker who could impersonate an
|
|
operator could harvest nullifiers).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
def derive_nullifier(*, secret: bytes, operator_id: str) -> str:
|
|
"""Return the hex SHA-256 of ``secret || operator_id`` (UTF-8 for
|
|
operator_id).
|
|
|
|
Stable across reboots / sessions / operating systems — the same
|
|
inputs always produce the same output. That's the whole property
|
|
a nullifier needs: deterministic and unforgeable.
|
|
"""
|
|
if not isinstance(secret, (bytes, bytearray)):
|
|
raise TypeError("secret must be bytes")
|
|
if not isinstance(operator_id, str) or not operator_id:
|
|
raise ValueError("operator_id must be a non-empty string")
|
|
h = hashlib.sha256()
|
|
h.update(bytes(secret))
|
|
h.update(b"|") # explicit separator so concatenation is unambiguous
|
|
h.update(operator_id.encode("utf-8"))
|
|
return h.hexdigest()
|
|
|
|
|
|
@dataclass
|
|
class NullifierTracker:
|
|
"""Operator-side store of seen nullifiers.
|
|
|
|
Sprint 11+ runway: this is the in-memory reference implementation.
|
|
Production operators use a persistent, atomic-write store
|
|
(database row + uniqueness constraint) so the "already-seen"
|
|
check is robust to crashes between the check and the receipt.
|
|
|
|
The interface is designed for that: ``check_and_record`` is the
|
|
only mutation method, and it's atomic — checks then records as
|
|
one operation. Production wraps this in a database transaction.
|
|
"""
|
|
|
|
seen: set[str] = field(default_factory=set)
|
|
|
|
def has_seen(self, nullifier: str) -> bool:
|
|
return nullifier in self.seen
|
|
|
|
def check_and_record(self, nullifier: str) -> bool:
|
|
"""Return ``True`` if the nullifier was unseen (and is now
|
|
recorded). Return ``False`` if it was already seen — the
|
|
operator MUST then issue a denial with code
|
|
``NULLIFIER_ALREADY_SEEN``.
|
|
|
|
The check + record is atomic by design: a concurrent caller
|
|
racing with this method will not produce two ``True`` results
|
|
for the same nullifier. (In-memory: trivially atomic. Production:
|
|
wrap in DB unique-insert.)
|
|
"""
|
|
if nullifier in self.seen:
|
|
return False
|
|
self.seen.add(nullifier)
|
|
return True
|
|
|
|
|
|
__all__ = [
|
|
"NullifierTracker",
|
|
"derive_nullifier",
|
|
]
|