flechtctl 0.16.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.
flechtctl/keyring.py ADDED
@@ -0,0 +1,474 @@
1
+ """The `Keyring` value object — pure key material, no cryptography, no joserfc.
2
+
3
+ A key is named by a `kid` and comes in one of three kinds: a 32-byte
4
+ symmetric (oct) key in `keys`, an X25519 recipient key in `recipients` (its
5
+ public half seals, its private half opens), or an Ed25519 signing key in
6
+ `signers` (its private half signs, its public half verifies). The `Keyring` is
7
+ the small value object holding a few of them plus which one is `primary` (used
8
+ to encrypt). Any key may be **bound** to the scopes it serves (`scopes`);
9
+ public-key material must be, because holding a public half is enough to seal
10
+ or to check, and an unbound one would serve every compartment. Sealing and
11
+ signing keys are chosen per scope explicitly (`primaries`, `signing`). The
12
+ keyring parses from — and its file form is — an RFC 7517 JWK Set with a
13
+ `primary` extension member (plus the optional `flenc_primaries`,
14
+ `flenc_signing` and per-key `flenc_scopes` members), so the same document
15
+ loads in joserfc, panva jose, and Nimbus.
16
+
17
+ The module holds no process-global state: the crypto functions in
18
+ `flechtctl.tokens` take the keyring explicitly. A keyring file can also be
19
+ password-protected (an encrypted JWK Set); `is_encrypted_keyring` recognizes
20
+ one, and `flechtctl.tokens.decrypt_keyring` opens it.
21
+ """
22
+ import base64
23
+ import json
24
+ from dataclasses import dataclass, field, replace
25
+
26
+ __all__ = [
27
+ "Ed25519Key",
28
+ "KEY_BYTES",
29
+ "Keyring",
30
+ "KeyringError",
31
+ "UnknownKeyError",
32
+ "X25519Key",
33
+ "is_encrypted_keyring",
34
+ "keyring_from_jwk",
35
+ ]
36
+
37
+ KEY_BYTES = 32
38
+ """AES-256 key length — the only key size this framework mints or accepts.
39
+
40
+ X25519 and Ed25519 key halves happen to be 32 bytes too.
41
+ """
42
+
43
+
44
+ class KeyringError(Exception):
45
+ """Base class for keyring problems (construction, install, key lookup)."""
46
+
47
+
48
+ class UnknownKeyError(KeyringError):
49
+ """A token names a `kid` absent from the active keyring.
50
+
51
+ In an incident this is the "the key is missing here" signature, distinct
52
+ from a GCM tag failure (same `kid` bound to different bytes).
53
+ """
54
+
55
+ def __init__(self, kid: str) -> None:
56
+ super().__init__(f"no key {kid!r} in the active keyring")
57
+ self.kid = kid
58
+
59
+
60
+ def _decode_key(raw: str, member: str = "k") -> bytes:
61
+ """Decode an unpadded base64url JWK member (`k`, `x` or `d`) to raw key bytes.
62
+
63
+ Validates strictly: `urlsafe_b64decode` alone (validate off) silently
64
+ scrubs stray characters and returns wrong-length bytes; here a malformed
65
+ value raises `ValueError` (`binascii.Error`) so `from_json` reports it as a
66
+ `KeyringError` about the base64, not a confusing wrong-length key error.
67
+ """
68
+ if not isinstance(raw, str):
69
+ raise ValueError(f"{member!r} must be a base64url string, got {type(raw).__name__}")
70
+ standard = raw.translate(_B64URL_TO_STD)
71
+ return base64.b64decode(standard + "=" * (-len(raw) % 4), validate=True)
72
+
73
+
74
+ _B64URL_TO_STD = str.maketrans("-_", "+/")
75
+
76
+
77
+ def _encode_key(raw: bytes) -> str:
78
+ """Encode raw key bytes as an unpadded base64url JWK member."""
79
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Ed25519Key:
84
+ """An Ed25519 signing key: `private` (the 32-byte seed) signs, `public` verifies.
85
+
86
+ `private=None` makes a verify-only key — what a reader holds.
87
+ """
88
+
89
+ public: bytes
90
+ private: bytes | None = field(default=None, repr=False)
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class X25519Key:
95
+ """An X25519 recipient key: `public` seals, `private` opens.
96
+
97
+ `private=None` makes a seal-only key — what a writer holds.
98
+ """
99
+
100
+ public: bytes
101
+ private: bytes | None = field(default=None, repr=False)
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class Keyring:
106
+ """The keys and which one is primary — pure key material.
107
+
108
+ Build via `Keyring.of(...)` (raw bytes) or `Keyring.from_json(...)` (a
109
+ JWK Set document). Both validate, raising `KeyringError` naming the
110
+ offending kid or scope:
111
+
112
+ 1. at least one key in `keys` or `recipients`;
113
+ 2. every kid is a non-empty string, held in only one of `keys`,
114
+ `recipients` and `signers`;
115
+ 3. every oct key is 32 bytes, every recipient / signing key has a 32-byte
116
+ `public` and a `private` that is `None` or 32 bytes;
117
+ 4. `primary` names a key in `keys` or `recipients`;
118
+ 5. every kid in `scopes` is held, bound to a non-empty set of non-empty
119
+ scope names;
120
+ 6. every recipient and signing kid is bound — public-key material always
121
+ is; an oct key may be or not;
122
+ 7. every `primaries` entry maps a non-empty scope to a kid in `keys` or
123
+ `recipients` whose binding, if any, covers that scope;
124
+ 8. every `signing` entry maps a non-empty scope to a signing kid holding a
125
+ `private` half, whose binding covers that scope.
126
+ """
127
+
128
+ keys: dict[str, bytes]
129
+ primary: str
130
+ primaries: dict[str, str] = field(default_factory=dict)
131
+ recipients: dict[str, X25519Key] = field(default_factory=dict)
132
+ scopes: dict[str, frozenset[str]] = field(default_factory=dict)
133
+ signers: dict[str, Ed25519Key] = field(default_factory=dict)
134
+ signing: dict[str, str] = field(default_factory=dict)
135
+
136
+ def __post_init__(self) -> None:
137
+ object.__setattr__(self, "scopes", {
138
+ kid: bound if isinstance(bound, str) else frozenset(bound) for kid, bound in self.scopes.items()
139
+ })
140
+ if not self.keys and not self.recipients:
141
+ raise KeyringError("a keyring needs at least one key")
142
+ kinds = (self.keys, self.recipients, self.signers)
143
+ held = [kid for kind in kinds for kid in kind]
144
+ for kid in held:
145
+ if not isinstance(kid, str) or not kid:
146
+ raise KeyringError("a key id must be a non-empty string")
147
+ if held.count(kid) > 1:
148
+ raise KeyringError(f"kid {kid!r} is held as more than one kind of key")
149
+ for kid, key in self.keys.items():
150
+ if len(key) != KEY_BYTES:
151
+ raise KeyringError(f"key {kid!r} is {len(key)} bytes, expected {KEY_BYTES} (AES-256)")
152
+ for kind, pairs in (("X25519", self.recipients), ("Ed25519", self.signers)):
153
+ for kid, pair in pairs.items():
154
+ if len(pair.public) != KEY_BYTES:
155
+ raise KeyringError(f"{kind} key {kid!r} has a {len(pair.public)}-byte public half, expected {KEY_BYTES}")
156
+ if pair.private is not None and len(pair.private) != KEY_BYTES:
157
+ raise KeyringError(f"{kind} key {kid!r} has a {len(pair.private)}-byte private half, expected {KEY_BYTES}")
158
+ if self.primary not in self.keys and self.primary not in self.recipients:
159
+ raise KeyringError(
160
+ f"primary {self.primary!r} is not among the keys {sorted(self.keys) + sorted(self.recipients)}"
161
+ )
162
+ for kid, bound in self.scopes.items():
163
+ if kid not in held:
164
+ raise KeyringError(f"scopes name kid {kid!r}, which the keyring does not hold")
165
+ if (not isinstance(bound, frozenset) or not bound
166
+ or not all(isinstance(scope, str) and scope for scope in bound)):
167
+ raise KeyringError(f"key {kid!r} must be bound to a non-empty set of non-empty scope names")
168
+ for kid in [*self.recipients, *self.signers]:
169
+ if kid not in self.scopes:
170
+ raise KeyringError(f"public-key kid {kid!r} must be bound to the scopes it serves")
171
+ for scope, kid in self.primaries.items():
172
+ if not isinstance(scope, str) or not scope:
173
+ raise KeyringError(f"primaries map an empty scope to {kid!r}")
174
+ if kid not in self.keys and kid not in self.recipients:
175
+ raise KeyringError(f"primaries map scope {scope!r} to {kid!r}, which is not a sealing key")
176
+ if kid in self.scopes and scope not in self.scopes[kid]:
177
+ raise KeyringError(f"primaries map scope {scope!r} to {kid!r}, which is not bound to it")
178
+ for scope, kid in self.signing.items():
179
+ if not isinstance(scope, str) or not scope:
180
+ raise KeyringError(f"signing maps an empty scope to {kid!r}")
181
+ if kid not in self.signers or self.signers[kid].private is None:
182
+ raise KeyringError(f"signing maps scope {scope!r} to {kid!r}, which is not a private signing key")
183
+ if scope not in self.scopes[kid]:
184
+ raise KeyringError(f"signing maps scope {scope!r} to {kid!r}, which is not bound to it")
185
+
186
+ @classmethod
187
+ def of(
188
+ cls,
189
+ keys: dict[str, bytes],
190
+ *,
191
+ primary: str,
192
+ primaries: dict[str, str] | None = None,
193
+ recipients: dict[str, X25519Key] | None = None,
194
+ scopes: dict[str, set[str] | frozenset[str]] | None = None,
195
+ signers: dict[str, Ed25519Key] | None = None,
196
+ signing: dict[str, str] | None = None,
197
+ ) -> "Keyring":
198
+ """Build a keyring from raw key bytes — the programmatic / test entry point."""
199
+ return cls(
200
+ keys=dict(keys),
201
+ primary=primary,
202
+ primaries=dict(primaries or {}),
203
+ recipients=dict(recipients or {}),
204
+ scopes={kid: bound if isinstance(bound, str) else frozenset(bound) for kid, bound in (scopes or {}).items()},
205
+ signers=dict(signers or {}),
206
+ signing=dict(signing or {}),
207
+ )
208
+
209
+ @classmethod
210
+ def from_json(cls, text: str) -> "Keyring":
211
+ """Parse an RFC 7517 JWK Set with a `primary` extension member.
212
+
213
+ Accepts oct keys with an unpadded-base64url `k`, and OKP keys with
214
+ `crv` `X25519` (recipients) or `Ed25519` (signers), a public `x` and an
215
+ optional private `d`; every key needs a `kid`. A key's optional
216
+ `flenc_scopes` member (a non-empty array of scope names) binds it —
217
+ required for OKP keys. Two optional top-level members map scopes to
218
+ kids: `flenc_primaries` (the sealing key per scope) and `flenc_signing`
219
+ (the signing key per scope). Unknown members (top-level and per-key)
220
+ are ignored, per JWK. This is the deployment entry point — the
221
+ application reads the document from wherever it lives (a mounted
222
+ secret, a file, an env var it reads itself) and hands the text in.
223
+ """
224
+ if is_encrypted_keyring(text):
225
+ raise KeyringError(
226
+ "keyring document is password-protected — open it with flechtctl (decrypt_keyring) "
227
+ "or write a plain copy with flechtctl keyring unprotect"
228
+ )
229
+ try:
230
+ doc = json.loads(text)
231
+ except json.JSONDecodeError as e:
232
+ raise KeyringError(f"keyring document is not valid JSON: {e}") from e
233
+ if not isinstance(doc, dict):
234
+ raise KeyringError(f"keyring document must be a JSON object, got {type(doc).__name__}")
235
+ raw_keys = doc.get("keys", [])
236
+ if not isinstance(raw_keys, list):
237
+ raise KeyringError("keyring 'keys' must be a JSON array")
238
+ keys: dict[str, bytes] = {}
239
+ recipients: dict[str, X25519Key] = {}
240
+ scopes: dict[str, frozenset[str]] = {}
241
+ signers: dict[str, Ed25519Key] = {}
242
+ for jwk in raw_keys:
243
+ kind, kid, material, bound = _parse_jwk(jwk, held=frozenset([*keys, *recipients, *signers]))
244
+ if bound is not None:
245
+ scopes[kid] = bound
246
+ if kind == "oct":
247
+ keys[kid] = material
248
+ elif kind == "X25519":
249
+ recipients[kid] = material
250
+ else:
251
+ signers[kid] = material
252
+ if "primary" not in doc:
253
+ raise KeyringError("keyring document has no top-level 'primary' member")
254
+ return cls(
255
+ keys=keys,
256
+ primary=doc["primary"],
257
+ primaries=_parse_scope_map(doc, "flenc_primaries"),
258
+ recipients=recipients,
259
+ scopes=scopes,
260
+ signers=signers,
261
+ signing=_parse_scope_map(doc, "flenc_signing"),
262
+ )
263
+
264
+ def to_json(self) -> str:
265
+ """The inverse of `from_json`: this keyring as an RFC 7517 JWK Set document.
266
+
267
+ Keys sorted by `kid`, members sorted, two-space indent, newline-terminated;
268
+ `Keyring.from_json(k.to_json()) == k` for every valid keyring. The private
269
+ halves are included — this is the file form, not a shareable one (see `public_jwk`).
270
+ """
271
+ jwks = [
272
+ {"k": _encode_key(key), "kid": kid, "kty": "oct"} for kid, key in self.keys.items()
273
+ ] + [
274
+ _okp_jwk("X25519", kid, key) for kid, key in self.recipients.items()
275
+ ] + [
276
+ _okp_jwk("Ed25519", kid, key) for kid, key in self.signers.items()
277
+ ]
278
+ for jwk in jwks:
279
+ if jwk["kid"] in self.scopes:
280
+ jwk["flenc_scopes"] = sorted(self.scopes[jwk["kid"]])
281
+ doc: dict = {"keys": sorted(jwks, key=lambda jwk: jwk["kid"]), "primary": self.primary}
282
+ if self.primaries:
283
+ doc["flenc_primaries"] = dict(self.primaries)
284
+ if self.signing:
285
+ doc["flenc_signing"] = dict(self.signing)
286
+ return json.dumps(doc, indent=2, sort_keys=True) + "\n"
287
+
288
+ def public_jwk(self, kid: str) -> dict:
289
+ """The public half of entry `kid` as one JWK — never the private half.
290
+
291
+ What `flechtctl keyring export` prints: hand it to the party that should be
292
+ able to seal (an X25519 recipient) or to check signatures (an Ed25519
293
+ signer). The keyring itself is unchanged. An oct entry has no public half.
294
+ """
295
+ if kid in self.keys:
296
+ raise KeyringError(f"key {kid!r} is a symmetric (oct) key and has no public half")
297
+ if kid in self.recipients:
298
+ jwk = _okp_jwk("X25519", kid, X25519Key(public=self.recipients[kid].public))
299
+ elif kid in self.signers:
300
+ jwk = _okp_jwk("Ed25519", kid, Ed25519Key(public=self.signers[kid].public))
301
+ else:
302
+ raise UnknownKeyError(kid)
303
+ jwk["flenc_scopes"] = sorted(self.scopes[kid])
304
+ return jwk
305
+
306
+ def with_jwk(self, jwk: dict) -> "Keyring":
307
+ """A new keyring with one more entry, parsed from a single JWK.
308
+
309
+ The private half is kept if the JWK carries one, and so are its scopes.
310
+ A kid already held raises `KeyringError`; the constructor's validation runs.
311
+ """
312
+ kind, kid, material, bound = _parse_jwk(jwk, held=frozenset(self.kids()))
313
+ return replace(
314
+ self,
315
+ keys={**self.keys, kid: material} if kind == "oct" else self.keys,
316
+ recipients={**self.recipients, kid: material} if kind == "X25519" else self.recipients,
317
+ scopes={**self.scopes, kid: bound} if bound is not None else self.scopes,
318
+ signers={**self.signers, kid: material} if kind == "Ed25519" else self.signers,
319
+ )
320
+
321
+ def key_for(self, kid: str) -> bytes:
322
+ """Return the oct key bytes for `kid`, or raise `UnknownKeyError`.
323
+
324
+ A recipient or signing kid is unknown here: this lookup serves `dir`
325
+ tokens only.
326
+ """
327
+ try:
328
+ return self.keys[kid]
329
+ except KeyError:
330
+ raise UnknownKeyError(kid) from None
331
+
332
+ def recipient_for(self, kid: str) -> X25519Key:
333
+ """Return the X25519 recipient key for `kid`, or raise `UnknownKeyError`."""
334
+ try:
335
+ return self.recipients[kid]
336
+ except KeyError:
337
+ raise UnknownKeyError(kid) from None
338
+
339
+ def signer_for(self, kid: str) -> Ed25519Key:
340
+ """Return the Ed25519 signing key for `kid`, or raise `UnknownKeyError`."""
341
+ try:
342
+ return self.signers[kid]
343
+ except KeyError:
344
+ raise UnknownKeyError(kid) from None
345
+
346
+ def primary_pair(self) -> tuple[str, bytes]:
347
+ """Return `(primary_kid, primary_key_bytes)` — what `dir` encryption stamps and uses.
348
+
349
+ Raises `KeyringError` when the primary is a recipient key, which has no
350
+ symmetric bytes to hand out.
351
+ """
352
+ if self.primary in self.recipients:
353
+ raise KeyringError(f"primary {self.primary!r} is a recipient key, not a symmetric one")
354
+ return self.primary, self.keys[self.primary]
355
+
356
+ def sealing_kid(self, scope: str) -> str:
357
+ """The kid that seals a value of `scope`: `primaries[scope]`, else `primary`.
358
+
359
+ Raises `KeyringError` when that kid is bound and `scope` is outside its
360
+ binding — an unscoped value (`scope == ""`) is outside every binding.
361
+ """
362
+ kid = self.primaries.get(scope, self.primary)
363
+ bound = self.scopes.get(kid)
364
+ if bound is not None and scope not in bound:
365
+ what = f"scope {scope!r}" if scope else "an unscoped value"
366
+ raise KeyringError(f"key {kid!r} is bound to scopes {sorted(bound)} and cannot seal {what}")
367
+ return kid
368
+
369
+ def signing_kid(self, scope: str) -> str:
370
+ """The kid that signs a value of `scope`, or raise `KeyringError` when none is configured."""
371
+ try:
372
+ return self.signing[scope]
373
+ except KeyError:
374
+ raise KeyringError(f"no signing key for scope {scope!r} in the active keyring") from None
375
+
376
+ def kids(self) -> list[str]:
377
+ """Loaded key ids of every kind, sorted — the labels for the startup keyring gauge."""
378
+ return sorted([*self.keys, *self.recipients, *self.signers])
379
+
380
+
381
+ def is_encrypted_keyring(text: str) -> bool:
382
+ """Whether `text` is a password-protected keyring: a compact JWE whose `cty` is `jwk-set+json`.
383
+
384
+ Never raises — anything else (a plain JWK Set, garbage, an ordinary token) is `False`.
385
+ """
386
+ try:
387
+ segments = text.strip().split(".")
388
+ if len(segments) != 5:
389
+ return False
390
+ header = json.loads(_decode_key(segments[0], "header"))
391
+ return isinstance(header, dict) and header.get("cty") == "jwk-set+json"
392
+ except (AttributeError, TypeError, ValueError): # JSONDecodeError and binascii.Error ⊂ ValueError
393
+ return False
394
+
395
+
396
+ def keyring_from_jwk(jwk: dict) -> Keyring:
397
+ """A one-entry keyring whose `primary` is that entry — a new keyring file made from an imported JWK.
398
+
399
+ An Ed25519 entry cannot be primary, so the constructor's own validation rejects it.
400
+ """
401
+ kind, kid, material, bound = _parse_jwk(jwk, held=frozenset())
402
+ return Keyring(
403
+ keys={kid: material} if kind == "oct" else {},
404
+ primary=kid,
405
+ recipients={kid: material} if kind == "X25519" else {},
406
+ scopes={kid: bound} if bound is not None else {},
407
+ signers={kid: material} if kind == "Ed25519" else {},
408
+ )
409
+
410
+
411
+ def _okp_jwk(crv: str, kid: str, key: X25519Key | Ed25519Key) -> dict:
412
+ """One OKP JWK member dict: `d` only when the private half is present."""
413
+ jwk = {"crv": crv, "kid": kid, "kty": "OKP", "x": _encode_key(key.public)}
414
+ if key.private is not None:
415
+ jwk["d"] = _encode_key(key.private)
416
+ return jwk
417
+
418
+
419
+ def _parse_jwk(
420
+ jwk: object, *, held: frozenset[str],
421
+ ) -> tuple[str, str, bytes | X25519Key | Ed25519Key, frozenset[str] | None]:
422
+ """Parse one JWK into `(kind, kid, material, scopes)`; kind is `oct`, `X25519` or `Ed25519`.
423
+
424
+ Checks in order: object, `kty`, `kid`, a kid already in `held`, scopes, key
425
+ material — so a duplicate kid with malformed material still reports the duplicate.
426
+ """
427
+ if not isinstance(jwk, dict):
428
+ raise KeyringError(f"each JWK must be a JSON object, got {type(jwk).__name__}")
429
+ kty = jwk.get("kty")
430
+ if kty not in ("OKP", "oct"):
431
+ raise KeyringError(
432
+ f"unsupported key type {kty!r} — only oct (symmetric) and OKP (X25519, Ed25519) keys are used"
433
+ )
434
+ kid = jwk.get("kid")
435
+ if not isinstance(kid, str) or not kid:
436
+ raise KeyringError(f"every JWK needs a non-empty string kid, got {kid!r}")
437
+ if kid in held:
438
+ raise KeyringError(f"duplicate kid {kid!r} in the keyring")
439
+ bound = _parse_scopes(kid, jwk["flenc_scopes"]) if "flenc_scopes" in jwk else None
440
+ if kty == "oct":
441
+ return "oct", kid, _decode_member(kid, jwk, "k"), bound
442
+ crv = jwk.get("crv")
443
+ if crv not in ("Ed25519", "X25519"):
444
+ raise KeyringError(f"JWK {kid!r} has unsupported curve {crv!r} — only X25519 and Ed25519 are used")
445
+ public = _decode_member(kid, jwk, "x")
446
+ private = _decode_member(kid, jwk, "d") if "d" in jwk else None
447
+ if crv == "X25519":
448
+ return "X25519", kid, X25519Key(public=public, private=private), bound
449
+ return "Ed25519", kid, Ed25519Key(public=public, private=private), bound
450
+
451
+
452
+ def _decode_member(kid: str, jwk: dict, member: str) -> bytes:
453
+ """Decode JWK member `member` of key `kid`, reporting absence or bad base64url as `KeyringError`."""
454
+ if member not in jwk:
455
+ raise KeyringError(f"JWK {kid!r} has no {member!r} (key material)")
456
+ try:
457
+ return _decode_key(jwk[member], member)
458
+ except (TypeError, ValueError) as e: # binascii.Error ⊂ ValueError
459
+ raise KeyringError(f"JWK {kid!r} has malformed base64url {member!r}: {e}") from e
460
+
461
+
462
+ def _parse_scopes(kid: str, raw: object) -> frozenset[str]:
463
+ """Parse a JWK's `flenc_scopes` member: a non-empty array of non-empty strings."""
464
+ if not isinstance(raw, list) or not raw or not all(isinstance(scope, str) and scope for scope in raw):
465
+ raise KeyringError(f"JWK {kid!r} 'flenc_scopes' must be a non-empty array of non-empty strings")
466
+ return frozenset(raw)
467
+
468
+
469
+ def _parse_scope_map(doc: dict, member: str) -> dict[str, str]:
470
+ """Parse an optional top-level scope → kid object (`flenc_primaries`, `flenc_signing`)."""
471
+ raw = doc.get(member, {})
472
+ if not isinstance(raw, dict) or not all(isinstance(kid, str) for kid in raw.values()):
473
+ raise KeyringError(f"keyring {member!r} must be a JSON object of scope to kid strings")
474
+ return dict(raw)
flechtctl/py.typed ADDED
File without changes