swarmauri_mre_crypto_pgp 0.3.0.dev3__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.
@@ -0,0 +1,223 @@
1
+ """OpenPGP sealed-per-recipient MRE provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
7
+
8
+ from swarmauri_core.mre_crypto.types import MultiRecipientEnvelope, RecipientId, MreMode
9
+ from swarmauri_core.crypto.types import Alg, KeyRef
10
+ from swarmauri_base.mre_crypto.MreCryptoBase import MreCryptoBase
11
+ from swarmauri_base.ComponentBase import ComponentBase
12
+
13
+ try: # pragma: no cover - dependency optional at runtime
14
+ import pgpy # type: ignore
15
+
16
+ _PGP_OK = True
17
+ except Exception: # pragma: no cover
18
+ _PGP_OK = False
19
+
20
+
21
+ def _ensure_pgpy() -> None:
22
+ if not _PGP_OK: # pragma: no cover - env dependent
23
+ raise RuntimeError(
24
+ "PGPSealMreCrypto requires 'PGPy'. Install with: pip install pgpy"
25
+ )
26
+
27
+
28
+ def _load_pubkey(ref: KeyRef) -> "pgpy.PGPKey":
29
+ _ensure_pgpy()
30
+ if isinstance(ref, dict):
31
+ kind = ref.get("kind")
32
+ if kind == "pgpy_pub" and isinstance(ref.get("pub"), pgpy.PGPKey):
33
+ return ref["pub"]
34
+ if kind == "pgpy_pub_armored" and isinstance(ref.get("pub"), str):
35
+ k, _ = pgpy.PGPKey.from_blob(ref["pub"])
36
+ return k
37
+ raise TypeError("Unsupported recipient KeyRef for PGP public key.")
38
+
39
+
40
+ def _load_privkey(ref: KeyRef, passphrase: Optional[bytes | str]) -> "pgpy.PGPKey":
41
+ _ensure_pgpy()
42
+ if isinstance(ref, dict):
43
+ kind = ref.get("kind")
44
+ if kind == "pgpy_priv" and isinstance(ref.get("priv"), pgpy.PGPKey):
45
+ k: pgpy.PGPKey = ref["priv"]
46
+ elif kind == "pgpy_priv_armored" and isinstance(ref.get("priv"), str):
47
+ k, _ = pgpy.PGPKey.from_blob(ref["priv"])
48
+ else:
49
+ raise TypeError("Unsupported identity KeyRef for PGP private key.")
50
+ if not k.is_unlocked:
51
+ if passphrase is None:
52
+ raise RuntimeError(
53
+ "PGP private key is locked; supply opts['passphrase']."
54
+ )
55
+ k.unlock(passphrase)
56
+ return k
57
+ raise TypeError("Unsupported identity KeyRef for PGP private key.")
58
+
59
+
60
+ def _fingerprint_pub(k: "pgpy.PGPKey") -> str:
61
+ return str(k.fingerprint)
62
+
63
+
64
+ def _pgp_encrypt_bytes_for(pub: "pgpy.PGPKey", data: bytes) -> bytes:
65
+ """Encrypt bytes for a recipient using OpenPGP."""
66
+ _ensure_pgpy()
67
+ msg = pgpy.PGPMessage.new(base64.b64encode(data), file=False)
68
+ enc = pub.encrypt(msg)
69
+ return bytes(enc.__bytes__())
70
+
71
+
72
+ def _pgp_decrypt_bytes_with(priv: "pgpy.PGPKey", blob: bytes) -> bytes:
73
+ _ensure_pgpy()
74
+ msg = pgpy.PGPMessage.from_blob(blob)
75
+ dec = priv.decrypt(msg)
76
+ if isinstance(dec.message, str):
77
+ return base64.b64decode(dec.message.encode("utf-8"))
78
+ return base64.b64decode(dec.message)
79
+
80
+
81
+ def _make_sealed_recipient(rid: str, sealed_payload: bytes) -> Dict[str, Any]:
82
+ return {"id": rid, "sealed": sealed_payload}
83
+
84
+
85
+ @ComponentBase.register_type(MreCryptoBase, "PGPSealMreCrypto")
86
+ class PGPSealMreCrypto(MreCryptoBase):
87
+ """OpenPGP sealed-per-recipient MRE provider."""
88
+
89
+ type: str = "PGPSealMreCrypto"
90
+
91
+ def supports(self) -> Dict[str, Iterable[str | MreMode]]:
92
+ return {
93
+ "recipient": ("OpenPGP-SEAL",),
94
+ "modes": (MreMode.SEALED_PER_RECIPIENT,),
95
+ "features": (),
96
+ }
97
+
98
+ async def encrypt_for_many(
99
+ self,
100
+ recipients: Sequence[KeyRef],
101
+ pt: bytes,
102
+ *,
103
+ payload_alg: Optional[Alg] = None,
104
+ recipient_alg: Optional[Alg] = None,
105
+ mode: Optional[MreMode | str] = None,
106
+ aad: Optional[bytes] = None,
107
+ shared: Optional[Mapping[str, bytes]] = None,
108
+ opts: Optional[Mapping[str, object]] = None,
109
+ ) -> MultiRecipientEnvelope:
110
+ _ensure_pgpy()
111
+ m = (
112
+ MreMode(mode)
113
+ if isinstance(mode, str)
114
+ else (mode or MreMode.SEALED_PER_RECIPIENT)
115
+ )
116
+ if m != MreMode.SEALED_PER_RECIPIENT:
117
+ raise ValueError(
118
+ f"PGPSealMreCrypto supports only mode={MreMode.SEALED_PER_RECIPIENT.value}."
119
+ )
120
+ if aad is not None:
121
+ raise ValueError("AAD is not supported in sealed_per_recipient mode.")
122
+
123
+ pubs = [_load_pubkey(r) for r in recipients]
124
+ rids = [_fingerprint_pub(pk) for pk in pubs]
125
+ sealed_entries = [
126
+ _make_sealed_recipient(rid, _pgp_encrypt_bytes_for(pk, pt))
127
+ for rid, pk in zip(rids, pubs)
128
+ ]
129
+ env: MultiRecipientEnvelope = {
130
+ "mode": MreMode.SEALED_PER_RECIPIENT.value,
131
+ "recipient_alg": "OpenPGP-SEAL",
132
+ "payload": {"kind": "sealed_per_recipient"},
133
+ "recipients": sealed_entries,
134
+ }
135
+ if shared:
136
+ env["shared"] = dict(shared)
137
+ return env
138
+
139
+ async def open_for(
140
+ self,
141
+ my_identity: KeyRef,
142
+ env: MultiRecipientEnvelope,
143
+ *,
144
+ aad: Optional[bytes] = None,
145
+ opts: Optional[Mapping[str, object]] = None,
146
+ ) -> bytes:
147
+ _ensure_pgpy()
148
+ if env.get("mode") != MreMode.SEALED_PER_RECIPIENT.value:
149
+ raise ValueError("Envelope mode mismatch; expected sealed_per_recipient.")
150
+ if aad is not None:
151
+ raise ValueError("AAD is not supported in sealed_per_recipient mode.")
152
+
153
+ priv = _load_privkey(my_identity, (opts or {}).get("passphrase"))
154
+ my_id = str(priv.fingerprint)
155
+ entries: List[Dict[str, Any]] = env.get("recipients", [])
156
+ target = next((e for e in entries if e.get("id") == my_id), None)
157
+ if target:
158
+ return _pgp_decrypt_bytes_with(priv, target["sealed"])
159
+ for e in entries:
160
+ try:
161
+ return _pgp_decrypt_bytes_with(priv, e["sealed"])
162
+ except Exception:
163
+ continue
164
+ raise PermissionError("This identity cannot open the sealed envelope.")
165
+
166
+ async def open_for_many(
167
+ self,
168
+ my_identities: Sequence[KeyRef],
169
+ env: MultiRecipientEnvelope,
170
+ *,
171
+ aad: Optional[bytes] = None,
172
+ opts: Optional[Mapping[str, object]] = None,
173
+ ) -> bytes:
174
+ last_err: Optional[Exception] = None
175
+ for ident in my_identities:
176
+ try:
177
+ return await self.open_for(ident, env, aad=aad, opts=opts)
178
+ except Exception as e: # pragma: no cover - best effort
179
+ last_err = e
180
+ continue
181
+ raise last_err or PermissionError(
182
+ "None of the provided identities could open the envelope."
183
+ )
184
+
185
+ async def rewrap(
186
+ self,
187
+ env: MultiRecipientEnvelope,
188
+ *,
189
+ add: Optional[Sequence[KeyRef]] = None,
190
+ remove: Optional[Sequence[RecipientId]] = None,
191
+ recipient_alg: Optional[Alg] = None,
192
+ opts: Optional[Mapping[str, object]] = None,
193
+ ) -> MultiRecipientEnvelope:
194
+ _ensure_pgpy()
195
+ if env.get("mode") != MreMode.SEALED_PER_RECIPIENT.value:
196
+ raise ValueError("Envelope mode mismatch; expected sealed_per_recipient.")
197
+
198
+ add = add or ()
199
+ remove_ids = set(remove or ())
200
+ new_env: MultiRecipientEnvelope = {k: v for k, v in env.items()}
201
+ current_entries: List[Dict[str, Any]] = list(new_env.get("recipients", []))
202
+ if remove_ids:
203
+ current_entries = [
204
+ e for e in current_entries if e.get("id") not in remove_ids
205
+ ]
206
+ if add:
207
+ pt_bytes = (opts or {}).get("plaintext")
208
+ if not isinstance(pt_bytes, (bytes, bytearray)):
209
+ raise RuntimeError(
210
+ "Rewrap(add=...) in sealed_per_recipient mode requires opts['plaintext'] (bytes)."
211
+ )
212
+ pubs = [_load_pubkey(r) for r in add]
213
+ rids = [_fingerprint_pub(pk) for pk in pubs]
214
+ new_entries = [
215
+ _make_sealed_recipient(rid, _pgp_encrypt_bytes_for(pk, pt_bytes))
216
+ for rid, pk in zip(rids, pubs)
217
+ ]
218
+ remaining = [
219
+ e for e in current_entries if e["id"] not in {rid for rid in rids}
220
+ ]
221
+ current_entries = remaining + new_entries
222
+ new_env["recipients"] = current_entries
223
+ return new_env
@@ -0,0 +1,11 @@
1
+ """Package exports for the PGP MRE crypto providers."""
2
+
3
+ from .PGPSealMreCrypto import PGPSealMreCrypto
4
+ from .pgp_sealed_cek_mre import PGPSealedCekMreCrypto
5
+ from .pgp_mre import PGPMreCrypto
6
+
7
+ __all__ = [
8
+ "PGPSealMreCrypto",
9
+ "PGPSealedCekMreCrypto",
10
+ "PGPMreCrypto",
11
+ ]
@@ -0,0 +1,457 @@
1
+ """PGPMreCrypto: OpenPGP-backed MRE provider supporting multiple modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ from typing import (
8
+ Any,
9
+ Dict,
10
+ Iterable,
11
+ List,
12
+ Literal,
13
+ Mapping,
14
+ Optional,
15
+ Sequence,
16
+ Tuple,
17
+ )
18
+
19
+ from swarmauri_core.mre_crypto.types import MultiRecipientEnvelope, RecipientId, MreMode
20
+ from swarmauri_core.crypto.types import Alg, KeyRef
21
+ from swarmauri_base.mre_crypto.MreCryptoBase import MreCryptoBase
22
+ from swarmauri_base.ComponentBase import ComponentBase
23
+
24
+ try: # pragma: no cover - dependency optional at runtime
25
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
26
+
27
+ _CRYPTOGRAPHY_OK = True
28
+ except Exception: # pragma: no cover
29
+ _CRYPTOGRAPHY_OK = False
30
+
31
+ try: # pragma: no cover - dependency optional at runtime
32
+ import pgpy
33
+
34
+ _PGP_OK = True
35
+ except Exception: # pragma: no cover
36
+ _PGP_OK = False
37
+
38
+
39
+ def _ensure_crypto() -> None:
40
+ if not _CRYPTOGRAPHY_OK:
41
+ raise RuntimeError(
42
+ "PGPMreCrypto requires 'cryptography'. Install with: pip install cryptography"
43
+ )
44
+
45
+
46
+ def _ensure_pgpy() -> None:
47
+ if not _PGP_OK:
48
+ raise RuntimeError(
49
+ "PGPMreCrypto requires 'PGPy'. Install with: pip install pgpy"
50
+ )
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # PGP helpers
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def _load_pubkey(ref: KeyRef) -> "pgpy.PGPKey":
59
+ _ensure_pgpy()
60
+ if isinstance(ref, dict):
61
+ kind = ref.get("kind")
62
+ if kind == "pgpy_pub" and isinstance(ref.get("pub"), pgpy.PGPKey):
63
+ return ref["pub"]
64
+ if kind == "pgpy_pub_armored" and isinstance(ref.get("pub"), str):
65
+ k, _ = pgpy.PGPKey.from_blob(ref["pub"])
66
+ return k
67
+ raise TypeError("Unsupported recipient KeyRef for PGP public key.")
68
+
69
+
70
+ def _load_privkey(ref: KeyRef, passphrase: Optional[bytes | str]) -> "pgpy.PGPKey":
71
+ _ensure_pgpy()
72
+ if isinstance(ref, dict):
73
+ kind = ref.get("kind")
74
+ if kind == "pgpy_priv" and isinstance(ref.get("priv"), pgpy.PGPKey):
75
+ k: pgpy.PGPKey = ref["priv"]
76
+ elif kind == "pgpy_priv_armored" and isinstance(ref.get("priv"), str):
77
+ k, _ = pgpy.PGPKey.from_blob(ref["priv"])
78
+ else:
79
+ raise TypeError("Unsupported identity KeyRef for PGP private key.")
80
+ if not k.is_unlocked:
81
+ if passphrase is None:
82
+ raise RuntimeError(
83
+ "PGP private key is locked; supply opts['passphrase']."
84
+ )
85
+ k.unlock(passphrase)
86
+ return k
87
+ raise TypeError("Unsupported identity KeyRef for PGP private key.")
88
+
89
+
90
+ def _fingerprint_pub(k: "pgpy.PGPKey") -> str:
91
+ return str(k.fingerprint)
92
+
93
+
94
+ def _pgp_encrypt_bytes_for(pub: "pgpy.PGPKey", data: bytes) -> bytes:
95
+ _ensure_pgpy()
96
+ msg = pgpy.PGPMessage.new(base64.b64encode(data), file=False)
97
+ enc = pub.encrypt(msg)
98
+ return bytes(enc.__bytes__())
99
+
100
+
101
+ def _pgp_decrypt_bytes_with(priv: "pgpy.PGPKey", blob: bytes) -> bytes:
102
+ _ensure_pgpy()
103
+ msg = pgpy.PGPMessage.from_blob(blob)
104
+ dec = priv.decrypt(msg)
105
+ if isinstance(dec.message, str):
106
+ return base64.b64decode(dec.message.encode("utf-8"))
107
+ return base64.b64decode(dec.message)
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # AEAD helpers
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def _aead_encrypt_gcm(
116
+ cek: bytes, pt: bytes, *, aad: Optional[bytes]
117
+ ) -> Tuple[bytes, bytes, bytes]:
118
+ _ensure_crypto()
119
+ if len(cek) != 32:
120
+ raise ValueError("AES-256-GCM requires a 32-byte CEK.")
121
+ aead = AESGCM(cek)
122
+ nonce = os.urandom(12)
123
+ cttag = aead.encrypt(nonce, pt, aad)
124
+ return nonce, cttag[:-16], cttag[-16:]
125
+
126
+
127
+ def _aead_decrypt_gcm(
128
+ cek: bytes, nonce: bytes, ct: bytes, tag: bytes, *, aad: Optional[bytes]
129
+ ) -> bytes:
130
+ _ensure_crypto()
131
+ aead = AESGCM(cek)
132
+ return aead.decrypt(nonce, ct + tag, aad)
133
+
134
+
135
+ def _make_aead_payload(
136
+ payload_alg: str, nonce: bytes, ct: bytes, tag: bytes, aad: Optional[bytes]
137
+ ) -> Dict[str, Any]:
138
+ return {
139
+ "kind": "aead",
140
+ "alg": payload_alg,
141
+ "nonce": nonce,
142
+ "ct": ct,
143
+ "tag": tag,
144
+ "aad": aad,
145
+ }
146
+
147
+
148
+ def _make_recipient_header(rid: str, header: bytes) -> Dict[str, Any]:
149
+ return {"id": rid, "header": header}
150
+
151
+
152
+ def _make_sealed_recipient(rid: str, sealed_payload: bytes) -> Dict[str, Any]:
153
+ return {"id": rid, "sealed": sealed_payload}
154
+
155
+
156
+ @ComponentBase.register_type(MreCryptoBase, "PGPMreCrypto")
157
+ class PGPMreCrypto(MreCryptoBase):
158
+ """OpenPGP-backed multi-recipient encryption provider."""
159
+
160
+ type: Literal["PGPMreCrypto"] = "PGPMreCrypto"
161
+ default_payload_alg: str = "AES-256-GCM"
162
+ default_recipient_alg: str = "OpenPGP"
163
+
164
+ def supports(
165
+ self,
166
+ ) -> Dict[str, Iterable[str | MreMode]]: # pragma: no cover - trivial
167
+ modes: Tuple[str | MreMode, ...] = (
168
+ MreMode.ENC_ONCE_HEADERS,
169
+ MreMode.SEALED_PER_RECIPIENT,
170
+ )
171
+ return {
172
+ "payload": (self.default_payload_alg,),
173
+ "recipient": ("OpenPGP", "OpenPGP-SEAL"),
174
+ "modes": modes,
175
+ "features": ("aad", "rewrap_without_reencrypt"),
176
+ }
177
+
178
+ async def encrypt_for_many(
179
+ self,
180
+ recipients: Sequence[KeyRef],
181
+ pt: bytes,
182
+ *,
183
+ payload_alg: Optional[Alg] = None,
184
+ recipient_alg: Optional[Alg] = None,
185
+ mode: Optional[MreMode | str] = None,
186
+ aad: Optional[bytes] = None,
187
+ shared: Optional[Mapping[str, bytes]] = None,
188
+ opts: Optional[Mapping[str, object]] = None,
189
+ ) -> MultiRecipientEnvelope:
190
+ _ensure_pgpy()
191
+
192
+ m = (
193
+ MreMode(mode)
194
+ if isinstance(mode, str)
195
+ else (mode or MreMode.ENC_ONCE_HEADERS)
196
+ )
197
+ payload_alg = payload_alg or self.default_payload_alg
198
+ recipient_alg = recipient_alg or self.default_recipient_alg
199
+
200
+ pubs = [_load_pubkey(r) for r in recipients]
201
+ rids = [_fingerprint_pub(pk) for pk in pubs]
202
+
203
+ if m == MreMode.ENC_ONCE_HEADERS:
204
+ if payload_alg != "AES-256-GCM":
205
+ raise ValueError(
206
+ "PGPMreCrypto currently supports only AES-256-GCM for shared payload."
207
+ )
208
+ cek = os.urandom(32)
209
+ nonce, ct, tag = _aead_encrypt_gcm(cek, pt, aad=aad)
210
+ headers = [
211
+ _make_recipient_header(rid, _pgp_encrypt_bytes_for(pk, cek))
212
+ for rid, pk in zip(rids, pubs)
213
+ ]
214
+ env: MultiRecipientEnvelope = {
215
+ "mode": MreMode.ENC_ONCE_HEADERS.value,
216
+ "payload_alg": payload_alg,
217
+ "recipient_alg": "OpenPGP",
218
+ "payload": _make_aead_payload(payload_alg, nonce, ct, tag, aad),
219
+ "recipients": headers,
220
+ }
221
+ if shared:
222
+ env["shared"] = dict(shared)
223
+ return env
224
+
225
+ if m == MreMode.SEALED_PER_RECIPIENT:
226
+ sealed_entries = [
227
+ _make_sealed_recipient(rid, _pgp_encrypt_bytes_for(pk, pt))
228
+ for rid, pk in zip(rids, pubs)
229
+ ]
230
+ env = {
231
+ "mode": MreMode.SEALED_PER_RECIPIENT.value,
232
+ "recipient_alg": "OpenPGP-SEAL",
233
+ "payload": {"kind": "sealed_per_recipient"},
234
+ "recipients": sealed_entries,
235
+ }
236
+ if shared:
237
+ env["shared"] = dict(shared)
238
+ return env
239
+
240
+ raise ValueError(f"Unsupported mode: {mode}")
241
+
242
+ async def open_for(
243
+ self,
244
+ my_identity: KeyRef,
245
+ env: MultiRecipientEnvelope,
246
+ *,
247
+ aad: Optional[bytes] = None,
248
+ opts: Optional[Mapping[str, object]] = None,
249
+ ) -> bytes:
250
+ _ensure_pgpy()
251
+ mode_str = env.get("mode")
252
+ m = MreMode(mode_str) if isinstance(mode_str, str) else mode_str
253
+ priv = _load_privkey(my_identity, (opts or {}).get("passphrase"))
254
+
255
+ if m == MreMode.ENC_ONCE_HEADERS:
256
+ my_id = str(priv.fingerprint)
257
+ headers: List[Dict[str, Any]] = env.get("recipients", [])
258
+ target = next((h for h in headers if h.get("id") == my_id), None)
259
+
260
+ cek: Optional[bytes] = None
261
+ if target:
262
+ cek = _pgp_decrypt_bytes_with(priv, target["header"])
263
+ else:
264
+ for h in headers:
265
+ try:
266
+ cek = _pgp_decrypt_bytes_with(priv, h["header"])
267
+ break
268
+ except Exception:
269
+ continue
270
+ if cek is None:
271
+ raise PermissionError("This identity cannot open the envelope.")
272
+
273
+ payload = env["payload"]
274
+ if payload.get("alg") != "AES-256-GCM":
275
+ raise ValueError("Unsupported payload_alg for PGPMreCrypto open.")
276
+ nonce, ct, tag = payload["nonce"], payload["ct"], payload["tag"]
277
+ bound_aad = payload.get("aad", None)
278
+ if aad is not None and bound_aad is not None and aad != bound_aad:
279
+ raise ValueError("AAD mismatch.")
280
+ return _aead_decrypt_gcm(cek, nonce, ct, tag, aad=bound_aad)
281
+
282
+ if m == MreMode.SEALED_PER_RECIPIENT:
283
+ my_id = str(priv.fingerprint)
284
+ entries: List[Dict[str, Any]] = env.get("recipients", [])
285
+ target = next((e for e in entries if e.get("id") == my_id), None)
286
+ if not target:
287
+ for e in entries:
288
+ try:
289
+ return _pgp_decrypt_bytes_with(priv, e["sealed"])
290
+ except Exception:
291
+ continue
292
+ raise PermissionError("This identity cannot open the sealed envelope.")
293
+ return _pgp_decrypt_bytes_with(priv, target["sealed"])
294
+
295
+ raise ValueError(f"Unsupported mode: {mode_str}")
296
+
297
+ async def open_for_many(
298
+ self,
299
+ my_identities: Sequence[KeyRef],
300
+ env: MultiRecipientEnvelope,
301
+ *,
302
+ aad: Optional[bytes] = None,
303
+ opts: Optional[Mapping[str, object]] = None,
304
+ ) -> bytes:
305
+ last_err: Optional[Exception] = None
306
+ for ident in my_identities:
307
+ try:
308
+ return await self.open_for(ident, env, aad=aad, opts=opts)
309
+ except Exception as e: # pragma: no cover
310
+ last_err = e
311
+ continue
312
+ raise last_err or PermissionError(
313
+ "None of the provided identities could open the envelope."
314
+ )
315
+
316
+ async def rewrap(
317
+ self,
318
+ env: MultiRecipientEnvelope,
319
+ *,
320
+ add: Optional[Sequence[KeyRef]] = None,
321
+ remove: Optional[Sequence[RecipientId]] = None,
322
+ recipient_alg: Optional[Alg] = None,
323
+ opts: Optional[Mapping[str, object]] = None,
324
+ ) -> MultiRecipientEnvelope:
325
+ _ensure_pgpy()
326
+ mode_str = env.get("mode")
327
+ m = MreMode(mode_str) if isinstance(mode_str, str) else mode_str
328
+
329
+ add = add or ()
330
+ remove_ids = set(remove or ())
331
+
332
+ new_env: MultiRecipientEnvelope = {k: v for k, v in env.items()}
333
+
334
+ if m == MreMode.ENC_ONCE_HEADERS:
335
+ current_headers: List[Dict[str, Any]] = list(new_env.get("recipients", []))
336
+ if remove_ids:
337
+ current_headers = [
338
+ h for h in current_headers if h.get("id") not in remove_ids
339
+ ]
340
+
341
+ cek: Optional[bytes] = None
342
+ if add:
343
+ if opts and isinstance(opts.get("cek"), (bytes, bytearray)):
344
+ cek = bytes(opts["cek"])
345
+ elif opts and opts.get("manage_key"):
346
+ manage_key = _load_privkey(
347
+ opts["manage_key"], (opts or {}).get("passphrase")
348
+ )
349
+ candidate_headers = current_headers or list(
350
+ env.get("recipients", [])
351
+ )
352
+ for h in candidate_headers:
353
+ try:
354
+ cek = _pgp_decrypt_bytes_with(manage_key, h["header"])
355
+ break
356
+ except Exception:
357
+ continue
358
+ else:
359
+ raise RuntimeError(
360
+ "Rewrap(add=...) requires opts['cek'] or opts['manage_key']."
361
+ )
362
+
363
+ if add and cek is not None:
364
+ pubs = [_load_pubkey(r) for r in add]
365
+ rids = [_fingerprint_pub(pk) for pk in pubs]
366
+ new_headers = [
367
+ _make_recipient_header(rid, _pgp_encrypt_bytes_for(pk, cek))
368
+ for rid, pk in zip(rids, pubs)
369
+ ]
370
+ merged: List[Dict[str, Any]] = []
371
+ for h in current_headers:
372
+ if h["id"] not in {rid for rid in rids}:
373
+ merged.append(h)
374
+ merged.extend(new_headers)
375
+ current_headers = merged
376
+
377
+ rotate = bool(opts.get("rotate_payload_on_revoke")) if opts else False
378
+ if remove_ids and rotate:
379
+ if not cek:
380
+ if opts and opts.get("manage_key"):
381
+ manage_key = _load_privkey(
382
+ opts["manage_key"], (opts or {}).get("passphrase")
383
+ )
384
+ for h in env.get("recipients", []):
385
+ try:
386
+ cek = _pgp_decrypt_bytes_with(manage_key, h["header"])
387
+ break
388
+ except Exception:
389
+ continue
390
+ if not cek:
391
+ raise RuntimeError(
392
+ "rotate_payload_on_revoke requires CEK via opts['manage_key'] or opts['cek']."
393
+ )
394
+
395
+ new_cek = os.urandom(32)
396
+ payload = env["payload"]
397
+ old_pt = _aead_decrypt_gcm(
398
+ cek,
399
+ payload["nonce"],
400
+ payload["ct"],
401
+ payload["tag"],
402
+ aad=payload.get("aad"),
403
+ )
404
+ nonce, ct, tag = _aead_encrypt_gcm(
405
+ new_cek, old_pt, aad=payload.get("aad")
406
+ )
407
+ new_env["payload"] = _make_aead_payload(
408
+ env.get("payload_alg", self.default_payload_alg),
409
+ nonce,
410
+ ct,
411
+ tag,
412
+ payload.get("aad"),
413
+ )
414
+
415
+ add_pubkeys = (opts or {}).get("add_pubkeys")
416
+ if not isinstance(add_pubkeys, (list, tuple)) or not add_pubkeys:
417
+ raise RuntimeError(
418
+ "After rotation, provide opts['add_pubkeys'] = [KeyRef(pub=...), ...] for remaining recipients."
419
+ )
420
+ pubs = [_load_pubkey(r) for r in add_pubkeys]
421
+ rids = [_fingerprint_pub(pk) for pk in pubs]
422
+ new_headers = [
423
+ _make_recipient_header(rid, _pgp_encrypt_bytes_for(pk, new_cek))
424
+ for rid, pk in zip(rids, pubs)
425
+ ]
426
+ current_headers = new_headers
427
+
428
+ new_env["recipients"] = current_headers
429
+ return new_env
430
+
431
+ if m == MreMode.SEALED_PER_RECIPIENT:
432
+ current_entries: List[Dict[str, Any]] = list(new_env.get("recipients", []))
433
+ if remove_ids:
434
+ current_entries = [
435
+ e for e in current_entries if e.get("id") not in remove_ids
436
+ ]
437
+ if add:
438
+ pt_bytes = (opts or {}).get("plaintext")
439
+ if not isinstance(pt_bytes, (bytes, bytearray)):
440
+ raise RuntimeError(
441
+ "Rewrap(add=...) in sealed_per_recipient mode requires opts['plaintext']."
442
+ )
443
+ pubs = [_load_pubkey(r) for r in add]
444
+ rids = [_fingerprint_pub(pk) for pk in pubs]
445
+ new_entries = [
446
+ _make_sealed_recipient(rid, _pgp_encrypt_bytes_for(pk, pt_bytes))
447
+ for rid, pk in zip(rids, pubs)
448
+ ]
449
+ remaining = [
450
+ e for e in current_entries if e["id"] not in {rid for rid in rids}
451
+ ]
452
+ current_entries = remaining + new_entries
453
+
454
+ new_env["recipients"] = current_entries
455
+ return new_env
456
+
457
+ raise ValueError(f"Unsupported mode for rewrap: {mode_str}")