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 +1 -0
- noirebox/anchors.py +87 -0
- noirebox/attestation.py +44 -0
- noirebox/auth.py +137 -0
- noirebox/chain.py +182 -0
- noirebox/cli.py +40 -0
- noirebox/client.py +45 -0
- noirebox/dashboard.py +179 -0
- noirebox/guardrail.py +105 -0
- noirebox/llm_agent.py +125 -0
- noirebox/main.py +202 -0
- noirebox/mcp_server.py +132 -0
- noirebox/merkle.py +107 -0
- noirebox/ml_guardrail.py +104 -0
- noirebox/models/detector.joblib +0 -0
- noirebox/models/detector_en.joblib +0 -0
- noirebox/pdf_export.py +163 -0
- noirebox/schemas.py +30 -0
- noirebox/store.py +100 -0
- noirebox-0.3.0.dist-info/METADATA +405 -0
- noirebox-0.3.0.dist-info/RECORD +24 -0
- noirebox-0.3.0.dist-info/WHEEL +4 -0
- noirebox-0.3.0.dist-info/entry_points.txt +2 -0
- noirebox-0.3.0.dist-info/licenses/LICENSE +21 -0
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
|
+
}
|
noirebox/attestation.py
ADDED
|
@@ -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")
|