Index <= Page index / Phase => Phase 5 – Signatures & crypto

Détails – Phase 5 – Signatures & crypto (laptop)

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.

1) Modèle de payload métier (avant signature)

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

2) Canonicalisation JSON (JCS) pour signature

  • Encodage : UTF-8 ; pas d’espaces/newlines/tab en trop.
  • Séparateurs : : entre clé/valeur, , entre éléments, sans espaces.
  • Bool/null : true, false, null en minuscules.
  • Nombres : notation décimale minimale (pas de zéros superflus, pas de 01, pas de 1.2300 ; éviter l’exponentiel si possible).
  • Strings : guillemets doubles, échappement JSON standard.
  • Ordre des clés : tri lexicographique (UTF-8) pour tous les objets, y compris dans 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}

3) Forme du “blob signé” (MVP JSON JCS)

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

4) API Python attendue (backend JSON JCS)

  • 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.
  • Anti-replay (métier) : vérifier ts (fenêtre) et nonce (cache par kid) après vérification.

5) Tests labo / CLI

  • Tests unitaires : sign/verify, tampering, kid inconnu, signature corrompue, anti-replay.
  • CLI : obbHFL_sign.py / obbHFL_verify.py (signer/vérifier JSON JCS + Ed25519).
  • Forum : obbHFL_forum_emit.py (construit+signe un msg_forum → outbox JSONL) ; obbHFL_forum_receive.py (vérif + anti-replay → inbox_valid/errors).
  • Scénario démo : générer une clé, préparer un payload, signer → .signed.json, vérifier avec key-index ; rejouer le même nonce pour déclencher le replay.

TEST : verrouiller le labo (scénario end-to-end)

  1. Préparer un dossier labo (ex. 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).
  2. Émission (Damara) via obbHFL_forum_emit.py :
    • --subject "Budget 2026", --content "Texte...", --issuer "maire:damara",
    • --key keys/maire-damara.key, --kid DMR1, --outbox outbox.jsonl.
    • Vérifier l’append dans outbox.jsonl, JSON signé lisible (payload, sig base64, kid).
  3. Réception via 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.
    • Attendu : append dans inbox_valid.jsonl, rien dans inbox_errors.jsonl.
  4. Replay : relancer la réception sur le même fichier
    • Doit produire une entrée “Replay detected” dans inbox_errors.jsonl,
    • Pas de doublon dans inbox_valid.jsonl.

Backend JSON signé – implémentation actuelle (signing.py)

Ce backend implémente la stratégie JSON canonique + Ed25519 décrite dans la Phase 5. Il fournit trois éléments principaux :

1) Chargement des clés / ========

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

2) Canonicalisation JSON (JCS-style) / ========

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
    )

3) Signature d’un payload / ========

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 :

4) Vérification de la signature / ========

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.

Exemple concret de payload + objet signé

1) Payload métier (avant signature)

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

2) Forme JSON canonique (JCS) utilisée pour la signature

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}

3) Objet signé retourné par 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"
}

4) Lisibilité pour les maires / citoyens