#!/usr/bin/env python3 # -*- coding: utf-8 -*- # SPDX-FileCopyrightText: 2026 OVELIYA # SPDX-License-Identifier: Apache-2.0 """ lire_capsule.py — lecteur indépendant du format OVELIYA Capsule (versions 1 et 2). Date verrouillée : messages (jeton 7) et capsules à fichiers (en-tête média 4). Ouvre une capsule OVELIYA sans le site OVELIYA : un lien (…#c=…), un jeton, un fichier de capsule .html ou un fichier .oveliya. python3 lire_capsule.py "https://oveliya.com/capsule/c/2030-09-18#c=4.c.…" python3 lire_capsule.py capsule-pour-lea.html --code "six mots du code" python3 lire_capsule.py capsule.oveliya --sortie ./contenu python3 lire_capsule.py capsule.html --empreinte python3 lire_capsule.py "" --code "…" --cle-auteur "clé d'auteur" python3 lire_capsule.py "" --exporter-temps part.age tle -d -o cle.bin part.age # outil officiel drand : github.com/drand/tlock python3 lire_capsule.py "" --code "…" --cle-temps cle.bin Spécification : https://oveliya.com/capsule/format.html Dépendance unique : cryptography (pip install cryptography) Ce lecteur respecte la date d'ouverture et les règles de fermeture. Il n'offre aucun moyen d'ouvrir une capsule avant son jour. Il n'exécute jamais les fichiers qu'une capsule contient : il les enregistre. Capsule IA (profil ia/1) : le contrat et la réponse d'une IA sont rendus tels quels (--json) ; les remettre à une IA est le travail de agent_capsule.py. """ import argparse, base64, datetime, getpass, hashlib, json, os, re, struct, sys, time, unicodedata, zlib try: from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives import hashes from cryptography.exceptions import InvalidTag except ImportError: # pragma: no cover sys.exit("Ce lecteur a besoin du module « cryptography » : pip install cryptography") ITERATIONS = 600_000 # PBKDF2-HMAC-SHA-256 TAILLE_BLOC = 1 << 20 # 1 Mio de clair par bloc (capsule média) MAGIE = b"OVELIYA-CAPSULE\x02" # début d'un fichier .oveliya FORMAT_MEDIA = "oveliya-capsule-media" # Version 2 : l'horloge publique drand (réseau quicknet de la League of Entropy) DRAND_CHAINE = "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971" DRAND_GENESE, DRAND_PERIODE = 1692803367, 3 INFO_HKDF = b"OVELIYA Capsule v2" # Codes de sortie OUVERTE, ERREUR, PAS_ENCORE, REFERMEE = 0, 1, 2, 3 DATE_MAX = 253402300800000 # 1er janvier 10000 : au-delà, aucune date n'est lisible class CapsuleErreur(Exception): """Capsule illisible : format, altération ou code.""" # ── Encodages ───────────────────────────────────────────────────────────── def b64u_dec(s): """base64url sans remplissage → octets.""" if not isinstance(s, str) or not re.fullmatch(r"[A-Za-z0-9_-]*", s): raise CapsuleErreur("format : base64url attendu") return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def b64u_texte(s): return b64u_dec(s).decode("utf-8") # Espaces au sens de JavaScript (String.prototype.trim et \s). ESPACES_JS = ("\t\n\v\f\r          " "    

   ") _RE_ESPACES = re.compile("[" + re.escape(ESPACES_JS) + "]+") def normaliser_code(code): """NFC, sans espaces aux extrémités, espaces intérieurs réduits à un seul, minuscules.""" s = unicodedata.normalize("NFC", str(code or "")) s = s.strip(ESPACES_JS) s = _RE_ESPACES.sub(" ", s) return s.lower() def cle_du_code(code, sel, iterations=ITERATIONS): return hashlib.pbkdf2_hmac("sha256", normaliser_code(code).encode("utf-8"), sel, iterations, 32) def empreinte(octets): """Les 8 premiers octets de SHA-256, en hexadécimal majuscule, par groupes de 4.""" h = hashlib.sha256(octets).digest()[:8].hex().upper() return " ".join(h[i:i + 4] for i in range(0, 16, 4)) def json_js(obj): """JSON.stringify : sans espaces, clés dans l'ordre donné, caractères non ASCII tels quels.""" return json.dumps(obj, ensure_ascii=False, separators=(",", ":")) def unites_utf16(s): return len(s.encode("utf-16-le")) // 2 # ── Capsule message : le jeton ──────────────────────────────────────────── def lire_jeton(jeton): p = str(jeton).strip().split(".") v = p[0] if p else "" if v == "7": return lire_jeton7(p, str(jeton).strip()) if v not in ("1", "2", "3", "4", "5", "6"): raise CapsuleErreur("format : version de jeton inconnue (%s)" % v[:4]) etendu = v in ("2", "3", "5", "6") nb, e = (10, 7) if etendu else (9, 6) if len(p) != nb or p[1] not in ("c", "l") or not (p[e] and p[e + 1] and p[e + 2]): raise CapsuleErreur("format : jeton incomplet") try: ouverture = int(p[2], 36) x = int(p[6], 36) if etendu else 0 except ValueError: raise CapsuleErreur("format : date illisible") scelle = v in ("4", "5", "6") c = { "type": "message", "jeton": str(jeton).strip(), "version": int(v), "mode": p[1], "ouverture": ouverture, "vie": x if v in ("2", "5") else 0, "fin": x if v in ("3", "6") else 0, "compresse": p[3] == "1", "etiquette": b64u_texte(p[4]) if scelle else "", "pour": "" if scelle else b64u_texte(p[4]), "de": "" if scelle else b64u_texte(p[5]), "fuseau": int(p[5]) if scelle and re.fullmatch(r"-?\d{1,4}", p[5]) else None, "donnee_associee": ".".join(p[:e]).encode("ascii"), "part_cle": b64u_dec(p[e]), "iv": b64u_dec(p[e + 1]), "chiffre": b64u_dec(p[e + 2]), "id": "m:" + p[e + 1], } c["empreinte"] = empreinte(c["jeton"].encode("utf-8")) return c def lire_jeton7(p, jeton): """Version 2 du format : jeton 7, message verrouillé par le temps (drand) et par le code.""" if len(p) != 12 or p[1] not in ("c", "l") or not all(p[7:12]): raise CapsuleErreur("format : jeton 7 incomplet") try: ouverture, tour = int(p[2], 36), int(p[6], 36) except ValueError: raise CapsuleErreur("format : date ou tour illisible") secours = b64u_dec(p[8]) if len(secours) != 76 or tour < 1: raise CapsuleErreur("format : jeton 7 invalide") c = {"type": "message", "jeton": jeton, "version": 7, "verrou": True, "mode": p[1], "ouverture": ouverture, "vie": 0, "fin": 0, "compresse": p[3] == "1", "etiquette": b64u_texte(p[4]), "pour": "", "de": "", "fuseau": int(p[5]) if re.fullmatch(r"-?\d{1,4}", p[5]) else None, "tour": tour, "age": b64u_dec(p[7]), "sel_secours": secours[:16], "iv_secours": secours[16:28], "chiffre_secours": secours[28:], "donnee_secours": ".".join(p[:8]).encode("ascii"), "donnee_associee": ".".join(p[:9]).encode("ascii"), "part_cle": b64u_dec(p[9]), "iv": b64u_dec(p[10]), "chiffre": b64u_dec(p[11]), "id": "m:" + p[10]} c["empreinte"] = empreinte(jeton.encode("utf-8")) return c def tour_drand(age): """Le tour et la chaîne inscrits dans l'en-tête age : « -> tlock ».""" m = re.search(rb"\n-> tlock (\d+) ([0-9a-f]{64})\n", age[:400]) if not m: raise CapsuleErreur("format : part du temps illisible") return int(m.group(1)), m.group(2).decode() def lire_cle_temps(valeur): """La clé du temps : un fichier (sortie brute de « tle -d », 32 octets, ou texte hexadécimal) ou 64 caractères hexadécimaux.""" brut = valeur if os.path.isfile(valeur): with open(valeur, "rb") as fh: octets = fh.read() if len(octets) == 32: return octets brut = octets.decode("ascii", "replace") try: k = bytes.fromhex("".join(brut.split())) except ValueError: k = b"" if len(k) != 32: raise CapsuleErreur("la clé du temps doit faire 32 octets : le fichier écrit par « tle -d », " "ou 64 caractères hexadécimaux") return k def cle_du_temps(c, secours=None, cle_temps=None): if cle_temps: return lire_cle_temps(cle_temps) if secours: try: return AESGCM(cle_du_code(secours, c["sel_secours"])).decrypt(c["iv_secours"], c["chiffre_secours"], c["donnee_secours"]) except InvalidTag: raise CapsuleErreur("clé d'auteur incorrecte") tour, chaine = tour_drand(c["age"]) raise CapsuleErreur( "cette capsule est verrouillée par l'horloge publique drand (tour %d). Pour l'ouvrir sans le site : " "--exporter-temps part.age, puis « tle -d -o cle.bin part.age » (outil officiel drand, github.com/drand/tlock), " "puis --cle-temps cle.bin ; ou, pour l'auteur, --cle-auteur \"clé d'auteur\"" % tour) def ouvrir_message7(c, code, secours=None, cle_temps=None): k_temps = cle_du_temps(c, secours, cle_temps) if c["mode"] == "c": k_code = cle_du_code(code, c["part_cle"]) else: k_code = c["part_cle"] if len(k_code) != 32: raise CapsuleErreur("format : clé du lien invalide") cle = HKDF(algorithm=hashes.SHA256(), length=32, salt=bytes(32), info=INFO_HKDF).derive(k_code + k_temps) try: clair = AESGCM(cle).decrypt(c["iv"], c["chiffre"], c["donnee_associee"]) except InvalidTag: # La clé d'auteur se vérifie seule ; la clé du temps, non : elle n'est jugée qu'avec le code. if c["mode"] == "c": raise CapsuleErreur("code incorrect" + (" (ou clé du temps incorrecte)" if cle_temps else "")) raise CapsuleErreur("clé du temps incorrecte" if cle_temps else "capsule altérée") if c["compresse"]: clair = zlib.decompress(clair, -15) d = json.loads(clair.decode("utf-8")) return {"texte": str(d.get("t") or ""), "pour": str(d.get("p") or ""), "de": str(d.get("d") or ""), "fichiers": []} def ouvrir_message(c, code): if c["mode"] == "c": cle = cle_du_code(code, c["part_cle"]) else: cle = c["part_cle"] if len(cle) != 32: raise CapsuleErreur("format : clé du lien invalide") try: clair = AESGCM(cle).decrypt(c["iv"], c["chiffre"], c["donnee_associee"]) except InvalidTag: raise CapsuleErreur("code incorrect" if c["mode"] == "c" else "capsule altérée") if c["compresse"]: clair = zlib.decompress(clair, -15) # deflate brut (RFC 1951) d = json.loads(clair.decode("utf-8")) pour, de = (str(d.get("p") or ""), str(d.get("d") or "")) if c["version"] >= 4 else (c["pour"], c["de"]) return {"texte": str(d.get("t") or ""), "pour": pour, "de": de, "fichiers": []} # ── Capsule média : en-tête, flux chiffré, fichiers ─────────────────────── def valider_entete(H): ok = (isinstance(H, dict) and H.get("f") == FORMAT_MEDIA and H.get("v") in (2, 3, 4) and H.get("m") in ("c", "l") and isinstance(H.get("o"), (int, float)) and not isinstance(H.get("o"), bool) and 0 <= H["o"] < DATE_MAX and H.get("cs") == TAILLE_BLOC and isinstance(H.get("n"), int) and H["n"] >= 1 and isinstance(H.get("taille"), int) and H["n"] == max(1, -(-H["taille"] // H["cs"])) and isinstance(H.get("np"), str)) v4 = ok and H["v"] == 4 # capsule à fichiers à date verrouillée (drand) if ok and H["m"] == "l": ok = isinstance(H.get("k"), str) and (not v4 or all(isinstance(H.get(k), str) for k in ("kiv", "kw"))) elif ok: ok = all(isinstance(H.get(k), str) for k in ("sel", "kiv", "kw")) and H.get("it") == ITERATIONS if ok and v4: ok = (isinstance(H.get("tour"), int) and not isinstance(H.get("tour"), bool) and H["tour"] >= 1 and isinstance(H.get("temps"), str) and isinstance(H.get("secours"), str) and "vie" not in H and "fin" not in H) if ok and "vie" in H: ok = isinstance(H["vie"], int) and H["vie"] > 0 if ok and "fin" in H: ok = isinstance(H["fin"], int) and H["fin"] > H["o"] if ok and unites_utf16(str(H.get("et") or "")) > 60: ok = False if not ok: raise CapsuleErreur("format : en-tête de capsule média invalide") return H def coeur_media(H, sans_secours=False): """Partie authentifiée de l'en-tête : ce JSON exact, dans cet ordre. Version 4 (date verrouillée) : le tour, la part du temps et la part de secours en font partie ; la part de secours est elle-même scellée sur ce JSON sans elle (sans_secours).""" if H["v"] >= 3: c = {"f": H["f"], "v": H["v"], "et": H.get("et") or ""} tz = H.get("tz") if isinstance(tz, int) and not isinstance(tz, bool): c["tz"] = tz else: c = {"f": H["f"], "v": H["v"], "pour": str(H.get("pour") or ""), "de": str(H.get("de") or "")} c.update({"o": H["o"], "m": H["m"], "cs": H["cs"], "n": H["n"], "taille": H["taille"], "np": H["np"], "it": H.get("it") or 0, "sel": H.get("sel") or ""}) if H["v"] >= 4: c.update({"tour": H["tour"], "temps": H["temps"]}) if not sans_secours: c["secours"] = H["secours"] if H.get("vie"): c["vie"] = H["vie"] if H.get("fin"): c["fin"] = H["fin"] return json_js(c) def capsule_media(H, blocs): H = valider_entete(H) coeur = coeur_media(H).encode("utf-8") c = {"type": "media", "H": H, "blocs": blocs, "version": H["v"], "mode": H["m"], "ouverture": int(H["o"]), "vie": H.get("vie") or 0, "fin": H.get("fin") or 0, "etiquette": str(H.get("et") or ""), "fuseau": H.get("tz") if isinstance(H.get("tz"), int) else None, "pour": str(H.get("pour") or ""), "de": str(H.get("de") or ""), "donnee_associee": hashlib.sha256(coeur).digest(), "empreinte": empreinte(coeur), "id": "f:" + H["np"]} if H["v"] >= 4: # la date verrouillée, comme pour un message (jeton 7) secours = b64u_dec(H["secours"]) if len(secours) != 76: raise CapsuleErreur("format : part de secours invalide") c.update({"verrou": True, "tour": H["tour"], "age": b64u_dec(H["temps"]), "sel_secours": secours[:16], "iv_secours": secours[16:28], "chiffre_secours": secours[28:], "donnee_secours": hashlib.sha256(coeur_media(H, sans_secours=True).encode("utf-8")).digest()}) return c def nonce_bloc(prefixe, i, dernier): return prefixe + struct.pack(">I", i) + (b"\x01" if dernier else b"\x00") def ouvrir_media(c, code, secours=None, cle_temps=None): H, aad = c["H"], c["donnee_associee"] if len(c["blocs"]) != H["n"]: raise CapsuleErreur("capsule incomplète") if H["v"] >= 4: # Date verrouillée : la clé qui scelle la clé de contenu combine celle du code (ou du lien) # et la clé du temps, que drand publie le jour dit. k_temps = cle_du_temps(c, secours, cle_temps) k_code = cle_du_code(code, b64u_dec(H["sel"]), H["it"]) if H["m"] == "c" else b64u_dec(H["k"]) if len(k_code) != 32: raise CapsuleErreur("format : clé du lien invalide") kek = HKDF(algorithm=hashes.SHA256(), length=32, salt=bytes(32), info=INFO_HKDF).derive(k_code + k_temps) try: dek = AESGCM(kek).decrypt(b64u_dec(H["kiv"]), b64u_dec(H["kw"]), aad) except InvalidTag: if H["m"] == "c": raise CapsuleErreur("code incorrect" + (" (ou clé du temps incorrecte)" if cle_temps else "")) raise CapsuleErreur("clé du temps incorrecte" if cle_temps else "capsule altérée") elif H["m"] == "c": kek = cle_du_code(code, b64u_dec(H["sel"]), H["it"]) try: dek = AESGCM(kek).decrypt(b64u_dec(H["kiv"]), b64u_dec(H["kw"]), aad) except InvalidTag: raise CapsuleErreur("code incorrect") else: dek = b64u_dec(H["k"]) prefixe = b64u_dec(H["np"]) if len(dek) != 32 or len(prefixe) != 7: raise CapsuleErreur("format : clé ou nonce invalide") aes, flux = AESGCM(dek), bytearray() for i, bloc in enumerate(c["blocs"]): try: flux += aes.decrypt(nonce_bloc(prefixe, i, i == H["n"] - 1), bloc, aad) except InvalidTag: raise CapsuleErreur("capsule altérée (bloc %d)" % (i + 1)) if len(flux) != H["taille"] or len(flux) < 4: raise CapsuleErreur("capsule altérée (taille)") L = struct.unpack(">I", flux[:4])[0] if L > 64_000_000 or 4 + L > len(flux): raise CapsuleErreur("capsule altérée (manifeste)") man = json.loads(bytes(flux[4:4 + L]).decode("utf-8")) if not isinstance(man, dict) or not isinstance(man.get("fichiers"), list): raise CapsuleErreur("capsule altérée (manifeste)") pos, fichiers = 4 + L, [] for f in man["fichiers"]: t = int(f.get("taille") or 0) if t < 0 or pos + t > len(flux): raise CapsuleErreur("capsule altérée (fichiers)") fichiers.append({"nom": str(f.get("nom") or "fichier")[:240], "type": str(f.get("type") or "application/octet-stream"), "octets": bytes(flux[pos:pos + t])}) pos += t if pos != len(flux): raise CapsuleErreur("capsule altérée (longueur)") pour, de = (str(man.get("p") or ""), str(man.get("d") or "")) if H["v"] >= 3 else (c["pour"], c["de"]) r = {"texte": str(man.get("t") or ""), "pour": pour, "de": de, "fichiers": fichiers} # Profil IA (ia/1) : le contrat et la réponse d'une IA voyagent dans le manifeste. # Le lecteur les rend tels quels ; les juger est le travail d'un agent. for cle in ("ia", "ia_reponse"): if isinstance(man.get(cle), dict): r[cle] = man[cle] return r # ── Conteneurs : lien, jeton, .html, .oveliya ───────────────────────────── _RE_EMBARQUEE = re.compile(rb'') _RE_BLOC = re.compile(rb'') def charger(source): """Lien, jeton ou chemin de fichier → capsule décrite (sans rien déchiffrer).""" if os.path.isfile(source): with open(source, "rb") as fh: tete = fh.read(len(MAGIE) + 4) if tete[:len(MAGIE)] == MAGIE and len(tete) == len(MAGIE) + 4: lh = struct.unpack(">I", tete[len(MAGIE):])[0] if lh > 1_000_000: raise CapsuleErreur("format : en-tête trop long") H = valider_entete(json.loads(fh.read(lh).decode("utf-8"))) debut = len(MAGIE) + 4 + lh if os.path.getsize(source) != debut + H["taille"] + 16 * H["n"]: raise CapsuleErreur("fichier .oveliya incomplet") blocs = [fh.read(H["cs"] + 16) for _ in range(H["n"] - 1)] blocs.append(fh.read()) return capsule_media(H, blocs) fh.seek(0) contenu = fh.read() m = _RE_EMBARQUEE.search(contenu) if not m: raise CapsuleErreur("ce fichier n'est pas une capsule OVELIYA") d = json.loads(m.group(1).decode("utf-8")) if d.get("type") == "message": return lire_jeton(d.get("jeton", "")) if d.get("type") == "media": return capsule_media(d.get("entete"), [b64u_dec(b.decode("ascii")) for b in _RE_BLOC.findall(contenu)]) raise CapsuleErreur("ce fichier n'est pas une capsule OVELIYA") s = source.strip() if "#c=" not in s and not re.fullmatch(r"(c=)?[1-7]\.[cl]\.[0-9a-z]+\..+", s): raise CapsuleErreur("ni fichier trouvé, ni lien de capsule : « %s »" % s[:80]) if "#c=" in s: s = s.split("#c=", 1)[1] elif s.startswith("c="): s = s[2:] return lire_jeton(s) # ── Le temps : règles d'ouverture et de fermeture ───────────────────────── MEMOIRE = os.path.join(os.path.expanduser("~"), ".oveliya-capsule", "ouvertures.json") def _memoire(): try: with open(MEMOIRE, encoding="utf-8") as fh: return json.load(fh) except (OSError, ValueError): return {} def premiere_ouverture(c): v = _memoire().get(c["id"]) return int(v) if isinstance(v, (int, float)) and v > 0 else 0 def noter_ouverture(c, maintenant): if not c["vie"] or premiere_ouverture(c): return m = _memoire() m[c["id"]] = maintenant try: os.makedirs(os.path.dirname(MEMOIRE), exist_ok=True) with open(MEMOIRE, "w", encoding="utf-8") as fh: json.dump(m, fh) except OSError: pass def fin_de_vie(c): if c["fin"]: return c["fin"] p = premiere_ouverture(c) return p + c["vie"] if c["vie"] and p else 0 def date_lisible(ms, fuseau=None): if not isinstance(fuseau, int) or isinstance(fuseau, bool) or not -1440 < fuseau < 1440: fuseau = 0 # un fuseau hors de ±24 h n'existe pas : UTC decalage = datetime.timedelta(minutes=fuseau) try: d = datetime.datetime.fromtimestamp(ms / 1000, tz=datetime.timezone(decalage)) except (OverflowError, OSError, ValueError): return "date hors calendrier" signe = "+" if (fuseau or 0) >= 0 else "−" h, mn = divmod(abs(fuseau or 0), 60) mois = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"][d.month - 1] jour = "1er" if d.day == 1 else str(d.day) # le 1er janvier, jamais le 1 janvier return "%s %s %d à %02d:%02d (UTC%s%02d:%02d)" % (jour, mois, d.year, d.hour, d.minute, signe, h, mn) # ── Enregistrement des fichiers d'une capsule média ─────────────────────── _RESERVES_WINDOWS = re.compile(r"(con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(\..*)?$", re.I) def nom_sur(nom): nom = re.split(r"[\\/]", nom)[-1] nom = "".join(ch for ch in nom if ch.isprintable() and ch not in '<>:"|?*').strip().lstrip(".").rstrip(". ") if _RESERVES_WINDOWS.match(nom): # CON, NUL, COM1… : des périphériques sous Windows nom = "_" + nom return nom[:200] or "fichier" def enregistrer(resultat, dossier): os.makedirs(dossier, exist_ok=True) ecrits = [] if resultat["texte"]: chemin = os.path.join(dossier, "message.txt") with open(chemin, "w", encoding="utf-8") as fh: fh.write(resultat["texte"]) ecrits.append(chemin) for f in resultat["fichiers"]: base, ext = os.path.splitext(nom_sur(f["nom"])) chemin, k = os.path.join(dossier, base + ext), 2 while os.path.exists(chemin): chemin, k = os.path.join(dossier, "%s (%d)%s" % (base, k, ext)), k + 1 with open(chemin, "xb") as fh: fh.write(f["octets"]) ecrits.append(chemin) return ecrits # ── Programme ───────────────────────────────────────────────────────────── def main(argv=None): ap = argparse.ArgumentParser(description="Lecteur indépendant des capsules OVELIYA (versions 1 et 2 du format).") ap.add_argument("capsule", help="lien de capsule, jeton, fichier .html ou fichier .oveliya") ap.add_argument("--code", help="code d'ouverture (demandé s'il manque)") ap.add_argument("--sortie", help="dossier où enregistrer le contenu d'une capsule média") ap.add_argument("--empreinte", action="store_true", help="afficher l'empreinte et la date, sans ouvrir") ap.add_argument("--cle-auteur", "--secours", dest="secours", metavar="CLE_AUTEUR", help="date verrouillée : clé d'auteur (remplace l'horloge publique) ; --secours est l'ancien nom") ap.add_argument("--cle-temps", help="date verrouillée : clé du temps, obtenue avec « tle -d » (le fichier cle.bin, ou 64 caractères hexadécimaux)") ap.add_argument("--exporter-temps", metavar="FICHIER", help="date verrouillée : écrire la part du temps (fichier age/tlock) pour « tle -d »") ap.add_argument("--json", action="store_true", help="résultat en JSON (pour les tests)") a = ap.parse_args(argv) def sortir(code, **champs): if a.json: print(json_js(champs)) elif champs.get("message"): print(champs["message"], file=sys.stderr if code else sys.stdout) return code try: c = charger(a.capsule) except (CapsuleErreur, ValueError, UnicodeDecodeError) as e: return sortir(ERREUR, resultat="erreur", message="Illisible : %s." % e) infos = {"type": c["type"], "version": c["version"], "mode": "code" if c["mode"] == "c" else "sans code", "ouverture": c["ouverture"], "empreinte": c["empreinte"], "etiquette": c["etiquette"]} if not a.json: print("Capsule OVELIYA · %s · %s" % ("message" if c["type"] == "message" else "média", infos["mode"])) if c["etiquette"]: print("Étiquette : " + c["etiquette"]) print("Ouverture : " + date_lisible(c["ouverture"], c["fuseau"])) print("Empreinte : " + c["empreinte"]) if c.get("verrou"): tour, chaine = tour_drand(c["age"]) if tour != c.get("tour", tour): return sortir(ERREUR, resultat="erreur", **infos, message="Illisible : le tour drand de l'en-tête et celui de la part du temps diffèrent.") infos["tour_drand"] = tour if not a.json: print("Verrou du temps : tour drand %d (%s), chaîne %s…" % (tour, date_lisible( (DRAND_GENESE + (tour - 1) * DRAND_PERIODE) * 1000, c["fuseau"]), chaine[:16])) if a.exporter_temps: with open(a.exporter_temps, "wb") as fh: fh.write(c["age"]) if not a.json: print("Part du temps écrite : " + a.exporter_temps) if a.empreinte or a.exporter_temps: return sortir(OUVERTE, resultat="empreinte", **infos) maintenant = int(time.time() * 1000) if maintenant < c["ouverture"]: jours = -(-(c["ouverture"] - maintenant) // 86_400_000) return sortir(PAS_ENCORE, resultat="pas-encore", **infos, message="Pas encore : cette capsule s'ouvrira le %s, dans %d jour%s. Ce lecteur respecte la date." % (date_lisible(c["ouverture"], c["fuseau"]), jours, "s" if jours > 1 else "")) fdv = fin_de_vie(c) if fdv and maintenant >= fdv: return sortir(REFERMEE, resultat="refermee", **infos, message="Cette capsule s'est refermée le %s." % date_lisible(fdv, c["fuseau"])) code = a.code if c["mode"] == "c" and code is None: code = getpass.getpass("Code de la capsule : ") try: if c.get("version") == 7: r = ouvrir_message7(c, code, a.secours, a.cle_temps) elif c["type"] == "message": r = ouvrir_message(c, code) else: r = ouvrir_media(c, code, a.secours, a.cle_temps) except (CapsuleErreur, ValueError, UnicodeDecodeError, zlib.error) as e: return sortir(ERREUR, resultat="erreur", **infos, message="Impossible d'ouvrir : %s." % e) noter_ouverture(c, maintenant) if a.json: extra = {k: r[k] for k in ("ia", "ia_reponse") if k in r} return sortir(OUVERTE, resultat="ouvre", **infos, texte=r["texte"], pour=r["pour"], de=r["de"], fichiers=[{"nom": f["nom"], "type": f["type"], "taille": len(f["octets"]), "sha256": hashlib.sha256(f["octets"]).hexdigest()} for f in r["fichiers"]], **extra) print() if "ia" in r: print("Capsule IA · profil %s — pour la remettre à une IA : agent_capsule.py (voir format.html)" % str(r["ia"].get("profil") or "?")) if "ia_reponse" in r: ir = r["ia_reponse"] print("Réponse d'une IA · statut %s · à la capsule %s" % (str(ir.get("statut") or "?"), str(ir.get("origine") or "?"))) if r["pour"] or r["de"]: print(("Pour " + r["pour"] if r["pour"] else "") + (" · " if r["pour"] and r["de"] else "") + ("De " + r["de"] if r["de"] else "")) if r["texte"]: print(r["texte"]) if r["fichiers"]: dossier = a.sortie or ("capsule-" + c["empreinte"].replace(" ", "").lower()) print() for chemin in enregistrer(r, dossier): print("Enregistré : " + chemin) return OUVERTE if __name__ == "__main__": sys.exit(main())