tigrbl-kms 0.0.1.dev1__py3-none-any.whl → 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.
tigrbl_kms/orm/key.py ADDED
@@ -0,0 +1,692 @@
1
+ # tigrbl_kms/orm/key.py
2
+ from __future__ import annotations
3
+ import base64
4
+ from enum import Enum
5
+ from uuid import UUID, uuid4
6
+ from typing import List, Optional, TYPE_CHECKING
7
+
8
+ from sqlalchemy import String, Integer, Enum as SAEnum
9
+ from tigrbl.types import Mapped, relationship
10
+ from tigrbl.orm.mixins import BulkCapable, Replaceable
11
+
12
+ from tigrbl.orm.tables import Base
13
+ from tigrbl.specs import acol, vcol, S, F, IO
14
+ from tigrbl.hook import hook_ctx
15
+ from tigrbl.op import op_ctx
16
+ from fastapi import HTTPException, Response
17
+
18
+ if TYPE_CHECKING:
19
+ from .key_version import KeyVersion
20
+
21
+ # Prefer PG UUID type class; fall back to String class
22
+ try:
23
+ from sqlalchemy.dialects.postgresql import UUID as PGUUID
24
+
25
+ _UUID_TYPE = PGUUID # type CLASS (binder instantiates)
26
+ except Exception:
27
+ _UUID_TYPE = String # type CLASS
28
+
29
+
30
+ class KeyAlg(str, Enum):
31
+ AES256_GCM = "AES256_GCM"
32
+ CHACHA20_POLY1305 = "CHACHA20_POLY1305"
33
+ RSA2048 = "RSA2048"
34
+ RSA3072 = "RSA3072"
35
+
36
+
37
+ def _alg_to_provider(alg: KeyAlg | str) -> Optional[str]:
38
+ """Translate a :class:`KeyAlg` into the algorithm string expected by crypto providers."""
39
+ alg_val = alg.value if isinstance(alg, KeyAlg) else alg
40
+ if alg_val == KeyAlg.AES256_GCM.value:
41
+ return "AES-256-GCM"
42
+ if alg_val in (KeyAlg.RSA2048.value, KeyAlg.RSA3072.value):
43
+ return None
44
+ return alg_val
45
+
46
+
47
+ class KeyStatus(str, Enum):
48
+ enabled = "enabled"
49
+ disabled = "disabled"
50
+
51
+
52
+ class Key(Base, BulkCapable, Replaceable):
53
+ __tablename__ = "keys"
54
+ __resource__ = "key"
55
+ __allow_unmapped__ = True # allow vcol attributes
56
+
57
+ # Persisted columns (py_type inferred from annotation; SA dtype via StorageSpec.type_)
58
+ id: Mapped[UUID] = acol(
59
+ storage=S(
60
+ type_=_UUID_TYPE,
61
+ primary_key=True,
62
+ index=True,
63
+ nullable=False,
64
+ default=uuid4,
65
+ ),
66
+ io=IO(out_verbs=("read", "list"), sortable=True),
67
+ )
68
+
69
+ name: Mapped[str] = acol(
70
+ storage=S(type_=String(120), unique=True, index=True, nullable=False),
71
+ field=F(constraints={"max_length": 120}, required_in=("create",)),
72
+ io=IO(
73
+ in_verbs=("create", "update", "replace"),
74
+ out_verbs=("read", "list"),
75
+ sortable=True,
76
+ filter_ops=("eq", "ilike"),
77
+ ),
78
+ )
79
+
80
+ # in Key model, for enums:
81
+ algorithm = acol(
82
+ storage=S(type_=SAEnum, nullable=False),
83
+ field=F(py_type=KeyAlg, required_in=("create",)), # <— explicit
84
+ io=IO(in_verbs=("create",), out_verbs=("read", "list")),
85
+ )
86
+ status = acol(
87
+ storage=S(type_=SAEnum, nullable=False, default=KeyStatus.enabled),
88
+ field=F(py_type=KeyStatus), # <— explicit
89
+ io=IO(
90
+ in_verbs=("update",),
91
+ out_verbs=("read", "list"),
92
+ filter_ops=("eq",),
93
+ sortable=True,
94
+ ),
95
+ )
96
+
97
+ primary_version: Mapped[int] = acol(
98
+ storage=S(type_=Integer, nullable=False, default=1),
99
+ io=IO(out_verbs=("read", "list")), # read-only exposure
100
+ )
101
+
102
+ # Relationship
103
+ versions: Mapped[List["KeyVersion"]] = relationship(
104
+ back_populates="key", lazy="selectin", cascade="all, delete-orphan"
105
+ )
106
+
107
+ # Virtual (wire-only)
108
+ kid: Optional[str] = vcol(
109
+ io=IO(out_verbs=("encrypt", "wrap")),
110
+ read_producer=lambda obj, ctx: str(getattr(obj, "id", "")),
111
+ )
112
+
113
+ plaintext_b64: Optional[str] = vcol(
114
+ field=F(required_in=("encrypt",)),
115
+ io=IO(in_verbs=("encrypt",), out_verbs=("decrypt",)),
116
+ )
117
+
118
+ aad_b64: Optional[str] = vcol(
119
+ field=F(allow_null_in=("encrypt", "decrypt")),
120
+ io=IO(
121
+ in_verbs=("encrypt", "decrypt", "wrap", "unwrap"),
122
+ out_verbs=("encrypt", "wrap", "unwrap"),
123
+ ),
124
+ )
125
+
126
+ nonce_b64: Optional[str] = vcol(
127
+ field=F(required_in=("decrypt", "unwrap"), allow_null_in=("encrypt", "wrap")),
128
+ io=IO(in_verbs=("encrypt", "decrypt", "unwrap"), out_verbs=("encrypt", "wrap")),
129
+ )
130
+
131
+ alg: Optional[KeyAlg] = vcol(
132
+ field=F(py_type=KeyAlg, allow_null_in=("encrypt", "decrypt", "wrap", "unwrap")),
133
+ io=IO(
134
+ in_verbs=("encrypt", "decrypt", "wrap", "unwrap"),
135
+ out_verbs=("encrypt", "wrap"),
136
+ ),
137
+ )
138
+
139
+ ciphertext_b64: Optional[str] = vcol(
140
+ field=F(required_in=("decrypt",)),
141
+ io=IO(in_verbs=("decrypt",), out_verbs=("encrypt",)),
142
+ )
143
+
144
+ tag_b64: Optional[str] = vcol(
145
+ field=F(allow_null_in=("encrypt", "decrypt", "wrap", "unwrap")),
146
+ io=IO(in_verbs=("decrypt", "unwrap"), out_verbs=("encrypt", "wrap")),
147
+ )
148
+
149
+ version: Optional[int] = vcol(
150
+ field=F(py_type=int),
151
+ io=IO(out_verbs=("encrypt", "wrap")),
152
+ )
153
+
154
+ # ---- Key Wrapping virtual columns ----
155
+ key_material_b64: Mapped[Optional[str]] = vcol(
156
+ field=F(required_in=("wrap",)),
157
+ io=IO(in_verbs=("wrap",), out_verbs=("unwrap",)),
158
+ )
159
+
160
+ wrapped_key_b64: Mapped[Optional[str]] = vcol(
161
+ field=F(required_in=("unwrap",)),
162
+ io=IO(in_verbs=("unwrap",), out_verbs=("wrap",)),
163
+ )
164
+
165
+ # ---- Hook: seed key material on create ----
166
+ @hook_ctx(ops=("create", "bulk_create"), phase="POST_HANDLER")
167
+ async def _seed_primary_version(cls, ctx):
168
+ import secrets
169
+ from .key_version import KeyVersion
170
+
171
+ db = ctx.get("db")
172
+ result = ctx.get("result")
173
+ if db is None or result is None:
174
+ raise HTTPException(status_code=500, detail="DB session missing")
175
+
176
+ keys = result if isinstance(result, list) else [result]
177
+
178
+ for key_obj in keys:
179
+ if key_obj.algorithm != KeyAlg.AES256_GCM:
180
+ continue # only symmetric keys supported for now
181
+
182
+ existing = await KeyVersion.handlers.list.core(
183
+ {
184
+ "db": db,
185
+ "payload": {
186
+ "filters": {
187
+ "key_id": key_obj.id,
188
+ "version": key_obj.primary_version,
189
+ }
190
+ },
191
+ }
192
+ )
193
+ if not existing:
194
+ material = secrets.token_bytes(32)
195
+ kv = KeyVersion(
196
+ key_id=key_obj.id,
197
+ version=key_obj.primary_version,
198
+ status="active",
199
+ public_material=material,
200
+ )
201
+ db.add(kv)
202
+
203
+ @hook_ctx(
204
+ ops=("create", "read", "list", "update", "replace", "wrap", "unwrap"),
205
+ phase="POST_RESPONSE",
206
+ )
207
+ async def _scrub_version_material(cls, ctx):
208
+ obj = ctx.get("result")
209
+ if obj is None:
210
+ return
211
+
212
+ def scrub(o):
213
+ from fastapi import Response as _FastAPIResponse
214
+
215
+ if isinstance(o, _FastAPIResponse):
216
+ return o
217
+ if isinstance(o, dict):
218
+ data = dict(o)
219
+ elif hasattr(o, "model_dump") and callable(getattr(o, "model_dump")):
220
+ data = o.model_dump()
221
+ elif hasattr(o, "dict") and callable(getattr(o, "dict")):
222
+ data = o.dict() # type: ignore[call-arg]
223
+ elif hasattr(o, "__dict__") and not isinstance(o, type):
224
+ data = {k: v for k, v in vars(o).items() if not k.startswith("_")}
225
+ else:
226
+ try:
227
+ data = dict(o)
228
+ except Exception:
229
+ return o
230
+
231
+ if isinstance(data, dict):
232
+ data.pop("versions", None)
233
+ from fastapi.encoders import jsonable_encoder
234
+
235
+ cleaned: dict = {}
236
+ for k, v in data.items():
237
+ try:
238
+ cleaned[k] = jsonable_encoder(v, sqlalchemy_safe=True)
239
+ except Exception:
240
+ cleaned[k] = str(v)
241
+ return cleaned
242
+ return data
243
+
244
+ if isinstance(obj, list):
245
+ ctx["result"] = [scrub(i) for i in obj]
246
+ else:
247
+ ctx["result"] = scrub(obj)
248
+
249
+ # ---- Hook: ensure key exists & enabled ----
250
+ @hook_ctx(
251
+ ops=("encrypt", "decrypt", "wrap", "unwrap", "rotate"), phase="PRE_HANDLER"
252
+ )
253
+ async def _ensure_key_enabled(cls, ctx):
254
+ pp = ctx.get("path_params") or {}
255
+ ident = pp.get("id") or pp.get("item_id")
256
+ if not ident:
257
+ raise HTTPException(status_code=400, detail="Missing key identifier")
258
+ try:
259
+ ident = ident if isinstance(ident, UUID) else UUID(str(ident))
260
+ except Exception:
261
+ raise HTTPException(status_code=422, detail="Invalid UUID for key id")
262
+
263
+ db = ctx.get("db")
264
+ if db is None:
265
+ raise HTTPException(status_code=500, detail="DB session missing")
266
+ # works with sync or async session
267
+ getter = getattr(db, "get", None)
268
+ obj = (
269
+ await getter(cls, ident)
270
+ if callable(getter)
271
+ and getattr(getter, "__code__", None)
272
+ and getter.__code__.co_flags & 0x80
273
+ else db.get(cls, ident)
274
+ )
275
+ if obj is None:
276
+ raise HTTPException(status_code=404, detail="Key not found")
277
+ if obj.status == KeyStatus.disabled:
278
+ raise HTTPException(status_code=403, detail="Key is disabled")
279
+ ctx["key"] = obj
280
+
281
+ # ---- Ops: ctx-only crypto (no DB writes) ----
282
+ @op_ctx(
283
+ alias="encrypt",
284
+ target="custom",
285
+ arity="member", # /key/{item_id}/encrypt
286
+ persist="skip",
287
+ )
288
+ async def encrypt(cls, ctx):
289
+ from ..utils import b64d, b64d_optional
290
+
291
+ p = ctx.get("payload") or {}
292
+ crypto = getattr(
293
+ getattr(ctx.get("request"), "state", object()), "crypto", None
294
+ ) or ctx.get("crypto")
295
+ if crypto is None:
296
+ raise HTTPException(status_code=500, detail="Crypto provider missing")
297
+
298
+ import binascii
299
+
300
+ try:
301
+ aad = b64d_optional(p.get("aad_b64"))
302
+ except binascii.Error as exc: # pragma: no cover - defensive
303
+ raise HTTPException(
304
+ status_code=400, detail="Invalid base64 encoding for aad_b64"
305
+ ) from exc
306
+ try:
307
+ nonce = b64d_optional(p.get("nonce_b64"))
308
+ except binascii.Error as exc: # pragma: no cover - defensive
309
+ raise HTTPException(
310
+ status_code=400, detail="Invalid base64 encoding for nonce_b64"
311
+ ) from exc
312
+ try:
313
+ pt = b64d(p["plaintext_b64"])
314
+ except binascii.Error as exc: # pragma: no cover - defensive
315
+ raise HTTPException(
316
+ status_code=400, detail="Invalid base64 encoding for plaintext_b64"
317
+ ) from exc
318
+ kid = str(ctx["key"].id)
319
+ alg_in = p.get("alg") or ctx["key"].algorithm
320
+ alg_enum = alg_in if isinstance(alg_in, KeyAlg) else KeyAlg(alg_in)
321
+ alg_str = _alg_to_provider(alg_enum)
322
+
323
+ import inspect
324
+ from swarmauri_core.crypto.types import (
325
+ ExportPolicy,
326
+ KeyRef,
327
+ KeyType,
328
+ KeyUse,
329
+ )
330
+
331
+ try:
332
+ inspect.signature(crypto.encrypt).parameters["kid"]
333
+ except KeyError:
334
+ key_obj = ctx["key"]
335
+ version = next(
336
+ (v for v in key_obj.versions if v.version == key_obj.primary_version),
337
+ None,
338
+ )
339
+ if version is None or version.public_material is None:
340
+ raise HTTPException(status_code=500, detail="Key material missing")
341
+ key_ref = KeyRef(
342
+ kid=kid,
343
+ version=key_obj.primary_version,
344
+ type=KeyType.SYMMETRIC,
345
+ uses=(KeyUse.ENCRYPT, KeyUse.DECRYPT),
346
+ export_policy=ExportPolicy.SECRET_WHEN_ALLOWED,
347
+ material=bytes(version.public_material),
348
+ )
349
+ res = await crypto.encrypt(
350
+ key_ref,
351
+ pt,
352
+ alg=alg_str,
353
+ aad=aad,
354
+ nonce=nonce,
355
+ )
356
+ else:
357
+ res = await crypto.encrypt(
358
+ kid=kid, plaintext=pt, alg=alg_str, aad=aad, nonce=nonce
359
+ )
360
+
361
+ return {
362
+ "kid": kid,
363
+ "version": getattr(res, "version", ctx["key"].primary_version),
364
+ "alg": alg_enum,
365
+ "nonce_b64": base64.b64encode(getattr(res, "nonce")).decode(),
366
+ "ciphertext_b64": base64.b64encode(getattr(res, "ct")).decode(),
367
+ "tag_b64": (
368
+ base64.b64encode(getattr(res, "tag")).decode()
369
+ if getattr(res, "tag", None)
370
+ else None
371
+ ),
372
+ "aad_b64": p.get("aad_b64"),
373
+ }
374
+
375
+ @op_ctx(
376
+ alias="decrypt",
377
+ target="custom",
378
+ arity="member", # /key/{item_id}/decrypt
379
+ persist="skip",
380
+ )
381
+ async def decrypt(cls, ctx):
382
+ from ..utils import b64d, b64d_optional
383
+
384
+ p = ctx.get("payload") or {}
385
+ crypto = getattr(
386
+ getattr(ctx.get("request"), "state", object()), "crypto", None
387
+ ) or ctx.get("crypto")
388
+ if crypto is None:
389
+ raise HTTPException(status_code=500, detail="Crypto provider missing")
390
+
391
+ import binascii
392
+
393
+ try:
394
+ aad = b64d_optional(p.get("aad_b64"))
395
+ except binascii.Error as exc: # pragma: no cover - defensive
396
+ raise HTTPException(
397
+ status_code=400, detail="Invalid base64 encoding for aad_b64"
398
+ ) from exc
399
+ try:
400
+ nonce = b64d(p["nonce_b64"])
401
+ except binascii.Error as exc: # pragma: no cover - defensive
402
+ raise HTTPException(
403
+ status_code=400, detail="Invalid base64 encoding for nonce_b64"
404
+ ) from exc
405
+ try:
406
+ ct = b64d(p["ciphertext_b64"])
407
+ except binascii.Error as exc: # pragma: no cover - defensive
408
+ raise HTTPException(
409
+ status_code=400, detail="Invalid base64 encoding for ciphertext_b64"
410
+ ) from exc
411
+ try:
412
+ tag = b64d_optional(p.get("tag_b64"))
413
+ except binascii.Error as exc: # pragma: no cover - defensive
414
+ raise HTTPException(
415
+ status_code=400, detail="Invalid base64 encoding for tag_b64"
416
+ ) from exc
417
+ kid = str(ctx["key"].id)
418
+ alg_in = p.get("alg") or ctx["key"].algorithm
419
+ alg_enum = alg_in if isinstance(alg_in, KeyAlg) else KeyAlg(alg_in)
420
+ alg_str = _alg_to_provider(alg_enum)
421
+
422
+ import inspect
423
+ from swarmauri_core.crypto.types import (
424
+ AEADCiphertext,
425
+ ExportPolicy,
426
+ KeyRef,
427
+ KeyType,
428
+ KeyUse,
429
+ )
430
+
431
+ try:
432
+ inspect.signature(crypto.decrypt).parameters["kid"]
433
+ except KeyError:
434
+ key_obj = ctx["key"]
435
+ version = next(
436
+ (v for v in key_obj.versions if v.version == key_obj.primary_version),
437
+ None,
438
+ )
439
+ if version is None or version.public_material is None:
440
+ raise HTTPException(status_code=500, detail="Key material missing")
441
+ key_ref = KeyRef(
442
+ kid=kid,
443
+ version=key_obj.primary_version,
444
+ type=KeyType.SYMMETRIC,
445
+ uses=(KeyUse.DECRYPT, KeyUse.ENCRYPT),
446
+ export_policy=ExportPolicy.SECRET_WHEN_ALLOWED,
447
+ material=bytes(version.public_material),
448
+ )
449
+ ct_obj = AEADCiphertext(
450
+ kid=kid,
451
+ version=key_obj.primary_version,
452
+ alg=alg_str,
453
+ nonce=nonce,
454
+ ct=ct,
455
+ tag=tag or b"",
456
+ aad=aad,
457
+ )
458
+ pt = await crypto.decrypt(key_ref, ct_obj, aad=aad)
459
+ else:
460
+ pt = await crypto.decrypt(
461
+ kid=kid, ciphertext=ct, nonce=nonce, tag=tag, aad=aad, alg=alg_str
462
+ )
463
+
464
+ return {"plaintext_b64": base64.b64encode(pt).decode()}
465
+
466
+ @op_ctx(
467
+ alias="wrap",
468
+ target="custom",
469
+ arity="member", # /key/{item_id}/wrap
470
+ persist="skip",
471
+ )
472
+ async def wrap(cls, ctx):
473
+ """Wrap (encrypt) key material using this key."""
474
+ from ..utils import b64d, b64d_optional
475
+
476
+ p = ctx.get("payload") or {}
477
+ crypto = getattr(
478
+ getattr(ctx.get("request"), "state", object()), "crypto", None
479
+ ) or ctx.get("crypto")
480
+ if crypto is None:
481
+ raise HTTPException(status_code=500, detail="Crypto provider missing")
482
+
483
+ import binascii
484
+
485
+ # Validate and decode the key material to be wrapped
486
+ try:
487
+ key_material = b64d(p["key_material_b64"])
488
+ except binascii.Error as exc:
489
+ raise HTTPException(
490
+ status_code=400, detail="Invalid base64 encoding for key_material_b64"
491
+ ) from exc
492
+ except KeyError:
493
+ raise HTTPException(status_code=400, detail="key_material_b64 is required")
494
+
495
+ # Optional AAD for key wrapping context
496
+ try:
497
+ aad = b64d_optional(p.get("aad_b64"))
498
+ except binascii.Error as exc:
499
+ raise HTTPException(
500
+ status_code=400, detail="Invalid base64 encoding for aad_b64"
501
+ ) from exc
502
+
503
+ kid = str(ctx["key"].id)
504
+ key_obj = ctx["key"]
505
+ if key_obj.status != KeyStatus.enabled:
506
+ raise HTTPException(status_code=403, detail="Key is disabled")
507
+ if key_obj.algorithm not in (KeyAlg.AES256_GCM, KeyAlg.CHACHA20_POLY1305):
508
+ raise HTTPException(
509
+ status_code=400,
510
+ detail="Key wrapping only supported for AES256_GCM and CHACHA20_POLY1305",
511
+ )
512
+ alg_str = _alg_to_provider(key_obj.algorithm)
513
+
514
+ from swarmauri_core.crypto.types import (
515
+ ExportPolicy,
516
+ KeyRef,
517
+ KeyType,
518
+ KeyUse,
519
+ )
520
+
521
+ version = next(
522
+ (v for v in key_obj.versions if v.version == key_obj.primary_version),
523
+ None,
524
+ )
525
+ if version is None or version.public_material is None:
526
+ raise HTTPException(status_code=500, detail="Key material missing")
527
+
528
+ key_ref = KeyRef(
529
+ kid=kid,
530
+ version=key_obj.primary_version,
531
+ type=KeyType.SYMMETRIC,
532
+ uses=(KeyUse.WRAP, KeyUse.UNWRAP),
533
+ export_policy=ExportPolicy.SECRET_WHEN_ALLOWED,
534
+ material=bytes(version.public_material),
535
+ )
536
+
537
+ try:
538
+ ct = await crypto.encrypt(
539
+ key_ref,
540
+ key_material,
541
+ alg=alg_str,
542
+ nonce=None,
543
+ aad=aad,
544
+ )
545
+ except Exception as exc:
546
+ raise HTTPException(status_code=500, detail="Key wrapping failed") from exc
547
+
548
+ return {
549
+ "kid": kid,
550
+ "version": ct.version,
551
+ "alg": key_obj.algorithm,
552
+ "nonce_b64": base64.b64encode(ct.nonce).decode(),
553
+ "wrapped_key_b64": base64.b64encode(ct.ct).decode(),
554
+ "tag_b64": base64.b64encode(ct.tag).decode(),
555
+ "aad_b64": p.get("aad_b64"),
556
+ }
557
+
558
+ @op_ctx(
559
+ alias="unwrap",
560
+ target="custom",
561
+ arity="member", # /key/{item_id}/unwrap
562
+ persist="skip",
563
+ )
564
+ async def unwrap(cls, ctx):
565
+ """Unwrap (decrypt) wrapped key material using this key."""
566
+ from ..utils import b64d, b64d_optional
567
+
568
+ p = ctx.get("payload") or {}
569
+ crypto = getattr(
570
+ getattr(ctx.get("request"), "state", object()), "crypto", None
571
+ ) or ctx.get("crypto")
572
+ if crypto is None:
573
+ raise HTTPException(status_code=500, detail="Crypto provider missing")
574
+
575
+ import binascii
576
+
577
+ # Validate and decode required fields
578
+ try:
579
+ wrapped_key = b64d(p["wrapped_key_b64"])
580
+ except binascii.Error as exc:
581
+ raise HTTPException(
582
+ status_code=400, detail="Invalid base64 encoding for wrapped_key_b64"
583
+ ) from exc
584
+ except KeyError:
585
+ raise HTTPException(status_code=422, detail="wrapped_key_b64 is required")
586
+
587
+ try:
588
+ nonce = b64d(p["nonce_b64"])
589
+ except binascii.Error as exc:
590
+ raise HTTPException(
591
+ status_code=400, detail="Invalid base64 encoding for nonce_b64"
592
+ ) from exc
593
+ except KeyError:
594
+ raise HTTPException(status_code=422, detail="nonce_b64 is required")
595
+
596
+ try:
597
+ tag = b64d_optional(p.get("tag_b64"))
598
+ except binascii.Error as exc:
599
+ raise HTTPException(
600
+ status_code=400, detail="Invalid base64 encoding for tag_b64"
601
+ ) from exc
602
+
603
+ try:
604
+ aad = b64d_optional(p.get("aad_b64"))
605
+ except binascii.Error as exc:
606
+ raise HTTPException(
607
+ status_code=400, detail="Invalid base64 encoding for aad_b64"
608
+ ) from exc
609
+
610
+ kid = str(ctx["key"].id)
611
+ key_obj = ctx["key"]
612
+ if key_obj.status != KeyStatus.enabled:
613
+ raise HTTPException(status_code=403, detail="Key is disabled")
614
+ alg_str = _alg_to_provider(key_obj.algorithm)
615
+
616
+ from swarmauri_core.crypto.types import (
617
+ AEADCiphertext,
618
+ ExportPolicy,
619
+ KeyRef,
620
+ KeyType,
621
+ KeyUse,
622
+ )
623
+
624
+ version = next(
625
+ (v for v in key_obj.versions if v.version == key_obj.primary_version),
626
+ None,
627
+ )
628
+ if version is None or version.public_material is None:
629
+ raise HTTPException(status_code=500, detail="Key material missing")
630
+
631
+ key_ref = KeyRef(
632
+ kid=kid,
633
+ version=key_obj.primary_version,
634
+ type=KeyType.SYMMETRIC,
635
+ uses=(KeyUse.UNWRAP, KeyUse.WRAP),
636
+ export_policy=ExportPolicy.SECRET_WHEN_ALLOWED,
637
+ material=bytes(version.public_material),
638
+ )
639
+
640
+ if tag is None:
641
+ raise HTTPException(status_code=422, detail="tag_b64 is required")
642
+
643
+ ct = AEADCiphertext(
644
+ kid=kid,
645
+ version=key_obj.primary_version,
646
+ alg=alg_str or "",
647
+ nonce=nonce,
648
+ ct=wrapped_key,
649
+ tag=tag,
650
+ aad=aad,
651
+ )
652
+
653
+ try:
654
+ key_material = await crypto.decrypt(key_ref, ct, aad=aad)
655
+ except Exception as exc:
656
+ raise HTTPException(
657
+ status_code=500, detail="Key unwrapping failed"
658
+ ) from exc
659
+
660
+ return {"key_material_b64": base64.b64encode(key_material).decode()}
661
+
662
+ @op_ctx(
663
+ alias="rotate",
664
+ target="custom",
665
+ arity="member", # /key/{item_id}/rotate
666
+ status_code=201,
667
+ )
668
+ async def rotate(cls, ctx):
669
+ import secrets
670
+ from .key_version import KeyVersion
671
+
672
+ db = ctx.get("db")
673
+ key_obj = ctx.get("key")
674
+ if db is None or key_obj is None:
675
+ raise HTTPException(status_code=500, detail="Required context missing")
676
+ if key_obj.algorithm != KeyAlg.AES256_GCM:
677
+ raise HTTPException(status_code=400, detail="Unsupported algorithm")
678
+
679
+ new_version = key_obj.primary_version + 1
680
+ material = secrets.token_bytes(32)
681
+ kv = KeyVersion(
682
+ key_id=key_obj.id,
683
+ version=new_version,
684
+ status="active",
685
+ public_material=material,
686
+ )
687
+ key_obj.primary_version = new_version
688
+ db.add(kv)
689
+
690
+ @hook_ctx(ops="rotate", phase="POST_RESPONSE")
691
+ async def _rotate_empty_body(cls, ctx):
692
+ ctx["result"] = Response(status_code=201)