Index <= Page index / Phase => Phase 5 – Signatures & crypto
ed25519 via PyNaCl. MVP : JSON signé canonique (JCS) côté laptop + vérification serveur. Porte ouverte vers COSE/CBOR (format binaire compact IoT) si besoin de compacité/interop plus tard.
Payload logique, indépendant du format :
type : string courte (ex. tx, vote, admin_cmd, msg_forum).version : entier (ex. 1).issuer : string du signataire (ex. maire:damara, commune:damara, node:K-001).ts : timestamp Unix (secondes) ou ISO8601 (à figer, conseillé : Unix int).nonce : string base64/hex de 96 bits aléatoires (anti-replay).body : objet métier (ex. vote, forum, admin).Exemple :
{
"type": "vote",
"version": 1,
"issuer": "maire:damara",
"ts": 1733900000,
"nonce": "B5ZqkZsLQ3H0R3qqXlQ2LQ==",
"body": {
"poll_id": "budget-2026",
"choice": "oui"
}
}
: entre clé/valeur, , entre éléments, sans espaces.true, false, null en minuscules.body.Exemple de string canonique (sans saut de ligne) :
{"body":{"choice":"oui","poll_id":"budget-2026"},"issuer":"maire:damara","nonce":"B5ZqkZsLQ3H0R3qqXlQ2LQ==","ts":1733900000,"type":"vote","version":1}
Objet transporté (simple) :
{
"payload": "STRING_JSON_CANONIQUE_JCS",
"sig": "BASE64_ED25519",
"kid": "ID_CLE"
}
Exemple :
{
"payload": "{\"body\":{\"choice\":\"oui\",\"poll_id\":\"budget-2026\"},\"issuer\":\"maire:damara\",\"nonce\":\"B5ZqkZsLQ3H0R3qqXlQ2LQ==\",\"ts\":1733900000,\"type\":\"vote\",\"version\":1}",
"sig": "qLh7Y8RHdzxYD5t9Cm8yBtV6VvTt4B...base64...",
"kid": "DMR1"
}
Remarque : payload est une string JSON canonique. Signature = Ed25519 sur payload.encode("utf-8").
canonicalize_payload(payload: dict) -> str : string JSON JCS (clés triées, sans espaces, nombres minimaux).sign_payload(payload: dict, key_path: str, kid: str) -> dict : canonicalise, signe (Ed25519), retourne {"payload": payload_str, "sig": base64_sig, "kid": kid}.verify_signed_object(signed_obj: dict, key_index_path: str) -> dict : charge la clé publique selon kid, vérifie la signature, parse payload, renvoie le dict ou lève SignatureError/ValueError.ts (fenêtre) et nonce (cache par kid) après vérification.obbHFL_sign.py / obbHFL_verify.py (signer/vérifier JSON JCS + Ed25519).obbHFL_forum_emit.py (construit+signe un msg_forum → outbox JSONL) ; obbHFL_forum_receive.py (vérif + anti-replay → inbox_valid/errors)..signed.json, vérifier avec key-index ; rejouer le même nonce pour déclencher le replay.lab-forum/) avec :
keys/ (clé privée maire Damara + keys/index.json),outbox.jsonl, inbox_valid.jsonl, inbox_errors.jsonl,nonce_store.json (pour NonceStore).obbHFL_forum_emit.py :
--subject "Budget 2026", --content "Texte...", --issuer "maire:damara",--key keys/maire-damara.key, --kid DMR1, --outbox outbox.jsonl.outbox.jsonl, JSON signé lisible (payload, sig base64, kid).obbHFL_forum_receive.py :
--signed msg_forum_damara.signed.json (ou pipe depuis l’outbox),--key-index keys/index.json, --nonce-store nonce_store.json,--inbox-valid inbox_valid.jsonl, --inbox-errors inbox_errors.jsonl.inbox_valid.jsonl, rien dans inbox_errors.jsonl.inbox_errors.jsonl,inbox_valid.jsonl.signing.py)Ce backend implémente la stratégie JSON canonique + Ed25519 décrite dans la Phase 5. Il fournit trois éléments principaux :
canonicalize_payload),sign_payload),verify_signed_object).On accepte les clés privées/publiques Ed25519 en hex ou en base64.
def _load_private_key(key_path: str) -> SigningKey:
"""
Charge une clé privée Ed25519 depuis un fichier texte.
Le fichier contient soit une clé en hex, soit en base64.
"""
raw = Path(key_path).read_text().strip()
try:
# Cas 1 : le fichier contient de l'hex (64 caractères pour Ed25519)
return SigningKey(bytes.fromhex(raw))
except ValueError:
# Cas 2 : sinon on essaie de le décoder comme base64
return SigningKey(base64.b64decode(raw))
def _load_public_key(pub_str: str) -> VerifyKey:
"""
Charge une clé publique Ed25519 depuis une chaîne hex ou base64.
Utilisé avec l'index des clés {kid: public_key}.
"""
try:
return VerifyKey(bytes.fromhex(pub_str))
except ValueError:
return VerifyKey(base64.b64decode(pub_str))
L’index des clés est stocké dans un fichier JSON (par exemple
~/.obbhfl/keys/index.json) de la forme :
{
"DMR1": "base64_ou_hex_de_la_clé_publique_du_maire",
"CMN2": "base64_ou_hex_de_la_clé_publique_de_la_commune"
}
La fonction suivante produit une string JSON canonique pour signature : même payload ⇒ même string ⇒ même signature.
def canonicalize_payload(payload: Dict) -> str:
"""
Produit une string JSON canonique pour la signature :
- UTF-8, aucun espace superflu
- séparateurs (',', ':') compact
- clés de tous les objets triées lexicographiquement
"""
return json.dumps(
payload,
ensure_ascii=False, # UTF-8 natif, pas d'échappement inutile
separators=(",", ":"), # pas d'espaces autour de ',' ou ':'
sort_keys=True # tri des clés par ordre lexicographique
)
body).
La fonction sign_payload prend un payload métier (dict Python) et renvoie un
objet signé prêt à être transporté ou stocké.
def sign_payload(payload: Dict, key_path: str, kid: str) -> Dict:
"""
1) Canonicalise le payload en JSON JCS (string)
2) Charge la clé privée Ed25519 (hex ou base64) depuis key_path
3) Signe la string JSON (UTF-8)
4) Retourne un objet signé :
{
"payload": <string JSON canonique>,
"sig": <signature Ed25519 en base64>,
"kid": <identifiant de clé court>
}
"""
payload_str = canonicalize_payload(payload)
sk = _load_private_key(key_path)
sig = sk.sign(payload_str.encode("utf-8")).signature
return {
"payload": payload_str,
"sig": base64.b64encode(sig).decode("utf-8"),
"kid": kid,
}
Le format final est donc un objet JSON très simple :
payload : la string JSON canonique JCS du message.sig : la signature Ed25519 encodée en base64.kid : un identifiant court de la clé (ex : "DMR1" pour le maire de Damara).
La fonction verify_signed_object prend un objet signé et l’index des clés, et rend le payload métier
si tout est valide (sinon, lève SignatureError).
class SignatureError(Exception):
"""Erreur de signature (kid inconnu, signature invalide, JSON cassé)."""
def verify_signed_object(signed_obj: Dict, key_index_path: str) -> Dict:
"""
1) Récupère payload, sig, kid dans l'objet signé
2) Charge l'index des clés publiques depuis key_index_path (JSON)
3) Charge la clé publique correspondant au kid
4) Vérifie la signature Ed25519 sur payload_str.encode("utf-8")
5) Parse payload_str en dict Python et le retourne
Lève SignatureError si :
- un champ manque,
- le kid est inconnu,
- la signature est invalide,
- le payload n'est pas un JSON valide.
"""
try:
payload_str = signed_obj["payload"]
sig_b64 = signed_obj["sig"]
kid = signed_obj["kid"]
except KeyError as exc:
raise SignatureError(f"Missing field in signed object: {exc}") from exc
key_index = json.loads(Path(key_index_path).read_text())
if kid not in key_index:
raise SignatureError("Unknown kid")
vk = _load_public_key(key_index[kid])
sig_bytes = base64.b64decode(sig_b64)
try:
vk.verify(payload_str.encode("utf-8"), sig_bytes)
except Exception as exc: # BadSignatureError ou ValueError
raise SignatureError("Invalid signature") from exc
try:
return json.loads(payload_str)
except json.JSONDecodeError as exc:
raise SignatureError("Payload is not valid JSON") from exc
Après cette vérification, le système récupère un payload métier propre
(dict Python) sur lequel il peut appliquer les règles de la Phase 5 :
vérification de ts, vérification du nonce, règles anti-replay, etc.
Un exemple de vote signé par le maire de Damara :
{
"type": "vote",
"version": 1,
"issuer": "maire:damara",
"ts": 1733900000,
"nonce": "B5ZqkZsLQ3H0R3qqXlQ2LQ==",
"body": {
"poll_id": "budget-2026",
"choice": "oui"
}
}
Après passage par canonicalize_payload, les clés sont triées
et le JSON est compact (une seule ligne, pas d'espaces) :
{"body":{"choice":"oui","poll_id":"budget-2026"},"issuer":"maire:damara","nonce":"B5ZqkZsLQ3H0R3qqXlQ2LQ==","ts":1733900000,"type":"vote","version":1}
sign_payload(..., key_path, "DMR1")
La fonction sign_payload signe la string ci-dessus et retourne un objet comme celui-ci
(signature illustrative) :
{
"payload": "{\"body\":{\"choice\":\"oui\",\"poll_id\":\"budget-2026\"},\"issuer\":\"maire:damara\",\"nonce\":\"B5ZqkZsLQ3H0R3qqXlQ2LQ==\",\"ts\":1733900000,\"type\":\"vote\",\"version\":1}",
"sig": "mGZ1b5yD3N1aM8pL1Q0q+Zr2O1DqQ9bD6fZzI0X8gYkMxK0uVvl1YHBBwF+EXEMPLE==",
"kid": "DMR1"
}
payload contient la version canonique du message (avec guillemets échappés car c’est une string JSON).sig est une signature Ed25519 encodée en base64 (la valeur ci-dessus est un exemple).kid identifie la clé du maire ("DMR1" pour Damara).verify_signed_object,body),DMR1)”.kid, base64),
l’UI reste lisible et simple pour les maires.