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