intermesh 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.
intermesh/crypto.py ADDED
@@ -0,0 +1,150 @@
1
+ import base64
2
+ import json
3
+ import os
4
+
5
+ from cryptography.hazmat.primitives.asymmetric import rsa, padding
6
+ from cryptography.hazmat.primitives import hashes, serialization
7
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
8
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
9
+
10
+
11
+ def generate_keypair():
12
+ """Génère une paire de clés RSA-2048."""
13
+ return rsa.generate_private_key(public_exponent=65537, key_size=2048)
14
+
15
+
16
+ def get_public_key_pem(private_key) -> str:
17
+ """Exporte la clé publique au format PEM."""
18
+ return private_key.public_key().public_bytes(
19
+ encoding=serialization.Encoding.PEM,
20
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
21
+ ).decode("utf-8")
22
+
23
+
24
+ def get_private_key_pem(private_key) -> str:
25
+ """Exporte la clé privée au format PEM."""
26
+ return private_key.private_bytes(
27
+ encoding=serialization.Encoding.PEM,
28
+ format=serialization.PrivateFormat.PKCS8,
29
+ encryption_algorithm=serialization.NoEncryption()
30
+ ).decode("utf-8")
31
+
32
+
33
+ def load_public_key(pem_string: str):
34
+ """Charge une clé publique depuis un PEM."""
35
+ return serialization.load_pem_public_key(pem_string.encode("utf-8"))
36
+
37
+
38
+ def load_private_key(pem_string: str):
39
+ """Charge une clé privée depuis un PEM."""
40
+ return serialization.load_pem_private_key(pem_string.encode("utf-8"), password=None)
41
+
42
+
43
+ def encrypt_for(recipient_public_key_pem: str, plaintext: str) -> str:
44
+ """Chiffrement hybride RSA-OAEP + AES-256-GCM."""
45
+ plaintext_bytes = plaintext.encode("utf-8")
46
+ aes_key = AESGCM.generate_key(bit_length=256)
47
+ nonce = os.urandom(12)
48
+ aesgcm = AESGCM(aes_key)
49
+ ciphertext = aesgcm.encrypt(nonce, plaintext_bytes, None)
50
+ pub_key = load_public_key(recipient_public_key_pem)
51
+ encrypted_aes_key = pub_key.encrypt(
52
+ aes_key,
53
+ padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
54
+ )
55
+ envelope = {
56
+ "ek": base64.b64encode(encrypted_aes_key).decode(),
57
+ "n": base64.b64encode(nonce).decode(),
58
+ "ct": base64.b64encode(ciphertext).decode()
59
+ }
60
+ return base64.b64encode(json.dumps(envelope).encode()).decode()
61
+
62
+
63
+ def decrypt_with(private_key, encrypted_b64: str) -> str:
64
+ """Déchiffrement hybride RSA-OAEP + AES-256-GCM."""
65
+ envelope = json.loads(base64.b64decode(encrypted_b64))
66
+ aes_key = private_key.decrypt(
67
+ base64.b64decode(envelope["ek"]),
68
+ padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
69
+ )
70
+ aesgcm = AESGCM(aes_key)
71
+ return aesgcm.decrypt(base64.b64decode(envelope["n"]), base64.b64decode(envelope["ct"]), None).decode("utf-8")
72
+
73
+
74
+ # --- CHIFFREMENT AU REPOS PAR PASSPHRASE (PBKDF2 + AES-256-GCM) ---
75
+
76
+ PBKDF2_ITERATIONS = 100_000
77
+
78
+
79
+ def _derive_key(passphrase: str, salt: bytes) -> bytes:
80
+ """Dérive une clé AES-256 depuis une passphrase, via PBKDF2-HMAC-SHA256."""
81
+ kdf = PBKDF2HMAC(
82
+ algorithm=hashes.SHA256(),
83
+ length=32,
84
+ salt=salt,
85
+ iterations=PBKDF2_ITERATIONS,
86
+ )
87
+ return kdf.derive(passphrase.encode("utf-8"))
88
+
89
+
90
+ def encrypt_blob(plaintext: str, passphrase: str) -> dict:
91
+ """
92
+ Chiffre un texte quelconque avec une passphrase.
93
+
94
+ Retourne un dict JSON-sérialisable (`salt`/`nonce`/`ciphertext` en
95
+ base64) : le sel est stocké en clair, il n'est pas secret — il empêche
96
+ seulement de pré-calculer une table pour toutes les passphrases.
97
+ """
98
+ salt = os.urandom(16)
99
+ nonce = os.urandom(12)
100
+ ciphertext = AESGCM(_derive_key(passphrase, salt)).encrypt(
101
+ nonce, plaintext.encode("utf-8"), None
102
+ )
103
+ return {
104
+ "salt": base64.b64encode(salt).decode("utf-8"),
105
+ "nonce": base64.b64encode(nonce).decode("utf-8"),
106
+ "ciphertext": base64.b64encode(ciphertext).decode("utf-8"),
107
+ }
108
+
109
+
110
+ def decrypt_blob(blob: dict, passphrase: str) -> str:
111
+ """
112
+ Déchiffre un blob produit par `encrypt_blob`.
113
+
114
+ Raises:
115
+ cryptography.exceptions.InvalidTag: passphrase incorrecte, ou
116
+ contenu altéré — AES-GCM ne distingue pas les deux cas, et
117
+ c'est voulu : les deux signifient « ne fais pas confiance ».
118
+ """
119
+ key = _derive_key(passphrase, base64.b64decode(blob["salt"]))
120
+ return AESGCM(key).decrypt(
121
+ base64.b64decode(blob["nonce"]),
122
+ base64.b64decode(blob["ciphertext"]),
123
+ None,
124
+ ).decode("utf-8")
125
+
126
+
127
+ # --- COFFRE-FORT DE CLÉS LOCAL CHIFFRÉ (LOCAL KEY VAULT) ---
128
+
129
+ def save_encrypted_vault(file_path: str, private_key_pem: str, passphrase: str):
130
+ """
131
+ Chiffre et sauvegarde la clé privée sur le disque avec PBKDF2 (100 000 itérations) + AES-256-GCM.
132
+ """
133
+ vault_data = encrypt_blob(private_key_pem, passphrase)
134
+ vault_data["version"] = "nexus_vault_v1"
135
+
136
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
137
+ with open(file_path, "w", encoding="utf-8") as f:
138
+ json.dump(vault_data, f, indent=2)
139
+
140
+ # Restreindre les permissions du fichier sous Linux (600 = lecture/écriture propriétaire seul)
141
+ os.chmod(file_path, 0o600)
142
+
143
+
144
+ def load_encrypted_vault(file_path: str, passphrase: str) -> str:
145
+ """
146
+ Déchiffre et charge la clé privée depuis le coffre-fort local.
147
+ """
148
+ with open(file_path, "r", encoding="utf-8") as f:
149
+ vault_data = json.load(f)
150
+ return decrypt_blob(vault_data, passphrase)
intermesh/egress.py ADDED
@@ -0,0 +1,180 @@
1
+ """
2
+ Filtrage de sortie : ce qu'une organisation accepte de laisser franchir
3
+ sa frontière.
4
+
5
+ L'isolement multi-tenant, le pairage explicite et la signature Ed25519
6
+ répondent à « qui parle à qui ». Ils ne disent rien de « ce qui sort » :
7
+ une fois le lien de fédération établi, un agent peut transmettre à un
8
+ partenaire n'importe quel contenu auquel il a accès. C'est le manque que
9
+ ce module comble.
10
+
11
+ Deux points d'application, parce qu'ils ne voient pas la même chose :
12
+
13
+ * L'agent émetteur (SDK), avant chiffrement. C'est le seul endroit où le
14
+ contenu est en clair sur le chemin nominal, donc le seul filtre qui
15
+ morde réellement quand le chiffrement E2E est actif.
16
+ * Le Hub, au moment de relayer vers un pair. Il ne voit que ce qui n'est
17
+ pas chiffré, mais il applique la politique même à un agent qui ne
18
+ l'aurait pas fait — un agent est du code, pas une autorité.
19
+
20
+ Ni l'un ni l'autre ne protège d'un agent interne malveillant qui
21
+ chiffrerait lui-même avant l'envoi. Le filtre d'egress traite la fuite
22
+ par négligence, pas l'exfiltration délibérée.
23
+
24
+ Politique vide par défaut : rien n'est filtré tant que rien n'est déclaré.
25
+ Caviarder en silence serait pire que ne pas filtrer.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import re
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ REDACT = "redact"
37
+ DROP = "drop"
38
+ BLOCK = "block"
39
+ ACTIONS = (REDACT, DROP, BLOCK)
40
+
41
+ DEFAULT_REPLACEMENT = "[REDACTED]"
42
+ ANY_ORG = "*"
43
+
44
+
45
+ class EgressBlocked(PermissionError):
46
+ """Le contenu contient quelque chose qui ne doit pas franchir la frontière."""
47
+
48
+ def __init__(self, rule_name: str, target_org: str):
49
+ self.rule_name = rule_name
50
+ self.target_org = target_org
51
+ super().__init__(
52
+ f"EGRESS_BLOCKED: la règle '{rule_name}' interdit d'envoyer ce contenu "
53
+ f"à l'organisation '{target_org}'."
54
+ )
55
+
56
+
57
+ @dataclass
58
+ class EgressRule:
59
+ """Une règle de sortie.
60
+
61
+ `action` :
62
+ - `redact` : remplace les portions de texte correspondant à `pattern`.
63
+ - `drop` : supprime toute clé nommée `field` (à n'importe quelle
64
+ profondeur), quelle que soit sa valeur.
65
+ - `block` : refuse l'envoi entier si `pattern` apparaît.
66
+
67
+ `to_orgs` restreint la règle à certaines organisations destinataires ;
68
+ `["*"]` l'applique à toute sortie hors de l'organisation.
69
+ """
70
+
71
+ name: str
72
+ action: str
73
+ pattern: str | None = None
74
+ field_name: str | None = None
75
+ replacement: str = DEFAULT_REPLACEMENT
76
+ to_orgs: list[str] = field(default_factory=lambda: [ANY_ORG])
77
+
78
+ def __post_init__(self):
79
+ if self.action not in ACTIONS:
80
+ raise ValueError(f"action inconnue : {self.action!r} (attendu {ACTIONS})")
81
+ if self.action in (REDACT, BLOCK) and not self.pattern:
82
+ raise ValueError(f"règle '{self.name}' : action {self.action} exige un `pattern`")
83
+ if self.action == DROP and not self.field_name:
84
+ raise ValueError(f"règle '{self.name}' : action drop exige un `field`")
85
+ self._compiled = re.compile(self.pattern, re.IGNORECASE) if self.pattern else None
86
+
87
+ def applies_to(self, target_org: str) -> bool:
88
+ return ANY_ORG in self.to_orgs or target_org in self.to_orgs
89
+
90
+ @classmethod
91
+ def from_dict(cls, raw: dict) -> "EgressRule":
92
+ return cls(
93
+ name=raw["name"],
94
+ action=raw["action"],
95
+ pattern=raw.get("pattern"),
96
+ field_name=raw.get("field"),
97
+ replacement=raw.get("replacement", DEFAULT_REPLACEMENT),
98
+ to_orgs=raw.get("to_orgs", [ANY_ORG]),
99
+ )
100
+
101
+
102
+ @dataclass
103
+ class EgressPolicy:
104
+ name: str = "empty"
105
+ rules: list[EgressRule] = field(default_factory=list)
106
+
107
+ def rules_for(self, target_org: str) -> list[EgressRule]:
108
+ return [r for r in self.rules if r.applies_to(target_org)]
109
+
110
+ @property
111
+ def is_empty(self) -> bool:
112
+ return not self.rules
113
+
114
+ @classmethod
115
+ def from_dict(cls, raw: dict) -> "EgressPolicy":
116
+ return cls(
117
+ name=raw.get("name", "unnamed"),
118
+ rules=[EgressRule.from_dict(r) for r in raw.get("rules", [])],
119
+ )
120
+
121
+ @classmethod
122
+ def load(cls, path: str | Path) -> "EgressPolicy":
123
+ return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
124
+
125
+
126
+ def apply_egress(payload: Any, target_org: str, policy: EgressPolicy | None
127
+ ) -> tuple[Any, list[str]]:
128
+ """Applique la politique de sortie à `payload`.
129
+
130
+ Retourne le contenu filtré et la liste des noms de règles déclenchées —
131
+ des *noms*, jamais les valeurs retirées : un journal d'audit ne doit pas
132
+ devenir l'endroit où fuit ce qu'on vient de caviarder.
133
+
134
+ Raises:
135
+ EgressBlocked: si une règle `block` correspond.
136
+ """
137
+ if policy is None or policy.is_empty:
138
+ return payload, []
139
+
140
+ rules = policy.rules_for(target_org)
141
+ if not rules:
142
+ return payload, []
143
+
144
+ triggered: list[str] = []
145
+
146
+ def visit(node: Any) -> Any:
147
+ if isinstance(node, dict):
148
+ out = {}
149
+ for key, value in node.items():
150
+ dropped = False
151
+ for rule in rules:
152
+ if rule.action == DROP and key.lower() == rule.field_name.lower():
153
+ if rule.name not in triggered:
154
+ triggered.append(rule.name)
155
+ dropped = True
156
+ break
157
+ if not dropped:
158
+ out[key] = visit(value)
159
+ return out
160
+
161
+ if isinstance(node, list):
162
+ return [visit(item) for item in node]
163
+
164
+ if isinstance(node, str):
165
+ text = node
166
+ for rule in rules:
167
+ if rule.action == BLOCK and rule._compiled.search(text):
168
+ raise EgressBlocked(rule.name, target_org)
169
+ for rule in rules:
170
+ if rule.action == REDACT:
171
+ replaced, count = rule._compiled.subn(rule.replacement, text)
172
+ if count:
173
+ text = replaced
174
+ if rule.name not in triggered:
175
+ triggered.append(rule.name)
176
+ return text
177
+
178
+ return node
179
+
180
+ return visit(payload), triggered
intermesh/escrow.py ADDED
@@ -0,0 +1,208 @@
1
+ """
2
+ InterMesh Escrow — séquestre inter-organisations pour les tâches payantes.
3
+
4
+ Portée volontairement limitée : ceci est le PROTOCOLE (états, règles,
5
+ traçabilité), pas une passerelle de paiement. Le `SimulatedLedger` tient des
6
+ soldes en mémoire, alimentés uniquement par `grant()` (un robinet de test/démo,
7
+ jamais une vraie source de fonds). Brancher un vrai mouvement d'argent (Stripe
8
+ Connect, stablecoins...) suppose de remplacer `SimulatedLedger` par un backend
9
+ qui parle à un processeur de paiement réel, en respectant la même interface
10
+ (`debit`/`credit`/`balance`) — volontairement pas fourni ici.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+ from enum import Enum
19
+ from typing import Dict, List, Optional, Tuple
20
+
21
+
22
+ class EscrowError(Exception):
23
+ """Séquestre impossible ou opération invalide sur un séquestre existant."""
24
+
25
+
26
+ class InsufficientFundsError(EscrowError):
27
+ """Solde insuffisant pour couvrir le montant demandé."""
28
+
29
+
30
+ class EscrowStatus(str, Enum):
31
+ HELD = "held"
32
+ RELEASED = "released"
33
+ REFUNDED = "refunded"
34
+
35
+
36
+ @dataclass
37
+ class EscrowHold:
38
+ hold_id: str
39
+ task_id: str
40
+ payer_org: str
41
+ payee_org: str
42
+ amount: float
43
+ currency: str
44
+ status: EscrowStatus
45
+ auto_release: bool
46
+ created_at: float
47
+ resolved_at: Optional[float] = None
48
+ metadata: dict = field(default_factory=dict)
49
+
50
+ def to_dict(self) -> dict:
51
+ return {
52
+ "hold_id": self.hold_id,
53
+ "task_id": self.task_id,
54
+ "payer_org": self.payer_org,
55
+ "payee_org": self.payee_org,
56
+ "amount": self.amount,
57
+ "currency": self.currency,
58
+ "status": self.status.value,
59
+ "auto_release": self.auto_release,
60
+ "created_at": self.created_at,
61
+ "resolved_at": self.resolved_at,
62
+ "metadata": self.metadata,
63
+ }
64
+
65
+
66
+ class SimulatedLedger:
67
+ """
68
+ Soldes en mémoire, par (organisation, devise).
69
+
70
+ `grant()` est le seul moyen de créer des fonds : un robinet explicite de
71
+ démonstration/test, jamais une source de vérité financière. Aucune
72
+ écriture ici ne représente un mouvement d'argent réel.
73
+ """
74
+
75
+ def __init__(self):
76
+ self._balances: Dict[Tuple[str, str], float] = {}
77
+
78
+ def balance(self, org_id: str, currency: str = "USD") -> float:
79
+ return self._balances.get((org_id, currency), 0.0)
80
+
81
+ def grant(self, org_id: str, amount: float, currency: str = "USD") -> float:
82
+ if amount <= 0:
83
+ raise EscrowError("Le montant crédité doit être positif.")
84
+ key = (org_id, currency)
85
+ self._balances[key] = self._balances.get(key, 0.0) + amount
86
+ return self._balances[key]
87
+
88
+ def debit(self, org_id: str, amount: float, currency: str = "USD") -> bool:
89
+ """Retourne False sans rien modifier si le solde est insuffisant."""
90
+ key = (org_id, currency)
91
+ current = self._balances.get(key, 0.0)
92
+ if current < amount:
93
+ return False
94
+ self._balances[key] = current - amount
95
+ return True
96
+
97
+ def credit(self, org_id: str, amount: float, currency: str = "USD") -> float:
98
+ key = (org_id, currency)
99
+ self._balances[key] = self._balances.get(key, 0.0) + amount
100
+ return self._balances[key]
101
+
102
+ def export_state(self) -> List[dict]:
103
+ """Soldes sérialisables — le tuple (org, devise) ne survit pas à JSON."""
104
+ return [
105
+ {"org_id": org, "currency": currency, "amount": amount}
106
+ for (org, currency), amount in sorted(self._balances.items())
107
+ ]
108
+
109
+ def import_state(self, rows: List[dict]) -> None:
110
+ """Remplace intégralement les soldes."""
111
+ self._balances = {
112
+ (r["org_id"], r["currency"]): float(r["amount"]) for r in rows
113
+ }
114
+
115
+
116
+ class EscrowManager:
117
+ """Un séquestre par tâche : créé à la soumission, résolu à l'issue de la tâche."""
118
+
119
+ def __init__(self, ledger: Optional[SimulatedLedger] = None):
120
+ self.ledger = ledger or SimulatedLedger()
121
+ self._holds: Dict[str, EscrowHold] = {} # task_id -> EscrowHold
122
+
123
+ def create_hold(
124
+ self,
125
+ task_id: str,
126
+ payer_org: str,
127
+ payee_org: str,
128
+ amount: float,
129
+ currency: str = "USD",
130
+ auto_release: bool = True,
131
+ metadata: Optional[dict] = None,
132
+ ) -> EscrowHold:
133
+ if not task_id:
134
+ raise EscrowError("task_id requis pour créer un séquestre.")
135
+ if amount <= 0:
136
+ raise EscrowError("Le montant séquestré doit être positif.")
137
+ if task_id in self._holds:
138
+ raise EscrowError(f"Un séquestre existe déjà pour la tâche '{task_id}'.")
139
+
140
+ if not self.ledger.debit(payer_org, amount, currency):
141
+ balance = self.ledger.balance(payer_org, currency)
142
+ raise InsufficientFundsError(
143
+ f"ESCROW_INSUFFICIENT_FUNDS: '{payer_org}' a {balance:.2f} {currency}, "
144
+ f"{amount:.2f} {currency} requis."
145
+ )
146
+
147
+ hold = EscrowHold(
148
+ hold_id=str(uuid.uuid4()), task_id=task_id, payer_org=payer_org, payee_org=payee_org,
149
+ amount=amount, currency=currency, status=EscrowStatus.HELD, auto_release=auto_release,
150
+ created_at=time.time(), metadata=metadata or {},
151
+ )
152
+ self._holds[task_id] = hold
153
+ return hold
154
+
155
+ def get(self, task_id: str) -> Optional[EscrowHold]:
156
+ return self._holds.get(task_id)
157
+
158
+ def list_holds(self, org_id: Optional[str] = None) -> List[EscrowHold]:
159
+ holds = list(self._holds.values())
160
+ if org_id:
161
+ holds = [h for h in holds if org_id in (h.payer_org, h.payee_org)]
162
+ return holds
163
+
164
+ def release(self, task_id: str) -> EscrowHold:
165
+ """Débloque les fonds vers le bénéficiaire (tâche livrée avec succès)."""
166
+ hold = self._require_held(task_id)
167
+ self.ledger.credit(hold.payee_org, hold.amount, hold.currency)
168
+ hold.status = EscrowStatus.RELEASED
169
+ hold.resolved_at = time.time()
170
+ return hold
171
+
172
+ def refund(self, task_id: str) -> EscrowHold:
173
+ """Rend les fonds au payeur (tâche échouée, annulée, ou litige tranché en sa faveur)."""
174
+ hold = self._require_held(task_id)
175
+ self.ledger.credit(hold.payer_org, hold.amount, hold.currency)
176
+ hold.status = EscrowStatus.REFUNDED
177
+ hold.resolved_at = time.time()
178
+ return hold
179
+
180
+ def export_state(self) -> dict:
181
+ """Séquestres et soldes, pour un instantané du Hub."""
182
+ return {
183
+ "ledger": self.ledger.export_state(),
184
+ "holds": [h.to_dict() for h in self._holds.values()],
185
+ }
186
+
187
+ def import_state(self, state: dict) -> None:
188
+ """Remplace intégralement séquestres et soldes."""
189
+ self.ledger.import_state(state.get("ledger") or [])
190
+ self._holds = {}
191
+ for raw in state.get("holds") or []:
192
+ hold = EscrowHold(
193
+ hold_id=raw["hold_id"], task_id=raw["task_id"],
194
+ payer_org=raw["payer_org"], payee_org=raw["payee_org"],
195
+ amount=float(raw["amount"]), currency=raw["currency"],
196
+ status=EscrowStatus(raw["status"]), auto_release=bool(raw["auto_release"]),
197
+ created_at=float(raw["created_at"]), resolved_at=raw.get("resolved_at"),
198
+ metadata=raw.get("metadata") or {},
199
+ )
200
+ self._holds[hold.task_id] = hold
201
+
202
+ def _require_held(self, task_id: str) -> EscrowHold:
203
+ hold = self._holds.get(task_id)
204
+ if hold is None:
205
+ raise EscrowError(f"Aucun séquestre pour la tâche '{task_id}'.")
206
+ if hold.status != EscrowStatus.HELD:
207
+ raise EscrowError(f"Séquestre déjà résolu ({hold.status.value}).")
208
+ return hold