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/tokens.py ADDED
@@ -0,0 +1,514 @@
1
+ """The `flenc:jwe:` / `flsig:jws:` token format — seal, open, sign, verify — over an explicit keyring.
2
+
3
+ The **only** flechtctl module that imports ``joserfc`` (and importing it is
4
+ lazy: ``import flechtctl`` does not load it, the first access of a name from
5
+ this module through the package does). Nothing here reads process-global
6
+ state: every function takes the `Keyring` it works with, and none emits
7
+ telemetry — that is the framework's layer.
8
+
9
+ A token is sealed under a keyring in one of two generations: symmetric
10
+ ``dir`` + ``A256GCM`` under an oct key, or ``ECDH-ES+A256KW`` + ``A256GCM`` to
11
+ an X25519 recipient key (whose public half seals and whose private half
12
+ opens). Readers pin a strict ``(alg, enc)`` allowlist per generation, and the
13
+ **kind of the key behind the token's ``kid``** selects which one applies — a
14
+ token never chooses. A key bound to scopes in the keyring serves only those
15
+ scopes, on write and on read. The optional ``scope`` binds a token into a
16
+ domain-separation compartment (``flenc_scope`` in the integrity-protected
17
+ header) — a one-way ratchet: adding a scope is non-breaking, dropping it is
18
+ blocked.
19
+
20
+ ``sign`` / ``verify`` produce and check ``flsig:jws:`` Ed25519 compact JWS
21
+ tokens, whose scope is required and checked exactly.
22
+
23
+ The JSON-level functions (`encrypt`, `decrypt`, `sign`, `verify`) work on any
24
+ JSON value; the ``*_bytes`` variants on raw payload bytes. ``encrypt(k, v,
25
+ scope=s)`` writes the same token flechtwerk's ``ENCRYPTED(inner, scope=s)``
26
+ writes for ``v == inner.encode(x)``.
27
+
28
+ A keyring file may be password-protected: `encrypt_keyring` / `decrypt_keyring`
29
+ wrap the JWK Set in an RFC 7517 §7 encrypted JWK Set (``PBES2-HS512+A256KW``).
30
+ """
31
+ import base64
32
+ import functools
33
+ import json
34
+ import secrets
35
+
36
+ from joserfc import jwe, jws
37
+ from joserfc.jwk import OctKey, OKPKey
38
+ from joserfc.registry import HeaderParameter
39
+
40
+ from .errors import SecretDecryptError, SecretFormatError, SecretSignatureError
41
+ from .keyring import (
42
+ Ed25519Key,
43
+ KEY_BYTES,
44
+ Keyring,
45
+ KeyringError,
46
+ UnknownKeyError,
47
+ X25519Key,
48
+ _decode_key,
49
+ )
50
+
51
+ __all__ = [
52
+ "PREFIX",
53
+ "SCHEME",
54
+ "SIGNATURE_PREFIX",
55
+ "SIGNATURE_SCHEME",
56
+ "canonical_json",
57
+ "decrypt",
58
+ "decrypt_bytes",
59
+ "decrypt_keyring",
60
+ "encrypt",
61
+ "encrypt_bytes",
62
+ "encrypt_keyring",
63
+ "generate_ed25519_key",
64
+ "generate_oct_key",
65
+ "generate_x25519_key",
66
+ "is_encrypted",
67
+ "is_signed",
68
+ "kid_of",
69
+ "read_header",
70
+ "reencrypt",
71
+ "sign",
72
+ "sign_bytes",
73
+ "signer_of",
74
+ "verify",
75
+ "verify_bytes",
76
+ ]
77
+
78
+ SCHEME = "flenc"
79
+ """The URI scheme naming a Flechtwerk-encrypted value (and the backend dispatch point)."""
80
+
81
+ _ENVELOPE = "jwe" # the encoding segment: RFC 7516 *compact* serialization, this design's profile
82
+ PREFIX = f"{SCHEME}:{_ENVELOPE}:"
83
+ """The full token prefix, ``flenc:jwe:`` — a valid URI scheme + envelope segment."""
84
+
85
+ SIGNATURE_SCHEME = "flsig"
86
+ """The URI scheme naming a Flechtwerk-signed value — ``flenc:`` stays reserved for ciphertext forms."""
87
+
88
+ _SIGNATURE_ENVELOPE = "jws" # RFC 7515 *compact* serialization
89
+ SIGNATURE_PREFIX = f"{SIGNATURE_SCHEME}:{_SIGNATURE_ENVELOPE}:"
90
+ """The full signed-value prefix, ``flsig:jws:``."""
91
+
92
+ _SCHEME_PREFIX = f"{SCHEME}:" # any value under this scheme is ciphertext-form
93
+ _SIGNATURE_SCHEME_PREFIX = f"{SIGNATURE_SCHEME}:" # any value under this scheme is signed-form
94
+ _ALG = "dir"
95
+ _ENC = "A256GCM"
96
+ _FLENC_SCOPE = "flenc_scope" # optional protected-header param — the domain-separation scope
97
+ _RECIPIENT_ALG = "ECDH-ES+A256KW"
98
+ _SIGNATURE_ALG = "Ed25519" # RFC 9864, fully specified — never the deprecated polymorphic "EdDSA"
99
+
100
+ # The one extension header every registry admits: flenc_scope is OPTIONAL (a
101
+ # token may carry a scope or not).
102
+ _HEADER_REGISTRY = {_FLENC_SCOPE: HeaderParameter("Flechtwerk domain-separation scope", "str", False)}
103
+
104
+ # The acceptance rule: exactly two (alg, enc) pairs, one registry each —
105
+ # dir+A256GCM for an oct kid, ECDH-ES+A256KW+A256GCM for an X25519 recipient
106
+ # kid. The KIND OF THE KEY behind the token's kid selects the registry; the
107
+ # token's own header never chooses, so neither generation can be confused for
108
+ # the other. Widening an allowlist (a future hybrid post-quantum mode) is a
109
+ # deliberate reader-side change, never token content.
110
+ _REGISTRY = jwe.JWERegistry(
111
+ header_registry=_HEADER_REGISTRY,
112
+ algorithms=[_ALG, _ENC],
113
+ strict_check_header=True,
114
+ )
115
+ _RECIPIENT_REGISTRY = jwe.JWERegistry(
116
+ header_registry=_HEADER_REGISTRY,
117
+ algorithms=[_RECIPIENT_ALG, _ENC],
118
+ strict_check_header=True,
119
+ )
120
+ # Signatures: exactly Ed25519. A signed token always carries flenc_scope (the
121
+ # codec checks it), the registry merely admits the header.
122
+ _SIGNATURE_REGISTRY = jws.JWSRegistry(
123
+ header_registry=_HEADER_REGISTRY,
124
+ algorithms=[_SIGNATURE_ALG],
125
+ strict_check_header=True,
126
+ )
127
+
128
+ # A password-protected keyring file (an encrypted JWK Set) is not a token: its
129
+ # own pinned registry, separate from the three above.
130
+ _KEYRING_ALG = "PBES2-HS512+A256KW"
131
+ _KEYRING_CTY = "jwk-set+json"
132
+ _KEYRING_ITERATIONS = 210000 # OWASP's figure for PBKDF2-HMAC-SHA512; joserfc rejects >= 300000
133
+ _KEYRING_REGISTRY = jwe.JWERegistry(algorithms=[_KEYRING_ALG, _ENC])
134
+ _MIN_PASSWORD_BYTES = 14 # joserfc warns below 112 bits; the check here refuses first
135
+
136
+
137
+ # --- classification & crypto helpers ---
138
+
139
+
140
+ def is_encrypted(value: object) -> bool:
141
+ """True if ``value`` is ciphertext-form — a string under the ``flenc:`` scheme.
142
+
143
+ Classification is by scheme, not by exact token: a plaintext config value
144
+ that merely looks like a URL under a *different* scheme is not
145
+ ciphertext-form. The envelope segment is validated later, at decode.
146
+ """
147
+ return isinstance(value, str) and value.startswith(_SCHEME_PREFIX)
148
+
149
+
150
+ def is_signed(value: object) -> bool:
151
+ """True if ``value`` is signed-form — a string under the ``flsig:`` scheme."""
152
+ return isinstance(value, str) and value.startswith(_SIGNATURE_SCHEME_PREFIX)
153
+
154
+
155
+ def _strip_envelope(token: str, scheme: str, expected: str) -> str:
156
+ body = token[len(scheme) + 1:]
157
+ envelope, sep, compact = body.partition(":")
158
+ if not sep or envelope != expected or not compact:
159
+ raise SecretFormatError(f"unknown {scheme} envelope {envelope!r} — expected {expected!r}")
160
+ return compact
161
+
162
+
163
+ def _compact(token: str) -> str:
164
+ """Strip ``flenc:jwe:`` and return the compact JWE, or raise on a bad envelope.
165
+
166
+ An unknown envelope segment (a future ``flenc:`` form this reader does not
167
+ understand) raises in every keyring mode — it is never handed to an inner
168
+ codec as legacy plaintext.
169
+ """
170
+ return _strip_envelope(token, SCHEME, _ENVELOPE)
171
+
172
+
173
+ def _signature_compact(token: str) -> str:
174
+ """Strip ``flsig:jws:`` and return the compact JWS, or raise on a bad envelope (as `_compact`)."""
175
+ return _strip_envelope(token, SIGNATURE_SCHEME, _SIGNATURE_ENVELOPE)
176
+
177
+
178
+ def _read_header(compact: str) -> dict:
179
+ """Decode the protected header of a compact JWE or JWS without any key (base64url decode)."""
180
+ header_segment = compact.split(".", 1)[0]
181
+ try:
182
+ padded = header_segment + "=" * (-len(header_segment) % 4)
183
+ header = json.loads(base64.urlsafe_b64decode(padded))
184
+ except Exception as e:
185
+ raise SecretFormatError(f"malformed JWE header: {e}") from e
186
+ if not isinstance(header, dict):
187
+ raise SecretFormatError(f"malformed JWE header: expected a JSON object, got {type(header).__name__}")
188
+ return header
189
+
190
+
191
+ def _read_kid(compact: str) -> str:
192
+ """Read the ``kid`` from a compact JWE or JWS header without the key (base64url decode).
193
+
194
+ Used only to *select* which key to hand the decryptor or verifier; the
195
+ header is re-parsed and authenticated during the actual decrypt (as the
196
+ AEAD's associated data) or verify (as part of the signing input), so a
197
+ tampered kid fails regardless.
198
+ """
199
+ kid = _read_header(compact).get("kid")
200
+ if not isinstance(kid, str) or not kid:
201
+ raise SecretFormatError(f"JWE header carries no usable kid: {kid!r}")
202
+ return kid
203
+
204
+
205
+ def kid_of(token: str) -> str:
206
+ """Return the ``kid`` a ``flenc`` token was encrypted under (no key needed).
207
+
208
+ A scan/rotation building block: reports which key a surviving record still
209
+ depends on. Raises ``SecretFormatError`` for a non-``flenc`` value.
210
+ """
211
+ if not is_encrypted(token):
212
+ raise SecretFormatError("not a flenc token")
213
+ return _read_kid(_compact(token))
214
+
215
+
216
+ def signer_of(token: str) -> str:
217
+ """Return the ``kid`` of the key that signed a ``flsig`` token (no key needed).
218
+
219
+ Raises ``SecretFormatError`` for a non-``flsig`` value.
220
+ """
221
+ if not is_signed(token):
222
+ raise SecretFormatError("not a flsig token")
223
+ return _read_kid(_signature_compact(token))
224
+
225
+
226
+ def read_header(token: str) -> dict:
227
+ """The decoded protected header of a ``flenc`` or ``flsig`` token — **unauthenticated**.
228
+
229
+ No key is involved, so nothing here is verified: the header is only
230
+ trustworthy once `decrypt` / `verify` has succeeded. For inspection and
231
+ tooling. Raises ``SecretFormatError`` for any other value or a malformed header.
232
+ """
233
+ if is_encrypted(token):
234
+ return _read_header(_compact(token))
235
+ if is_signed(token):
236
+ return _read_header(_signature_compact(token))
237
+ raise SecretFormatError("not a flenc or flsig token")
238
+
239
+
240
+ def _b64url(b: bytes) -> str:
241
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
242
+
243
+
244
+ @functools.cache
245
+ def _okp_private_key(kid: str, crv: str, public: bytes, private: bytes) -> OKPKey:
246
+ """Import a private OKP key and check that its halves belong together.
247
+
248
+ joserfc imports a mismatched pair without complaint, so the check derives
249
+ the public half from the private one. Cached: it runs once per key
250
+ material. A mismatch is a keyring misconfiguration and propagates as
251
+ `KeyringError`, unwrapped.
252
+ """
253
+ key = OKPKey.import_key({"crv": crv, "d": _b64url(private), "kty": "OKP", "x": _b64url(public)})
254
+ if key.private_key.public_key().public_bytes_raw() != public:
255
+ raise KeyringError(f"{crv} key {kid!r}: the private key does not match its public key")
256
+ return key
257
+
258
+
259
+ def canonical_json(value: object) -> bytes:
260
+ """Canonical JSON bytes: compact, sorted keys, UTF-8, NaN rejected.
261
+
262
+ Matches the framework's canonical JSON, so encrypted / signed RECORD/DICT
263
+ payloads are deterministic.
264
+ """
265
+ return json.dumps(
266
+ value, allow_nan=False, ensure_ascii=False, separators=(",", ":"), sort_keys=True,
267
+ ).encode("utf-8")
268
+
269
+
270
+ # --- seal / open / sign / verify over an explicit keyring ---
271
+
272
+
273
+ def encrypt_bytes(keyring: Keyring, payload: bytes, *, scope: str = "") -> str:
274
+ """Encrypt raw payload bytes under the scope's sealing key, stamping ``scope`` if set.
275
+
276
+ The keyring picks the kid (``primaries[scope]``, else ``primary``) and
277
+ refuses a scope outside that key's binding. An oct kid seals ``dir``; an
278
+ X25519 recipient kid seals ``ECDH-ES+A256KW`` to its public half.
279
+ """
280
+ kid = keyring.sealing_kid(scope)
281
+ if kid in keyring.recipients:
282
+ recipient = keyring.recipient_for(kid)
283
+ if recipient.private is not None:
284
+ _okp_private_key(kid, "X25519", recipient.public, recipient.private)
285
+ key = OKPKey.import_key({"crv": "X25519", "kty": "OKP", "x": _b64url(recipient.public)})
286
+ protected = {"alg": _RECIPIENT_ALG, "enc": _ENC, "kid": kid}
287
+ registry = _RECIPIENT_REGISTRY
288
+ else:
289
+ key = OctKey.import_key(keyring.key_for(kid), {"kid": kid})
290
+ protected = {"alg": _ALG, "enc": _ENC, "kid": kid}
291
+ registry = _REGISTRY
292
+ if scope:
293
+ protected[_FLENC_SCOPE] = scope
294
+ token = jwe.encrypt_compact(protected, payload, key, registry=registry)
295
+ return PREFIX + token
296
+
297
+
298
+ def decrypt_bytes(keyring: Keyring, token: str, *, scope: str = "", topic: str | None = None,
299
+ wire_key: str | None = None) -> bytes:
300
+ """Decrypt a ``flenc`` token to raw payload bytes, enforcing the scope ratchet.
301
+
302
+ Selects the key by the token's ``kid`` — a recipient kid opens with its
303
+ private half under the ``ECDH-ES+A256KW`` allowlist, any other kid is an oct
304
+ key under the ``dir`` allowlist. Then, comparing the codec's ``scope`` to the token's stamped
305
+ ``flenc_scope``:
306
+
307
+ - ``scope`` set: reject only when the token carries a *different* scope
308
+ (relocation). An unscoped token is accepted (upgrade / fallback) — a
309
+ key-less attacker can neither mint nor strip a scope (it's the AEAD's
310
+ associated data), so this is equivalent to strict once every token is
311
+ scoped.
312
+ - ``scope`` empty: reject when the token *carries* a scope (downgrade
313
+ blocked — a scoped token may not be read by an unscoped codec).
314
+
315
+ Finally, a key bound to scopes in the keyring rejects a token whose
316
+ stamped scope (possibly none) is outside its binding. That check runs
317
+ after decryption, because only then is the stamped header authenticated.
318
+
319
+ Every token failure surfaces as ``SecretDecryptError``; a keyring whose
320
+ recipient halves do not match raises ``KeyringError``.
321
+ """
322
+ compact = _compact(token)
323
+ kid = _read_kid(compact)
324
+ if kid in keyring.recipients:
325
+ recipient = keyring.recipient_for(kid)
326
+ if recipient.private is None:
327
+ raise SecretDecryptError(
328
+ scope=scope, kid=kid, topic=topic, wire_key=wire_key,
329
+ reason=f"keyring holds only the public half of {kid!r}: it can seal but not open",
330
+ )
331
+ key = _okp_private_key(kid, "X25519", recipient.public, recipient.private)
332
+ registry = _RECIPIENT_REGISTRY
333
+ else:
334
+ try:
335
+ key_bytes = keyring.key_for(kid)
336
+ except UnknownKeyError as e:
337
+ raise SecretDecryptError(scope=scope, kid=kid, topic=topic, wire_key=wire_key) from e
338
+ key = OctKey.import_key(key_bytes, {"kid": kid})
339
+ registry = _REGISTRY
340
+ try:
341
+ obj = jwe.decrypt_compact(compact, key, registry=registry)
342
+ except Exception as e:
343
+ raise SecretDecryptError(scope=scope, kid=kid, topic=topic, wire_key=wire_key) from e
344
+ stamped = obj.protected.get(_FLENC_SCOPE) # str | None
345
+ if scope:
346
+ if stamped is not None and stamped != scope:
347
+ raise SecretDecryptError(
348
+ scope=scope, kid=kid, topic=topic, wire_key=wire_key,
349
+ reason=f"scope mismatch: token scoped {stamped!r}, read as {scope!r}",
350
+ )
351
+ elif stamped is not None:
352
+ raise SecretDecryptError(
353
+ scope=scope, kid=kid, topic=topic, wire_key=wire_key,
354
+ reason=f"token is scoped {stamped!r} but this codec declares no scope (downgrade blocked)",
355
+ )
356
+ bound = keyring.scopes.get(kid)
357
+ if bound is not None and stamped not in bound:
358
+ raise SecretDecryptError(
359
+ scope=scope, kid=kid, topic=topic, wire_key=wire_key,
360
+ reason=f"key {kid!r} is bound to scopes {sorted(bound)}; token stamped {stamped!r}",
361
+ )
362
+ return obj.plaintext
363
+
364
+
365
+ def _require_scope(verb: str, scope: str) -> None:
366
+ if not scope:
367
+ raise ValueError(f"{verb} needs a non-empty scope — a signature always names the compartment it vouches for")
368
+
369
+
370
+ def sign_bytes(keyring: Keyring, payload: bytes, *, scope: str) -> str:
371
+ """Sign raw payload bytes with the keyring's signing key for ``scope``; return the ``flsig:jws:`` token.
372
+
373
+ ``scope`` is required: a signature always names the compartment it vouches
374
+ for. The keyring's ``signing[scope]`` names the key; none configured raises
375
+ ``KeyringError``.
376
+ """
377
+ _require_scope("sign", scope)
378
+ kid = keyring.signing_kid(scope)
379
+ signer = keyring.signer_for(kid)
380
+ key = _okp_private_key(kid, "Ed25519", signer.public, signer.private)
381
+ protected = {"alg": _SIGNATURE_ALG, _FLENC_SCOPE: scope, "kid": kid}
382
+ token = jws.serialize_compact(protected, payload, key, registry=_SIGNATURE_REGISTRY)
383
+ return SIGNATURE_PREFIX + token
384
+
385
+
386
+ def verify_bytes(keyring: Keyring, token: str, *, scope: str) -> bytes:
387
+ """Verify a ``flsig`` token to raw payload bytes, enforcing scope and binding.
388
+
389
+ Selects the signer by the token's ``kid`` and pins ``Ed25519``. The token's
390
+ stamped ``flenc_scope`` must **equal** ``scope`` — there is no ratchet, as
391
+ no unscoped signature exists — and ``scope`` must be within the signer's
392
+ binding. Every failure surfaces as ``SecretSignatureError``.
393
+ """
394
+ _require_scope("verify", scope)
395
+ compact = _signature_compact(token)
396
+ kid = _read_kid(compact)
397
+ try:
398
+ signer = keyring.signer_for(kid)
399
+ except UnknownKeyError as e:
400
+ raise SecretSignatureError(scope=scope, kid=kid) from e
401
+ key = OKPKey.import_key({"crv": "Ed25519", "kty": "OKP", "x": _b64url(signer.public)})
402
+ try:
403
+ obj = jws.deserialize_compact(compact, key, registry=_SIGNATURE_REGISTRY)
404
+ except Exception as e:
405
+ raise SecretSignatureError(scope=scope, kid=kid) from e
406
+ stamped = obj.protected.get(_FLENC_SCOPE)
407
+ if stamped != scope:
408
+ raise SecretSignatureError(
409
+ scope=scope, kid=kid, reason=f"scope mismatch: signature scoped {stamped!r}, read as {scope!r}",
410
+ )
411
+ bound = keyring.scopes[kid]
412
+ if scope not in bound:
413
+ raise SecretSignatureError(
414
+ scope=scope, kid=kid, reason=f"signer {kid!r} is bound to scopes {sorted(bound)}",
415
+ )
416
+ return obj.payload
417
+
418
+
419
+ def encrypt(keyring: Keyring, value: object, *, scope: str = "") -> str:
420
+ """Encrypt a JSON value (as canonical JSON) and return the ``flenc:jwe:`` token."""
421
+ return encrypt_bytes(keyring, canonical_json(value), scope=scope)
422
+
423
+
424
+ def decrypt(keyring: Keyring, token: str, *, scope: str = "") -> object:
425
+ """Decrypt a ``flenc`` token and parse its payload as JSON."""
426
+ return json.loads(decrypt_bytes(keyring, token, scope=scope))
427
+
428
+
429
+ def sign(keyring: Keyring, value: object, *, scope: str) -> str:
430
+ """Sign a JSON value (as canonical JSON) and return the ``flsig:jws:`` token."""
431
+ return sign_bytes(keyring, canonical_json(value), scope=scope)
432
+
433
+
434
+ def verify(keyring: Keyring, token: str, *, scope: str) -> object:
435
+ """Verify a ``flsig`` token and parse its payload as JSON."""
436
+ return json.loads(verify_bytes(keyring, token, scope=scope))
437
+
438
+
439
+ def reencrypt(keyring: Keyring, token: str, *, scope: str = "") -> str:
440
+ """Re-encrypt ``token`` under the scope's current sealing key (decrypt-with-named-kid).
441
+
442
+ The building block for a rotation sweep and for cross-environment
443
+ promotion: it decrypts with the key the token names, then re-encrypts the
444
+ same payload under the keyring's sealing key for ``scope``
445
+ (``primaries[scope]``, else ``primary``) — which moves a token from ``dir``
446
+ to a recipient key, from one recipient key to another, and back. Opening a
447
+ recipient token needs that recipient's private half. The exact inner bytes
448
+ are preserved, never round-tripped through the decoded value. Also promotes an
449
+ unscoped token to ``scope`` (the ratchet's upgrade path); it cannot strip a
450
+ scope. Raises ``SecretDecryptError`` if the token cannot be read.
451
+ """
452
+ return encrypt_bytes(keyring, decrypt_bytes(keyring, token, scope=scope), scope=scope)
453
+
454
+
455
+ # --- password-protected keyring documents ---
456
+
457
+
458
+ def encrypt_keyring(keyring: Keyring, password: str) -> str:
459
+ """The keyring as a password-protected document: an encrypted JWK Set (RFC 7517 §7).
460
+
461
+ A compact JWE over ``keyring.to_json()``, ``PBES2-HS512+A256KW`` +
462
+ ``A256GCM``, 210 000 iterations, a fresh salt per call. Raises
463
+ ``KeyringError`` for a password shorter than 14 bytes.
464
+ """
465
+ key = _password_key(password)
466
+ header = {"alg": _KEYRING_ALG, "cty": _KEYRING_CTY, "enc": _ENC, "p2c": _KEYRING_ITERATIONS}
467
+ return jwe.encrypt_compact(header, keyring.to_json().encode("utf-8"), key, registry=_KEYRING_REGISTRY)
468
+
469
+
470
+ def decrypt_keyring(text: str, password: str) -> Keyring:
471
+ """Open a password-protected keyring document.
472
+
473
+ Any failure to open it — malformed, a different algorithm, a wrong
474
+ password — raises ``KeyringError`` chaining the cause.
475
+ """
476
+ message = "cannot open the password-protected keyring — wrong password or damaged file"
477
+ try:
478
+ obj = jwe.decrypt_compact(text.strip(), _password_key(password), registry=_KEYRING_REGISTRY)
479
+ plaintext = obj.plaintext.decode("utf-8")
480
+ except Exception as e:
481
+ raise KeyringError(message) from e
482
+ return Keyring.from_json(plaintext)
483
+
484
+
485
+ def _password_key(password: str) -> OctKey:
486
+ raw = password.encode("utf-8")
487
+ if len(raw) < _MIN_PASSWORD_BYTES:
488
+ raise KeyringError(f"a keyring password must be at least {_MIN_PASSWORD_BYTES} bytes")
489
+ return OctKey.import_key(raw)
490
+
491
+
492
+ # --- key generation ---
493
+
494
+
495
+ def generate_oct_key() -> bytes:
496
+ """A fresh 32-byte AES-256 key, for the `keys` of a keyring."""
497
+ return secrets.token_bytes(KEY_BYTES)
498
+
499
+
500
+ def _generate_okp(crv: str) -> tuple[bytes, bytes]:
501
+ jwk = OKPKey.generate_key(crv).as_dict(private=True)
502
+ return _decode_key(jwk["x"], "x"), _decode_key(jwk["d"], "d")
503
+
504
+
505
+ def generate_x25519_key() -> X25519Key:
506
+ """A fresh X25519 recipient key pair (both halves present)."""
507
+ public, private = _generate_okp("X25519")
508
+ return X25519Key(public=public, private=private)
509
+
510
+
511
+ def generate_ed25519_key() -> Ed25519Key:
512
+ """A fresh Ed25519 signing key pair (both halves present)."""
513
+ public, private = _generate_okp("Ed25519")
514
+ return Ed25519Key(public=public, private=private)
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.5
2
+ Name: flechtctl
3
+ Version: 0.16.0
4
+ Summary: Keyrings and flenc:/flsig: secret tokens for Flechtwerk, without the framework: a library and a command line tool.
5
+ Project-URL: Documentation, https://bsure-analytics.github.io/flechtwerk/guides/flechtctl/
6
+ Project-URL: Homepage, https://github.com/bsure-analytics/flechtwerk
7
+ Project-URL: Issues, https://github.com/bsure-analytics/flechtwerk/issues
8
+ Project-URL: Repository, https://github.com/bsure-analytics/flechtwerk
9
+ Author: Christian Schlichtherle, Viktor Penev
10
+ Maintainer: b.sure GmbH
11
+ License: MIT
12
+ Keywords: flechtwerk,jwe,jws,keyring,secrets
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Security :: Cryptography
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: joserfc~=1.7
23
+ Description-Content-Type: text/markdown
24
+
25
+ # flechtctl
26
+
27
+ Keyrings and `flenc:` / `flsig:` secret tokens for
28
+ [Flechtwerk](https://github.com/bsure-analytics/flechtwerk) — as a small library and a
29
+ command line tool, **without the framework**. `flechtctl` depends on
30
+ [joserfc](https://jose.authlib.org/) only; it never imports `flechtwerk`, so it installs and
31
+ runs wherever Python does (including interpreters the full stream-processing framework does
32
+ not support yet).
33
+
34
+ It does three things:
35
+
36
+ - **Keyrings.** Create and edit keyring files — an RFC 7517 JWK Set holding oct (symmetric),
37
+ X25519 (sealing) and Ed25519 (signing) keys, each optionally bound to scopes. Optionally
38
+ password-protected.
39
+ - **Value crypto.** Seal, open, sign and verify values: `flenc:jwe:…` compact JWE tokens and
40
+ `flsig:jws:…` Ed25519 compact JWS tokens.
41
+ - **Token tooling.** Inspect a token's header, re-seal a token under another key.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ uvx flechtctl --help # run it without installing
47
+ uv tool install flechtctl # or install the command
48
+ pip install flechtctl # or use it as a library
49
+ ```
50
+
51
+ Python 3.12 or newer.
52
+
53
+ ## Command line quick start
54
+
55
+ ```bash
56
+ flechtctl keyring new keyring.json --kid prod-2026-10 # a keyring with one fresh 32-byte key
57
+ TOKEN=$(flechtctl encrypt -k keyring.json --scope api_key --string 's3cret')
58
+ flechtctl inspect "$TOKEN" # the unauthenticated header: kid, scope, alg
59
+ flechtctl decrypt -k keyring.json --scope api_key --string "$TOKEN"
60
+ ```
61
+
62
+ `-k/--keyring` is required for every command that needs key material: the command reads no
63
+ environment variables. `flechtctl keyring --help` lists the keyring commands — `new`, `add`,
64
+ `remove`, `primary`, `signing`, `show`, `export`, `import`, `protect` and `unprotect`. A keyring
65
+ file works like a keystore: a set of named entries, handed on one public half at a time with
66
+ `export` and `import`.
67
+
68
+ ## Library quick start
69
+
70
+ The functions take the keyring explicitly — there is no global state:
71
+
72
+ ```python
73
+ from pathlib import Path
74
+
75
+ from flechtctl import Keyring, decrypt, encrypt, sign, verify
76
+
77
+ keyring = Keyring.from_json(Path("keyring.json").read_text())
78
+
79
+ token = encrypt(keyring, "s3cret", scope="api_key") # "flenc:jwe:…"
80
+ assert decrypt(keyring, token, scope="api_key") == "s3cret"
81
+
82
+ signed = sign(keyring, {"order": 17}, scope="orders") # "flsig:jws:…" — needs a signing key for the scope
83
+ assert verify(keyring, signed, scope="orders") == {"order": 17}
84
+ ```
85
+
86
+ `encrypt`, `decrypt`, `sign` and `verify` work on any JSON value; the `*_bytes` variants take
87
+ raw payload bytes. `reencrypt` moves a token to the scope's current sealing key, `read_header`
88
+ shows a token's unauthenticated header, and `generate_oct_key`, `generate_x25519_key` and
89
+ `generate_ed25519_key` mint key material.
90
+
91
+ ## Equivalence with Flechtwerk's codecs
92
+
93
+ `flechtctl.encrypt(keyring, value, scope=s)` writes exactly the token that Flechtwerk's
94
+ `ENCRYPTED(inner, scope=s)` codec writes for `value == inner.encode(x)`, and every token
95
+ Flechtwerk writes opens here. For a `BYTES` field, pass the base64 string. Flechtwerk itself
96
+ is built on this package: `pip install "flechtwerk[secrets]"` installs the matching `flechtctl`.
97
+
98
+ ## More
99
+
100
+ The guide [Keyrings and Secret Tokens with flechtctl](https://bsure-analytics.github.io/flechtwerk/guides/flechtctl/)
101
+ walks through a complete reader/writer setup, password-protected keyrings and the library API.
102
+ The format itself is specified on the
103
+ [Encrypted Secrets](https://bsure-analytics.github.io/flechtwerk/concepts/secrets/) page.
104
+
105
+ MIT licensed.
@@ -0,0 +1,11 @@
1
+ flechtctl/__init__.py,sha256=hEPAWyXqiZeAjw7aDllpeVxbIuWdfjJni2VpQ2-h-EQ,2544
2
+ flechtctl/__main__.py,sha256=ztC3wj5_xNyUQObzafaFKhSM9rhqxTQdUVIb2t_RbPk,149
3
+ flechtctl/cli.py,sha256=CU8FdWE8w6p_E8Zl1UIt1GHg_ANTgodMe7GfxLpzGQs,19285
4
+ flechtctl/errors.py,sha256=1qpcdvs63VyNuFgIxVvl9CfsrbUADpWphoROZ0SPli0,4546
5
+ flechtctl/keyring.py,sha256=V1JEH6IfQ86HMOLS0nVreZMpQihAUihR-b_QAucSrxs,21807
6
+ flechtctl/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ flechtctl/tokens.py,sha256=NWkIf9KvVJLmKnCNHk-zLyu8Fhnib53XJ5thvaPNs8Q,21548
8
+ flechtctl-0.16.0.dist-info/METADATA,sha256=qHBFFRMpNRcFRq2aykD-IKlO1mWtyUg4WbqWMATNa-0,4668
9
+ flechtctl-0.16.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ flechtctl-0.16.0.dist-info/entry_points.txt,sha256=cHzMTPNoOlIrgVD0jo24xEhZRiNMcWqclNS6YyiFrTc,49
11
+ flechtctl-0.16.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ flechtctl = flechtctl.cli:main