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/__init__.py +0 -14
- tigrbl_kms/__main__.py +23 -0
- tigrbl_kms/app.py +49 -0
- tigrbl_kms/cli.py +30 -0
- tigrbl_kms/orm/__init__.py +4 -0
- tigrbl_kms/orm/key.py +692 -0
- tigrbl_kms/orm/key_version.py +139 -0
- tigrbl_kms/utils.py +150 -0
- tigrbl_kms-0.3.0.dev3.dist-info/LICENSE +201 -0
- tigrbl_kms-0.3.0.dev3.dist-info/METADATA +137 -0
- tigrbl_kms-0.3.0.dev3.dist-info/RECORD +13 -0
- tigrbl_kms-0.3.0.dev3.dist-info/entry_points.txt +3 -0
- tigrbl_kms/ExampleAgent.py +0 -1
- tigrbl_kms-0.0.1.dev1.dist-info/METADATA +0 -18
- tigrbl_kms-0.0.1.dev1.dist-info/RECORD +0 -5
- {tigrbl_kms-0.0.1.dev1.dist-info → tigrbl_kms-0.3.0.dev3.dist-info}/WHEEL +0 -0
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)
|