noirebox 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
noirebox/__init__.py ADDED
@@ -0,0 +1 @@
1
+
noirebox/anchors.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+
8
+ import httpx
9
+
10
+ from .chain import GENESIS, KeyPair
11
+ from .store import EventStore
12
+
13
+ GENESIS_HASH = GENESIS
14
+
15
+
16
+ def tsa_configured() -> bool:
17
+ return bool(os.environ.get("NOIREBOX_TSA_URL"))
18
+
19
+
20
+ def _tsa_url() -> str:
21
+ return os.environ["NOIREBOX_TSA_URL"].rstrip("/")
22
+
23
+
24
+ def build_query(head_hash: str) -> bytes:
25
+ """Builds the timestamp request (TimeStampReq, DER) via OpenSSL.
26
+
27
+ The DIGEST (hex) is passed directly — the TSA never sees the data;
28
+ a 32-byte hash tells no one anything.
29
+ """
30
+ with tempfile.TemporaryDirectory() as tmp:
31
+ out = f"{tmp}/req.tsq"
32
+ proc = subprocess.run(
33
+ ["openssl", "ts", "-query", "-digest", head_hash, "-sha256", "-cert", "-out", out],
34
+ capture_output=True,
35
+ )
36
+ if proc.returncode != 0:
37
+ raise RuntimeError(f"openssl ts -query failed: {proc.stderr.decode()[:200]}")
38
+ with open(out, "rb") as f:
39
+ return f.read()
40
+
41
+
42
+ def request_token(head_hash: str, tsa_url: str | None = None) -> bytes:
43
+ """Sends the request to the TSA, returns the TimeStampResp token (DER)."""
44
+ url = (tsa_url or _tsa_url()).rstrip("/")
45
+ response = httpx.post(
46
+ f"{url}/tsa",
47
+ content=build_query(head_hash),
48
+ headers={"Content-Type": "application/timestamp-query"},
49
+ timeout=10,
50
+ )
51
+ if response.status_code != 200:
52
+ raise RuntimeError(f"TSA responded {response.status_code}")
53
+ return response.content
54
+
55
+
56
+ def fetch_tsa_cert(tsa_url: str | None = None) -> str:
57
+ """Fetches the TSA certificate (it travels INSIDE the anchor — the third
58
+ party needs it to verify the token; TOFU + pinning possible, ADR 006)."""
59
+ url = (tsa_url or _tsa_url()).rstrip("/")
60
+ response = httpx.get(f"{url}/cert", timeout=10)
61
+ response.raise_for_status()
62
+ return response.text
63
+
64
+
65
+ def anchor_now(store: EventStore, key: KeyPair, tsa_url: str | None = None) -> dict:
66
+ """Seals the current chain head to the TSA: token + certificate logged
67
+ in an "anchor" event (the anchor is part of the chain)."""
68
+ events = store.all()
69
+ head_seq = len(events)
70
+ head_hash = events[-1]["event_hash"] if events else GENESIS_HASH
71
+
72
+ token = request_token(head_hash, tsa_url)
73
+ cert_pem = fetch_tsa_cert(tsa_url)
74
+
75
+ event = store.append(
76
+ "anchor",
77
+ {"head_seq": head_seq, "head_hash": head_hash,
78
+ "tsr": base64.b64encode(token).decode("ascii"),
79
+ "tsa_cert_pem": cert_pem},
80
+ key,
81
+ )
82
+ return {
83
+ "anchor_event_seq": event.seq,
84
+ "anchored_head_seq": head_seq,
85
+ "anchored_head_hash": head_hash,
86
+ "tsr_bytes": len(token),
87
+ }
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+
5
+ from .chain import GENESIS, KeyPair, canonical, ed25519_verify, verify_chain
6
+ from .store import now_iso
7
+
8
+
9
+ def build_attestation(store, key: KeyPair) -> dict:
10
+ """Builds the attestation of the current state: canonical digest + signature.
11
+
12
+ `store` is loosely typed (to avoid a circular import with store.py):
13
+ we only depend on its `.all()`.
14
+ """
15
+ events = store.all()
16
+ report = verify_chain(key.public_hex(), events)
17
+ by_type = dict(Counter(e["type"] for e in events))
18
+ core = {
19
+ "generated_at": now_iso(),
20
+ "algo": "sha256-chain+ed25519",
21
+ "public_key": key.public_hex(),
22
+ "head_seq": len(events),
23
+ "head_hash": events[-1]["event_hash"] if events else GENESIS,
24
+ "total_events": len(events),
25
+ "chain_valid": report["valid"],
26
+ "event_types": by_type,
27
+ }
28
+
29
+ return {**core, "signature": key.sign(canonical(core))}
30
+
31
+
32
+ def verify_attestation(attestation: dict) -> bool:
33
+ """Verifies the signature of a submitted attestation.
34
+
35
+ The `signature` key is removed from the dict before recomputing the
36
+ digest: that is the "signed payload" scheme — everything is signed
37
+ EXCEPT the signature itself.
38
+ """
39
+ core = {k: v for k, v in attestation.items() if k != "signature"}
40
+ public_key = attestation.get("public_key", "")
41
+ signature = attestation.get("signature", "")
42
+ if not public_key or not signature:
43
+ return False
44
+ return ed25519_verify(public_key, signature, canonical(core))
noirebox/auth.py ADDED
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import os
6
+ import time
7
+
8
+ import jwt
9
+ from fastapi import HTTPException, Request
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+ DEFAULT_CLIENTS = {"demo": "demo-secret"}
18
+
19
+
20
+ def load_clients() -> dict[str, str]:
21
+ """Reads NOIREBOX_CLIENTS ("id:secret,id2:secret2"), otherwise the demo client."""
22
+ raw = os.environ.get("NOIREBOX_CLIENTS", "")
23
+ clients: dict[str, str] = {}
24
+ for pair in raw.split(","):
25
+ if ":" in pair:
26
+ client_id, secret = pair.split(":", 1)
27
+ clients[client_id.strip()] = secret.strip()
28
+ return clients or dict(DEFAULT_CLIENTS)
29
+
30
+
31
+
32
+
33
+ TOKEN_TTL_SECONDS = 3600
34
+
35
+
36
+ def _jwt_secret() -> str:
37
+ """JWT signing secret: NOIREBOX_JWT_SECRET, otherwise derived from the
38
+ instance key (every deployment signs with something unique)."""
39
+ secret = os.environ.get("NOIREBOX_JWT_SECRET")
40
+ if secret:
41
+ return secret
42
+
43
+
44
+ from .chain import KeyPair
45
+
46
+ key = KeyPair.load_or_create(os.environ.get("NOIREBOX_DB", "data/noirebox.db") + ".key")
47
+ return hashlib.sha256(key.public_hex().encode()).hexdigest()
48
+
49
+
50
+ def issue_token(client_id: str, client_secret: str, clients: dict[str, str] | None = None) -> str | None:
51
+ """Exchanges an id/secret pair for a 1 h JWT. None if credentials are invalid.
52
+
53
+ Constant-time comparison (`hmac.compare_digest`) — the SQL injection
54
+ lesson applied to timing: reveal nothing through request duration.
55
+ """
56
+ clients = clients if clients is not None else load_clients()
57
+ expected = clients.get(client_id)
58
+ if expected is None or not hmac.compare_digest(expected.encode(), client_secret.encode()):
59
+ return None
60
+ now = int(time.time())
61
+ payload = {"sub": client_id, "iat": now, "exp": now + TOKEN_TTL_SECONDS, "scope": "api"}
62
+ return jwt.encode(payload, _jwt_secret(), algorithm="HS256")
63
+
64
+
65
+ def verify_token(token: str) -> str | None:
66
+ """Verifies signature + expiration. Returns the client_id, None if invalid."""
67
+ try:
68
+ payload = jwt.decode(token, _jwt_secret(), algorithms=["HS256"])
69
+ except jwt.InvalidTokenError:
70
+ return None
71
+ return payload.get("sub")
72
+
73
+
74
+
75
+
76
+ class RateLimiter:
77
+ """Per-client counter over a sliding window.
78
+
79
+ Why in memory and not SQLite: rate limiting must cost ~0 ms and, above
80
+ all, never write to the append-only journal (the journal records the
81
+ business, not the traffic). Redis = the multi-process extension.
82
+ """
83
+
84
+ def __init__(self, max_requests: int = 60, window_seconds: int = 60):
85
+ self.max = max_requests
86
+ self.window = window_seconds
87
+ self._hits: dict[str, list[float]] = {}
88
+
89
+ def allow(self, client_id: str, now: float | None = None) -> tuple[bool, int]:
90
+ """Returns (allowed?, remaining quota). Purges expired hits along the way."""
91
+ now = now if now is not None else time.monotonic()
92
+ bucket = [t for t in self._hits.get(client_id, []) if now - t < self.window]
93
+ if len(bucket) >= self.max:
94
+ self._hits[client_id] = bucket
95
+ return False, 0
96
+ bucket.append(now)
97
+ self._hits[client_id] = bucket
98
+ return True, self.max - len(bucket)
99
+
100
+
101
+
102
+
103
+ def build_auth_dependency(clients: dict[str, str] | None = None,
104
+ limiter: RateLimiter | None = None,
105
+ enabled: bool = True):
106
+ """Returns the FastAPI dependency `require_auth`.
107
+
108
+ Why a factory: tests inject their own clients/limits without touching
109
+ env vars — same pattern as `create_app(db_path)`.
110
+ Auth DISABLED by default locally (enabled=False when no client is
111
+ configured): the quickstart stays friction-free `./start.sh`; enabling
112
+ it means setting NOIREBOX_CLIENTS. Security must not break the demo,
113
+ and activation must be an explicit choice.
114
+ """
115
+ clients = clients if clients is not None else load_clients()
116
+ limiter = limiter or RateLimiter()
117
+
118
+ def require_auth(request: Request) -> str:
119
+ if not enabled:
120
+ return "anonymous"
121
+ header = request.headers.get("authorization", "")
122
+ if not header.startswith("Bearer "):
123
+ raise HTTPException(status_code=401, detail="Authorization: Bearer <token> required",
124
+ headers={"WWW-Authenticate": "Bearer"})
125
+ client_id = verify_token(header.removeprefix("Bearer ").strip())
126
+ if client_id is None:
127
+ raise HTTPException(status_code=401, detail="invalid or expired token",
128
+ headers={"WWW-Authenticate": "Bearer"})
129
+ allowed, remaining = limiter.allow(client_id)
130
+ if not allowed:
131
+ raise HTTPException(status_code=429, detail="rate limit exceeded (60 req/min)",
132
+ headers={"Retry-After": "60"})
133
+ request.state.client_id = client_id
134
+ request.state.quota_remaining = remaining
135
+ return client_id
136
+
137
+ return require_auth
noirebox/chain.py ADDED
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+ from cryptography.exceptions import InvalidSignature
9
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
10
+ Ed25519PrivateKey,
11
+ Ed25519PublicKey,
12
+ )
13
+ from cryptography.hazmat.primitives.serialization import (
14
+ Encoding,
15
+ NoEncryption,
16
+ PrivateFormat,
17
+ PublicFormat,
18
+ load_pem_private_key,
19
+ )
20
+
21
+
22
+
23
+ GENESIS = "0" * 64
24
+
25
+
26
+ def canonical(obj: object) -> bytes:
27
+ """Deterministic canonical serialization: identical bytes on every machine.
28
+
29
+ Why this is critical: the hash is computed over these bytes. If two
30
+ serializations of the same content produced two different representations
31
+ (key order, whitespace), third-party verification would fail with no
32
+ tampering at all. Hence: sorted keys (`sort_keys`), zero superfluous
33
+ whitespace, explicit UTF-8 (accented text must hash identically everywhere).
34
+ """
35
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
36
+
37
+
38
+ def compute_event_hash(seq: int, ts: str, type_: str, payload: dict, prev_hash: str) -> str:
39
+ """Hex SHA-256 hash committing the entire content of the event."""
40
+ core = {"seq": seq, "ts": ts, "type": type_, "payload": payload, "prev_hash": prev_hash}
41
+ return hashlib.sha256(canonical(core)).hexdigest()
42
+
43
+
44
+ class KeyPair:
45
+ """The instance's Ed25519 key pair.
46
+
47
+ Why Ed25519: modern elliptic-curve cryptography, 32-byte keys (versus
48
+ 256+ bytes for RSA), very fast signing and verification, standardized.
49
+
50
+ The private key is persisted as PEM (standard text format) with chmod 600:
51
+ only the server process can read it. The public key, meanwhile, travels
52
+ in every export/attestation — it is all a third party needs.
53
+ """
54
+
55
+ def __init__(self, private_key: Ed25519PrivateKey):
56
+ self._priv = private_key
57
+ self._pub = private_key.public_key()
58
+
59
+ @classmethod
60
+ def generate(cls) -> KeyPair:
61
+ """Factory: generates a new key pair."""
62
+ return cls(Ed25519PrivateKey.generate())
63
+
64
+ @classmethod
65
+ def load_or_create(cls, path: str) -> KeyPair:
66
+ """Loads the PEM key if it exists, otherwise generates it and writes it chmod 600.
67
+
68
+ `os.open(..., 0o600)`: the file is created with its permissions set
69
+ right away (atomically), rather than created then chmod'ed — a key
70
+ file world-readable for even an instant would be a vulnerability.
71
+ """
72
+ if os.path.exists(path):
73
+ with open(path, "rb") as f:
74
+ priv = load_pem_private_key(f.read(), password=None)
75
+ if not isinstance(priv, Ed25519PrivateKey):
76
+ raise ValueError(f"{path} does not contain an Ed25519 key")
77
+ return cls(priv)
78
+ kp = cls.generate()
79
+ pem = kp._priv.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
80
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
81
+ with os.fdopen(fd, "wb") as f:
82
+ f.write(pem)
83
+ return kp
84
+
85
+ def public_hex(self) -> str:
86
+ """Public key in hexadecimal — the value embedded in the exports."""
87
+ return self._pub.public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
88
+
89
+ def sign(self, data: bytes) -> str:
90
+ """Signs bytes, returns the signature in hexadecimal."""
91
+ return self._priv.sign(data).hex()
92
+
93
+ def verify(self, signature_hex: str, data: bytes) -> bool:
94
+ """Verifies with THIS instance's public key (self-verification)."""
95
+ try:
96
+ self._pub.verify(bytes.fromhex(signature_hex), data)
97
+ return True
98
+ except (InvalidSignature, ValueError):
99
+ return False
100
+
101
+ def verify_with_public_key(self, public_hex: str, signature_hex: str, data: bytes) -> bool:
102
+ """Verifies with an external public key — the third-party auditor case."""
103
+ return ed25519_verify(public_hex, signature_hex, data)
104
+
105
+
106
+ @dataclass
107
+ class Event:
108
+ """A journal event. `@dataclass` generates __init__/__eq__ automatically.
109
+
110
+ The 7 fields below are enough: no getter/setter to write.
111
+ """
112
+
113
+ seq: int
114
+ ts: str
115
+ type: str
116
+ payload: dict
117
+ prev_hash: str
118
+ event_hash: str
119
+ signature: str
120
+
121
+ def as_dict(self) -> dict:
122
+ """Explicit serialization to a dict (JSON-compatible)."""
123
+ return {
124
+ "seq": self.seq,
125
+ "ts": self.ts,
126
+ "type": self.type,
127
+ "payload": self.payload,
128
+ "prev_hash": self.prev_hash,
129
+ "event_hash": self.event_hash,
130
+ "signature": self.signature,
131
+ }
132
+
133
+
134
+ def ed25519_verify(public_hex: str, signature_hex: str, data: bytes) -> bool:
135
+ """Verification with the public key alone.
136
+
137
+ Returns False instead of raising: a signature failure is an expected
138
+ BUSINESS outcome (tampering), not a programming error.
139
+ """
140
+ try:
141
+ pub = Ed25519PublicKey.from_public_bytes(bytes.fromhex(public_hex))
142
+ pub.verify(bytes.fromhex(signature_hex), data)
143
+ return True
144
+ except (InvalidSignature, ValueError):
145
+ return False
146
+
147
+
148
+ def verify_event(public_hex: str, ev: dict) -> str | None:
149
+ """Verifies a single event. Convention: `None` = intact, otherwise the reason.
150
+
151
+ No exception is raised for a predictable business case — the auditor wants
152
+ a report, not a crash.
153
+ """
154
+ recomputed = compute_event_hash(ev["seq"], ev["ts"], ev["type"], ev["payload"], ev["prev_hash"])
155
+ if recomputed != ev["event_hash"]:
156
+ return "invalid hash (content was modified)"
157
+ if not ed25519_verify(public_hex, ev["signature"], bytes.fromhex(ev["event_hash"])):
158
+ return "invalid signature"
159
+ return None
160
+
161
+
162
+ def verify_chain(public_hex: str, events: list[dict]) -> dict:
163
+ """Verifies the whole chain: order, links, hashes, signatures.
164
+
165
+ We stop at the first anomaly and locate it precisely (`first_error.seq`):
166
+ that is what the auditor wants to see — WHERE it breaks.
167
+ """
168
+ prev = GENESIS
169
+ for expected_seq, ev in enumerate(events, start=1):
170
+
171
+ if ev["seq"] != expected_seq:
172
+ return {"valid": False, "nb_events": len(events),
173
+ "first_error": {"seq": ev["seq"], "reason": "broken sequence (reordering or deletion)"}}
174
+ if ev["prev_hash"] != prev:
175
+ return {"valid": False, "nb_events": len(events),
176
+ "first_error": {"seq": ev["seq"], "reason": f"broken link: prev_hash ≠ hash of event {ev['seq'] - 1}"}}
177
+ reason = verify_event(public_hex, ev)
178
+ if reason is not None:
179
+ return {"valid": False, "nb_events": len(events),
180
+ "first_error": {"seq": ev["seq"], "reason": reason}}
181
+ prev = ev["event_hash"]
182
+ return {"valid": True, "nb_events": len(events), "first_error": None}
noirebox/cli.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+
5
+
6
+ def _version() -> str:
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+ try:
10
+ return version("noirebox")
11
+ except PackageNotFoundError:
12
+ return "0.3.0+unknown"
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(
17
+ prog="noirebox",
18
+ description="NoireBox — the flight data recorder for AI agents.",
19
+ )
20
+ parser.add_argument("--version", action="version", version=f"%(prog)s {_version()}")
21
+ sub = parser.add_subparsers(dest="command")
22
+ serve = sub.add_parser("serve", help="run the HTTP API (uvicorn)")
23
+ serve.add_argument("--host", default="127.0.0.1", help="bind address")
24
+ serve.add_argument("--port", type=int, default=8768, help="listening port")
25
+ args = parser.parse_args(argv)
26
+
27
+ if args.command == "serve":
28
+ import uvicorn
29
+
30
+ uvicorn.run("noirebox.main:app", host=args.host, port=args.port, log_level="info")
31
+ return 0
32
+
33
+ parser.print_help()
34
+ return 0
35
+
36
+
37
+ if __name__ == "__main__":
38
+ import sys
39
+
40
+ sys.exit(main())
noirebox/client.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+
6
+ class NoireBoxError(RuntimeError):
7
+ """Business error raised by the API (status 4xx/5xx)."""
8
+
9
+
10
+ class NoireBoxClient:
11
+ def __init__(self, base_url: str = "http://127.0.0.1:8768",
12
+ timeout: float = 5.0, transport: httpx.BaseTransport | None = None):
13
+
14
+
15
+ self._http = httpx.Client(
16
+ base_url=base_url.rstrip("/"), timeout=timeout, transport=transport
17
+ )
18
+
19
+ def _request(self, method: str, path: str, json_body: dict | None = None) -> dict:
20
+ response = self._http.request(method, path, json=json_body)
21
+ if response.status_code >= 400:
22
+ raise NoireBoxError(f"{method} {path} → {response.status_code}: {response.text}")
23
+ return response.json()
24
+
25
+ def health(self) -> dict:
26
+ return self._request("GET", "/health")
27
+
28
+ def log_event(self, type_: str, payload: dict | None = None) -> dict:
29
+ return self._request("POST", "/api/v1/events",
30
+ {"type": type_, "payload": payload or {}})
31
+
32
+ def scan(self, meeting_id: str, text: str, engine: str = "regex", lang: str = "fr") -> dict:
33
+ """Guardrail on a text. `engine`: "regex" or "ml"; `lang`: "fr"/"en" (ml engine)."""
34
+ return self._request("POST", "/api/v1/transcripts/scan",
35
+ {"meeting_id": meeting_id, "text": text,
36
+ "engine": engine, "lang": lang})
37
+
38
+ def verify(self) -> dict:
39
+ return self._request("GET", "/api/v1/verify")
40
+
41
+ def attestation(self) -> dict:
42
+ return self._request("GET", "/api/v1/attestation")
43
+
44
+ def export(self) -> dict:
45
+ return self._request("GET", "/api/v1/export")