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 ADDED
@@ -0,0 +1,90 @@
1
+ """Keyrings and `flenc:`/`flsig:` secret tokens for Flechtwerk — without the framework.
2
+
3
+ This package owns the keyring model (`Keyring`, `X25519Key`, `Ed25519Key`) and
4
+ the token format (seal, open, sign, verify, the scope ratchet, the key-binding
5
+ checks), and depends on joserfc only — never on flechtwerk. The API takes the
6
+ keyring explicitly, with no process-global state:
7
+
8
+ from flechtctl import Keyring, decrypt, encrypt
9
+
10
+ keyring = Keyring.from_json(open("keyring.json").read())
11
+ token = encrypt(keyring, "s3cret", scope="api_key") # flenc:jwe:...
12
+ decrypt(keyring, token, scope="api_key") # "s3cret"
13
+
14
+ `encrypt(k, v, scope=s)` writes the same token flechtwerk's
15
+ `ENCRYPTED(inner, scope=s)` codec writes for `v == inner.encode(x)` — for a
16
+ `BYTES` field, pass the base64 string. The `flechtctl` command wraps the same
17
+ functions for the shell (`flechtctl --help`).
18
+
19
+ `import flechtctl` loads no joserfc: the keyring and error names are imported
20
+ eagerly, every `flechtctl.tokens` name lazily on first access (PEP 562).
21
+ """
22
+ import importlib
23
+
24
+ from .errors import (
25
+ PlaintextSecretError,
26
+ SecretDecryptError,
27
+ SecretError,
28
+ SecretFormatError,
29
+ SecretSignatureError,
30
+ UnsignedSecretError,
31
+ )
32
+ from .keyring import (
33
+ Ed25519Key,
34
+ KEY_BYTES,
35
+ Keyring,
36
+ KeyringError,
37
+ UnknownKeyError,
38
+ X25519Key,
39
+ is_encrypted_keyring,
40
+ keyring_from_jwk,
41
+ )
42
+
43
+ __all__ = [
44
+ "Ed25519Key",
45
+ "KEY_BYTES",
46
+ "Keyring",
47
+ "KeyringError",
48
+ "PREFIX",
49
+ "PlaintextSecretError",
50
+ "SCHEME",
51
+ "SIGNATURE_PREFIX",
52
+ "SIGNATURE_SCHEME",
53
+ "SecretDecryptError",
54
+ "SecretError",
55
+ "SecretFormatError",
56
+ "SecretSignatureError",
57
+ "UnknownKeyError",
58
+ "UnsignedSecretError",
59
+ "X25519Key",
60
+ "canonical_json",
61
+ "decrypt",
62
+ "decrypt_bytes",
63
+ "decrypt_keyring",
64
+ "encrypt",
65
+ "encrypt_bytes",
66
+ "encrypt_keyring",
67
+ "generate_ed25519_key",
68
+ "generate_oct_key",
69
+ "generate_x25519_key",
70
+ "is_encrypted",
71
+ "is_encrypted_keyring",
72
+ "is_signed",
73
+ "keyring_from_jwk",
74
+ "kid_of",
75
+ "read_header",
76
+ "reencrypt",
77
+ "sign",
78
+ "sign_bytes",
79
+ "signer_of",
80
+ "verify",
81
+ "verify_bytes",
82
+ ]
83
+
84
+ _TOKEN_NAMES = frozenset(name for name in __all__ if name not in globals()) # everything the eager imports did not bind
85
+
86
+
87
+ def __getattr__(name: str) -> object:
88
+ if name in _TOKEN_NAMES:
89
+ return getattr(importlib.import_module(".tokens", __name__), name)
90
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
flechtctl/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ __all__: list[str] = [] # purely internal: `python -m flechtctl` is an entry point, not an API
6
+
7
+ sys.exit(main())
flechtctl/cli.py ADDED
@@ -0,0 +1,437 @@
1
+ """The `flechtctl` command: keyring management, value crypto, token tooling.
2
+
3
+ Stdlib `argparse` only. Every crypto command takes its keyring explicitly with
4
+ `-k/--keyring PATH` — the command reads no environment variables, by decision.
5
+ A keyring file may be password-protected (an encrypted JWK Set): it is detected
6
+ on read and opened with `--password-file PATH`, or a prompt on a terminal.
7
+ """
8
+ import argparse
9
+ import contextlib
10
+ import getpass
11
+ import json
12
+ import os
13
+ import sys
14
+ import tempfile
15
+ from collections.abc import Callable
16
+ from dataclasses import replace
17
+ from importlib.metadata import version
18
+ from pathlib import Path
19
+
20
+ from . import tokens
21
+ from .errors import SecretError
22
+ from .keyring import Keyring, KeyringError, is_encrypted_keyring, keyring_from_jwk
23
+
24
+ __all__ = ["main"]
25
+
26
+ KEY_TYPES = ("oct", "x25519", "ed25519")
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> int:
30
+ """Run the command; return the exit status (0 ok, 1 error, 2 usage)."""
31
+ args = build_parser().parse_args(argv)
32
+ try:
33
+ args.run(args)
34
+ except (KeyringError, SecretError, OSError, ValueError, ModuleNotFoundError) as e:
35
+ print(f"flechtctl: error: {e}", file=sys.stderr)
36
+ return 1
37
+ return 0
38
+
39
+
40
+ # --- the parser ---
41
+
42
+
43
+ def build_parser() -> argparse.ArgumentParser:
44
+ parser = argparse.ArgumentParser(
45
+ prog="flechtctl",
46
+ description="Keyrings and flenc:/flsig: secret tokens for Flechtwerk. "
47
+ "Reads no environment variables: every crypto command needs -k/--keyring.",
48
+ )
49
+ parser.add_argument("--version", action="version", version=f"flechtctl {version('flechtctl')}")
50
+ commands = parser.add_subparsers(dest="command", metavar="COMMAND", required=True)
51
+
52
+ def command(name: str, run: Callable[[argparse.Namespace], None], help: str) -> argparse.ArgumentParser:
53
+ sub = commands.add_parser(name, help=help, description=help)
54
+ sub.set_defaults(run=run)
55
+ return sub
56
+
57
+ decrypt = command("decrypt", run_decrypt, "decrypt a flenc token and print its value")
58
+ add_keyring_option(decrypt)
59
+ add_scope_option(decrypt)
60
+ add_value_arguments(decrypt, "TOKEN", "print the value raw (it must be a JSON string)")
61
+
62
+ encrypt = command("encrypt", run_encrypt, "encrypt a value and print the flenc token")
63
+ add_keyring_option(encrypt)
64
+ add_scope_option(encrypt)
65
+ add_value_arguments(encrypt, "VALUE", "take VALUE as a raw string, not as JSON")
66
+
67
+ inspect = command("inspect", run_inspect, "show the unauthenticated header of a flenc or flsig token (no keyring)")
68
+ inspect.add_argument("token", metavar="TOKEN", nargs="?", help="the token; omitted or - reads stdin")
69
+
70
+ keyring = command("keyring", lambda args: None, "create and edit keyring files")
71
+ add_keyring_commands(keyring.add_subparsers(dest="keyring_command", metavar="SUBCOMMAND", required=True))
72
+
73
+ reencrypt = command("reencrypt", run_reencrypt, "re-seal a flenc token under the scope's current sealing key")
74
+ add_keyring_option(reencrypt)
75
+ add_scope_option(reencrypt)
76
+ reencrypt.add_argument("token", metavar="TOKEN", nargs="?", help="the token; omitted or - reads stdin")
77
+
78
+ sign = command("sign", run_sign, "sign a value and print the flsig token")
79
+ add_keyring_option(sign)
80
+ add_scope_option(sign, required=True)
81
+ add_value_arguments(sign, "VALUE", "take VALUE as a raw string, not as JSON")
82
+
83
+ verify = command("verify", run_verify, "verify a flsig token and print its value")
84
+ add_keyring_option(verify)
85
+ add_scope_option(verify, required=True)
86
+ add_value_arguments(verify, "TOKEN", "print the value raw (it must be a JSON string)")
87
+ return parser
88
+
89
+
90
+ def add_keyring_commands(subcommands: argparse._SubParsersAction) -> None:
91
+ def sub(name: str, run: Callable[[argparse.Namespace], None], help: str) -> argparse.ArgumentParser:
92
+ parser = subcommands.add_parser(name, help=help, description=help)
93
+ parser.set_defaults(run=run)
94
+ return parser
95
+
96
+ def keyring_argument(parser: argparse.ArgumentParser) -> None:
97
+ parser.add_argument("keyring", metavar="KEYRING", help="the keyring file")
98
+
99
+ def role_options(parser: argparse.ArgumentParser) -> None:
100
+ parser.add_argument("--primary", action="store_true", help="make the entry the primary sealing key")
101
+ parser.add_argument("--primary-for", action="append", default=[], metavar="SCOPE",
102
+ help="make the entry the sealing key for SCOPE (repeatable)")
103
+ parser.add_argument("--signing-for", action="append", default=[], metavar="SCOPE",
104
+ help="make the entry the signing key for SCOPE (repeatable)")
105
+
106
+ add = sub("add", run_add, "add a freshly generated key to a keyring")
107
+ keyring_argument(add)
108
+ add_password_option(add)
109
+ add.add_argument("--kid", required=True, help="the new key's id")
110
+ add.add_argument("--type", choices=KEY_TYPES, default="oct", help="the kind of key (default: oct)")
111
+ add.add_argument("--scope", action="append", default=[], metavar="SCOPE", help="bind the key to SCOPE (repeatable)")
112
+ role_options(add)
113
+
114
+ export = sub("export", run_export, "print the public half of one entry as a JWK (the keyring is left alone)")
115
+ keyring_argument(export)
116
+ add_password_option(export)
117
+ export.add_argument("kid", metavar="KID", help="the entry to export")
118
+ export.add_argument("-o", "--output", metavar="OUT", help="write the JWK to OUT instead of stdout")
119
+
120
+ import_ = sub("import", run_import, "add the single JWK in JWKFILE (typically an export) as a new entry")
121
+ keyring_argument(import_)
122
+ add_password_option(import_)
123
+ import_.add_argument("jwkfile", metavar="JWKFILE", help="a file holding one JWK")
124
+ import_.add_argument("--encrypt", action="store_true", help="when creating the keyring, write it password-protected")
125
+ role_options(import_)
126
+
127
+ new = sub("new", run_new, "create a keyring holding one freshly generated sealing key")
128
+ keyring_argument(new)
129
+ add_password_option(new)
130
+ new.add_argument("--kid", required=True, help="the key's id")
131
+ new.add_argument("--type", choices=KEY_TYPES[:2], default="oct", help="the kind of key (default: oct)")
132
+ new.add_argument("--scope", action="append", default=[], metavar="SCOPE", help="bind the key to SCOPE (repeatable)")
133
+ new.add_argument("--force", action="store_true", help="overwrite an existing file")
134
+ new.add_argument("--encrypt", action="store_true", help="write the keyring password-protected")
135
+
136
+ primary = sub("primary", run_primary, "set the primary sealing key, or the sealing key for one scope")
137
+ keyring_argument(primary)
138
+ add_password_option(primary)
139
+ primary.add_argument("kid", metavar="KID")
140
+ primary.add_argument("--scope", metavar="SCOPE", help="set the sealing key for SCOPE instead of the primary")
141
+
142
+ protect = sub("protect", run_protect, "rewrite a plain keyring file in place as password-protected")
143
+ keyring_argument(protect)
144
+ add_password_option(protect)
145
+
146
+ remove = sub("remove", run_remove, "drop an entry with its scope binding and roles")
147
+ keyring_argument(remove)
148
+ add_password_option(remove)
149
+ remove.add_argument("kid", metavar="KID")
150
+
151
+ show = sub("show", run_show, "list the entries (never any key material)")
152
+ keyring_argument(show)
153
+ add_password_option(show)
154
+
155
+ signing = sub("signing", run_signing, "set the signing key for one scope")
156
+ keyring_argument(signing)
157
+ add_password_option(signing)
158
+ signing.add_argument("kid", metavar="KID")
159
+ signing.add_argument("--scope", required=True, metavar="SCOPE")
160
+
161
+ unprotect = sub("unprotect", run_unprotect, "write a plain copy of a protected keyring (it stays protected)")
162
+ keyring_argument(unprotect)
163
+ add_password_option(unprotect)
164
+ unprotect.add_argument("-o", "--output", required=True, metavar="OUT", help="where to write the plain copy")
165
+ unprotect.add_argument("--force", action="store_true", help="overwrite OUT if it exists")
166
+
167
+
168
+ def add_keyring_option(parser: argparse.ArgumentParser) -> None:
169
+ parser.add_argument("-k", "--keyring", required=True, metavar="PATH", help="the keyring file")
170
+ add_password_option(parser)
171
+
172
+
173
+ def add_password_option(parser: argparse.ArgumentParser) -> None:
174
+ parser.add_argument("--password-file", metavar="PATH", help="file holding the keyring password")
175
+
176
+
177
+ def add_scope_option(parser: argparse.ArgumentParser, *, required: bool = False) -> None:
178
+ parser.add_argument("--scope", default="", required=required, metavar="SCOPE", help="the domain-separation scope")
179
+
180
+
181
+ def add_value_arguments(parser: argparse.ArgumentParser, name: str, string_help: str) -> None:
182
+ parser.add_argument("--string", action="store_true", help=string_help)
183
+ parser.add_argument(name.lower(), metavar=name, nargs="?", help=f"the {name.lower()}; omitted or - reads stdin")
184
+
185
+
186
+ # --- input, output, files ---
187
+
188
+
189
+ def read_operand(operand: str | None) -> str:
190
+ """The operand itself, or stdin (one trailing newline stripped) when omitted or `-`."""
191
+ if operand is None or operand == "-":
192
+ return sys.stdin.read().removesuffix("\n")
193
+ return operand
194
+
195
+
196
+ def read_value(args: argparse.Namespace) -> object:
197
+ text = read_operand(args.value)
198
+ if args.string:
199
+ return text
200
+ try:
201
+ return json.loads(text)
202
+ except ValueError as e:
203
+ raise ValueError(f"VALUE is not valid JSON ({e}) — pass --string for a plain string") from e
204
+
205
+
206
+ def print_value(value: object, *, raw: bool) -> None:
207
+ if raw:
208
+ if not isinstance(value, str):
209
+ raise ValueError("the value is not a JSON string — drop --string to print it as JSON")
210
+ print(value)
211
+ else:
212
+ print(json.dumps(value, ensure_ascii=False))
213
+
214
+
215
+ def write_private(path: Path, text: str) -> None:
216
+ """Write `path` atomically, mode 0600: a temp file next to it, then a rename."""
217
+ fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
218
+ try:
219
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
220
+ file.write(text)
221
+ os.chmod(temporary, 0o600)
222
+ os.replace(temporary, path)
223
+ except BaseException:
224
+ with contextlib.suppress(FileNotFoundError):
225
+ os.unlink(temporary)
226
+ raise
227
+
228
+
229
+ def read_password_file(path: str) -> str:
230
+ return Path(path).read_text(encoding="utf-8").removesuffix("\n")
231
+
232
+
233
+ def existing_password(args: argparse.Namespace) -> str:
234
+ if args.password_file:
235
+ return read_password_file(args.password_file)
236
+ if sys.stdin.isatty():
237
+ return getpass.getpass("Keyring password: ")
238
+ raise ValueError("this keyring is password-protected — pass --password-file")
239
+
240
+
241
+ def new_password(args: argparse.Namespace) -> str:
242
+ if args.password_file:
243
+ return read_password_file(args.password_file)
244
+ if sys.stdin.isatty():
245
+ password = getpass.getpass("Keyring password: ")
246
+ if getpass.getpass("Repeat the password: ") != password:
247
+ raise ValueError("the passwords do not match")
248
+ return password
249
+ raise ValueError("a password-protected keyring needs a password — pass --password-file")
250
+
251
+
252
+ def open_keyring(args: argparse.Namespace) -> tuple[Keyring, str | None]:
253
+ """The keyring in `args.keyring` and, when its file is protected, the password that opened it."""
254
+ text = Path(args.keyring).read_text(encoding="utf-8")
255
+ if is_encrypted_keyring(text):
256
+ password = existing_password(args)
257
+ return tokens.decrypt_keyring(text, password), password
258
+ return Keyring.from_json(text), None
259
+
260
+
261
+ def save_keyring(path: str, keyring: Keyring, password: str | None) -> None:
262
+ """Write the keyring — protected again when it was opened with a password, never silently plain."""
263
+ write_private(Path(path), keyring.to_json() if password is None else tokens.encrypt_keyring(keyring, password) + "\n")
264
+
265
+
266
+ def crypto_keyring(args: argparse.Namespace) -> Keyring:
267
+ return open_keyring(args)[0]
268
+
269
+
270
+ # --- value crypto ---
271
+
272
+
273
+ def run_decrypt(args: argparse.Namespace) -> None:
274
+ value = tokens.decrypt(crypto_keyring(args), read_operand(args.token), scope=args.scope)
275
+ print_value(value, raw=args.string)
276
+
277
+
278
+ def run_encrypt(args: argparse.Namespace) -> None:
279
+ print(tokens.encrypt(crypto_keyring(args), read_value(args), scope=args.scope))
280
+
281
+
282
+ def run_reencrypt(args: argparse.Namespace) -> None:
283
+ print(tokens.reencrypt(crypto_keyring(args), read_operand(args.token), scope=args.scope))
284
+
285
+
286
+ def run_sign(args: argparse.Namespace) -> None:
287
+ print(tokens.sign(crypto_keyring(args), read_value(args), scope=args.scope))
288
+
289
+
290
+ def run_verify(args: argparse.Namespace) -> None:
291
+ print_value(tokens.verify(crypto_keyring(args), read_operand(args.token), scope=args.scope), raw=args.string)
292
+
293
+
294
+ def run_inspect(args: argparse.Namespace) -> None:
295
+ token = read_operand(args.token)
296
+ header = tokens.read_header(token)
297
+ envelope = "flenc:jwe" if tokens.is_encrypted(token) else "flsig:jws"
298
+ print(json.dumps({"authenticated": False, "envelope": envelope, "header": header}, indent=2, sort_keys=True))
299
+
300
+
301
+ # --- keyring management ---
302
+
303
+
304
+ def generated_entry(keyring: Keyring, kid: str, kind: str, scopes: list[str]) -> Keyring:
305
+ """`keyring` plus one freshly generated key of `kind`; validation runs in the constructor."""
306
+ if kid in keyring.kids():
307
+ raise KeyringError(f"duplicate kid {kid!r} in the keyring")
308
+ return replace(
309
+ keyring,
310
+ keys={**keyring.keys, kid: tokens.generate_oct_key()} if kind == "oct" else keyring.keys,
311
+ recipients={**keyring.recipients, kid: tokens.generate_x25519_key()} if kind == "x25519" else keyring.recipients,
312
+ scopes={**keyring.scopes, kid: frozenset(scopes)} if scopes else keyring.scopes,
313
+ signers={**keyring.signers, kid: tokens.generate_ed25519_key()} if kind == "ed25519" else keyring.signers,
314
+ )
315
+
316
+
317
+ def with_roles(keyring: Keyring, kid: str, args: argparse.Namespace) -> Keyring:
318
+ return replace(
319
+ keyring,
320
+ primary=kid if args.primary else keyring.primary,
321
+ primaries={**keyring.primaries, **{scope: kid for scope in args.primary_for}},
322
+ signing={**keyring.signing, **{scope: kid for scope in args.signing_for}},
323
+ )
324
+
325
+
326
+ def run_add(args: argparse.Namespace) -> None:
327
+ keyring, password = open_keyring(args)
328
+ save_keyring(args.keyring, with_roles(generated_entry(keyring, args.kid, args.type, args.scope), args.kid, args), password)
329
+
330
+
331
+ def run_export(args: argparse.Namespace) -> None:
332
+ text = json.dumps(open_keyring(args)[0].public_jwk(args.kid), indent=2, sort_keys=True) + "\n"
333
+ if args.output:
334
+ Path(args.output).write_text(text, encoding="utf-8")
335
+ else:
336
+ sys.stdout.write(text)
337
+
338
+
339
+ def run_import(args: argparse.Namespace) -> None:
340
+ jwk = json.loads(Path(args.jwkfile).read_text(encoding="utf-8"))
341
+ if not isinstance(jwk, dict):
342
+ raise KeyringError(f"each JWK must be a JSON object, got {type(jwk).__name__}")
343
+ if Path(args.keyring).exists():
344
+ if args.encrypt:
345
+ raise ValueError("--encrypt only applies when import creates the keyring; use flechtctl keyring protect")
346
+ keyring, password = open_keyring(args)
347
+ keyring = keyring.with_jwk(jwk)
348
+ else:
349
+ if not args.primary:
350
+ raise ValueError("a new keyring needs a primary: pass --primary")
351
+ keyring, password = keyring_from_jwk(jwk), new_password(args) if args.encrypt else None
352
+ save_keyring(args.keyring, with_roles(keyring, jwk["kid"], args), password)
353
+
354
+
355
+ def run_new(args: argparse.Namespace) -> None:
356
+ path = Path(args.keyring)
357
+ if path.exists() and not args.force:
358
+ raise ValueError(f"{path} already exists — pass --force to overwrite it")
359
+ password = new_password(args) if args.encrypt else None
360
+ if args.type == "oct":
361
+ keyring = Keyring.of({args.kid: tokens.generate_oct_key()}, primary=args.kid, scopes=bound(args))
362
+ else:
363
+ keyring = Keyring.of(
364
+ {}, primary=args.kid, recipients={args.kid: tokens.generate_x25519_key()}, scopes=bound(args),
365
+ )
366
+ save_keyring(args.keyring, keyring, password)
367
+
368
+
369
+ def bound(args: argparse.Namespace) -> dict[str, set[str]]:
370
+ return {args.kid: set(args.scope)} if args.scope else {}
371
+
372
+
373
+ def run_primary(args: argparse.Namespace) -> None:
374
+ keyring, password = open_keyring(args)
375
+ if args.scope:
376
+ keyring = replace(keyring, primaries={**keyring.primaries, args.scope: args.kid})
377
+ else:
378
+ keyring = replace(keyring, primary=args.kid)
379
+ save_keyring(args.keyring, keyring, password)
380
+
381
+
382
+ def run_protect(args: argparse.Namespace) -> None:
383
+ text = Path(args.keyring).read_text(encoding="utf-8")
384
+ if is_encrypted_keyring(text):
385
+ raise ValueError(f"{args.keyring} is already password-protected")
386
+ save_keyring(args.keyring, Keyring.from_json(text), new_password(args))
387
+
388
+
389
+ def run_remove(args: argparse.Namespace) -> None:
390
+ keyring, password = open_keyring(args)
391
+ kid = args.kid
392
+ if kid not in keyring.kids():
393
+ raise KeyringError(f"the keyring holds no key {kid!r}")
394
+ if kid == keyring.primary:
395
+ raise KeyringError(f"{kid!r} is the primary key — set another primary first (flechtctl keyring primary)")
396
+ save_keyring(args.keyring, replace(
397
+ keyring,
398
+ keys={k: v for k, v in keyring.keys.items() if k != kid},
399
+ primaries={scope: k for scope, k in keyring.primaries.items() if k != kid},
400
+ recipients={k: v for k, v in keyring.recipients.items() if k != kid},
401
+ scopes={k: v for k, v in keyring.scopes.items() if k != kid},
402
+ signers={k: v for k, v in keyring.signers.items() if k != kid},
403
+ signing={scope: k for scope, k in keyring.signing.items() if k != kid},
404
+ ), password)
405
+
406
+
407
+ def run_show(args: argparse.Namespace) -> None:
408
+ keyring = open_keyring(args)[0]
409
+ for kid in keyring.kids():
410
+ if kid in keyring.keys:
411
+ kind, half = "oct", "private"
412
+ elif kid in keyring.recipients:
413
+ kind, half = "x25519", "private" if keyring.recipients[kid].private is not None else "public"
414
+ else:
415
+ kind, half = "ed25519", "private" if keyring.signers[kid].private is not None else "public"
416
+ roles = [
417
+ *(["primary"] if kid == keyring.primary else []),
418
+ *(f"primary-for:{scope}" for scope, k in sorted(keyring.primaries.items()) if k == kid),
419
+ *(f"signing-for:{scope}" for scope, k in sorted(keyring.signing.items()) if k == kid),
420
+ ]
421
+ scopes = ",".join(sorted(keyring.scopes.get(kid, ()))) or "-"
422
+ print(f"{kid} {kind} {half} scopes={scopes} roles={','.join(roles) or '-'}")
423
+
424
+
425
+ def run_signing(args: argparse.Namespace) -> None:
426
+ keyring, password = open_keyring(args)
427
+ save_keyring(args.keyring, replace(keyring, signing={**keyring.signing, args.scope: args.kid}), password)
428
+
429
+
430
+ def run_unprotect(args: argparse.Namespace) -> None:
431
+ text = Path(args.keyring).read_text(encoding="utf-8")
432
+ if not is_encrypted_keyring(text):
433
+ raise ValueError(f"{args.keyring} is not password-protected")
434
+ output = Path(args.output)
435
+ if output.exists() and not args.force:
436
+ raise ValueError(f"{output} already exists — pass --force to overwrite it")
437
+ write_private(output, tokens.decrypt_keyring(text, existing_password(args)).to_json())
flechtctl/errors.py ADDED
@@ -0,0 +1,119 @@
1
+ """The secret-token error family — joserfc-free, so a caller can catch them without the crypto."""
2
+
3
+ __all__ = [
4
+ "PlaintextSecretError",
5
+ "SecretDecryptError",
6
+ "SecretError",
7
+ "SecretFormatError",
8
+ "SecretSignatureError",
9
+ "UnsignedSecretError",
10
+ ]
11
+
12
+
13
+ class SecretError(Exception):
14
+ """Base class for secret encode/decode failures."""
15
+
16
+
17
+ class SecretFormatError(SecretError):
18
+ """A value is not a well-formed ``flenc`` token (unknown envelope, bad header)."""
19
+
20
+
21
+ class SecretDecryptError(SecretError):
22
+ """A ``flenc`` token could not be decrypted or failed scope verification.
23
+
24
+ Carries what the codec knows — the codec's ``scope`` (may be empty) and the
25
+ token's ``kid`` — plus ``topic`` / ``wire_key`` when a caller with that
26
+ context (the scan helper) raises. The hook an application uses to quarantine
27
+ a single config under the "only catch what you can remedy" rule. An unknown
28
+ ``kid`` and a GCM tag failure are distinct incident signatures (missing key
29
+ vs. wrong bytes for a known key); the ``__cause__`` preserves which one.
30
+ """
31
+
32
+ def __init__(self, *, scope: str = "", kid: str | None = None,
33
+ topic: str | None = None, wire_key: str | None = None,
34
+ reason: str | None = None) -> None:
35
+ self.scope = scope
36
+ self.kid = kid
37
+ self.topic = topic
38
+ self.wire_key = wire_key
39
+ detail = ["cannot decrypt secret value"]
40
+ if scope:
41
+ detail.append(f"scope={scope!r}")
42
+ if kid is not None:
43
+ detail.append(f"kid={kid!r}")
44
+ if wire_key is not None:
45
+ detail.append(f"key={wire_key!r}")
46
+ if topic is not None:
47
+ detail.append(f"topic={topic!r}")
48
+ if reason is not None:
49
+ detail.append(reason)
50
+ super().__init__("; ".join(detail))
51
+
52
+
53
+ class PlaintextSecretError(SecretError):
54
+ """A secret value is plaintext but the codec's ``read_plaintext`` is off.
55
+
56
+ The read-time paste-guard firing: the record must be re-produced encrypted
57
+ (and the pasted value treated as compromised). Flipping ``read_plaintext``
58
+ back on to silence it is the anti-pattern — it re-opens the hole for that
59
+ field.
60
+ """
61
+
62
+ def __init__(self, *, scope: str = "", topic: str | None = None, wire_key: str | None = None) -> None:
63
+ self.scope = scope
64
+ self.topic = topic
65
+ self.wire_key = wire_key
66
+ detail = ["secret value is plaintext but read_plaintext is off for this attribute"]
67
+ if scope:
68
+ detail.append(f"scope={scope!r}")
69
+ if wire_key is not None:
70
+ detail.append(f"key={wire_key!r}")
71
+ if topic is not None:
72
+ detail.append(f"topic={topic!r}")
73
+ super().__init__("; ".join(detail))
74
+
75
+
76
+ class SecretSignatureError(SecretError):
77
+ """A ``flsig`` token could not be verified, or vouches for a different scope.
78
+
79
+ Shaped like `SecretDecryptError`: the codec's ``scope``, the token's signer
80
+ ``kid``, ``topic`` / ``wire_key`` when a caller has them, and a ``reason``.
81
+ An unknown signer keeps `UnknownKeyError` as ``__cause__``.
82
+ """
83
+
84
+ def __init__(self, *, scope: str = "", kid: str | None = None,
85
+ topic: str | None = None, wire_key: str | None = None,
86
+ reason: str | None = None) -> None:
87
+ self.scope = scope
88
+ self.kid = kid
89
+ self.topic = topic
90
+ self.wire_key = wire_key
91
+ detail = ["cannot verify signed value"]
92
+ if scope:
93
+ detail.append(f"scope={scope!r}")
94
+ if kid is not None:
95
+ detail.append(f"kid={kid!r}")
96
+ if wire_key is not None:
97
+ detail.append(f"key={wire_key!r}")
98
+ if topic is not None:
99
+ detail.append(f"topic={topic!r}")
100
+ if reason is not None:
101
+ detail.append(reason)
102
+ super().__init__("; ".join(detail))
103
+
104
+
105
+ class UnsignedSecretError(SecretError):
106
+ """A ``SIGNED`` value is unsigned but the codec's ``read_unsigned`` is off."""
107
+
108
+ def __init__(self, *, scope: str = "", topic: str | None = None, wire_key: str | None = None) -> None:
109
+ self.scope = scope
110
+ self.topic = topic
111
+ self.wire_key = wire_key
112
+ detail = ["value is unsigned but read_unsigned is off for this attribute"]
113
+ if scope:
114
+ detail.append(f"scope={scope!r}")
115
+ if wire_key is not None:
116
+ detail.append(f"key={wire_key!r}")
117
+ if topic is not None:
118
+ detail.append(f"topic={topic!r}")
119
+ super().__init__("; ".join(detail))