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/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
|