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/__init__.py +90 -0
- flechtctl/__main__.py +7 -0
- flechtctl/cli.py +437 -0
- flechtctl/errors.py +119 -0
- flechtctl/keyring.py +474 -0
- flechtctl/py.typed +0 -0
- flechtctl/tokens.py +514 -0
- flechtctl-0.16.0.dist-info/METADATA +105 -0
- flechtctl-0.16.0.dist-info/RECORD +11 -0
- flechtctl-0.16.0.dist-info/WHEEL +4 -0
- flechtctl-0.16.0.dist-info/entry_points.txt +2 -0
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,,
|