diff --git a/README.md b/README.md index 0e9a750..926f198 100644 --- a/README.md +++ b/README.md @@ -13,26 +13,26 @@ authorization system and the key-document schema; the crypto primitives live in ## Install ``` -envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.5 +envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.6 ``` Direct: ```bash -pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.5" +pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.6" ``` The base install uses a local JSON file for storage (stdlib only). For shared dev→server storage, install the mongo extra: ```bash -pip install "envelope_authorizer[mongo] @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.5" +pip install "envelope_authorizer[mongo] @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.6" ``` Installing pulls `envelope_crypto` (and `mongo` with the extra). After install, the `authorizer` command is on your PATH; `python -m envelope_authorizer` also works. -Drop the `@v0.1.5` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.6` suffix from the line above to install the latest unpinned. ## Trust model (read this) diff --git a/pyproject.toml b/pyproject.toml index b9df23d..1d672de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "envelope_authorizer" -version = "0.1.5" +version = "0.1.6" description = "CLI key-authorization manager for envelope_crypto" requires-python = ">=3.10" dependencies = [ diff --git a/src/envelope_authorizer/__init__.py b/src/envelope_authorizer/__init__.py index 1276d02..0a8da88 100644 --- a/src/envelope_authorizer/__init__.py +++ b/src/envelope_authorizer/__init__.py @@ -1 +1 @@ -__version__ = "0.1.5" +__version__ = "0.1.6" diff --git a/src/envelope_authorizer/cli.py b/src/envelope_authorizer/cli.py index 1ce66c5..be4846c 100644 --- a/src/envelope_authorizer/cli.py +++ b/src/envelope_authorizer/cli.py @@ -2,8 +2,7 @@ `config init` runs without an existing config (it writes one); every other command loads config and resolves a storage backend first. expected failures -(ConfigError, CommandError, RuntimeError from a guarded backend) print a clean -`[✘] ...` line and exit non-zero — no traceback. +print a clean `[✘] ...` line and exit non-zero - no traceback. """ import argparse @@ -79,8 +78,6 @@ def main() -> int: config_init.run(None, None, args) return 0 except (CommandError, OSError) as error: - # config_init writes a file (cwd may be read-only, full, or gone) — an - # OSError must print a clean [✘] line, not a raw traceback return _fail(str(error)) parser.parse_args(["config", "--help"]) return 0 @@ -99,13 +96,12 @@ def main() -> int: handlers[args.cmd](config, storage, args) return 0 except InvalidTag: - return _fail("capability flag failed authentication — tampered or wrong DEK") + return _fail("capability flag failed authentication - tampered or wrong DEK") except PyMongoError as error: return _fail(f"storage backend error: {error}") except (ConfigError, CommandError, RuntimeError, ValueError, OSError, KeyError, TypeError) as error: - # OSError covers the FileNotFoundError/PermissionError/IsADirectoryError family; - # KeyError/TypeError cover a structurally-malformed flag/doc (unguarded indexing - # of ['iv']/['meta']['authorizer']/['key']) — all print a clean [✘] line, not a traceback + # covers a malformed config/doc/flag (incl. KeyError/TypeError from unguarded + # indexing) - all print a clean [✘] line, not a raw traceback return _fail(str(error)) diff --git a/src/envelope_authorizer/commands/__init__.py b/src/envelope_authorizer/commands/__init__.py index 0d24d70..9e4c90e 100644 --- a/src/envelope_authorizer/commands/__init__.py +++ b/src/envelope_authorizer/commands/__init__.py @@ -1,9 +1,7 @@ """command implementations + shared crypto helpers for the authorizer CLI each command module exposes `run(config, storage, args)`. helpers here own the -recurring crypto plumbing: minting/reading the encrypted capability flag and the -boot sequence (unwrap the local DEK and arm a crypto instance). these never -print key material. +capability-flag and DEK-boot plumbing; none of them print key material. """ import time @@ -25,7 +23,7 @@ def read_flag(crypto: EnvelopeCrypto, blob: dict) -> bool: """decrypt a capability flag; return the `allowed` bool fails closed: a non-dict / unexpected plaintext reads as not-allowed rather than - raising — a privilege gate must default to deny on a malformed flag. + raising - a privilege gate must default to deny on a malformed flag. """ data = crypto.decrypt_data(blob) return bool(data.get("allowed", False)) if isinstance(data, dict) else False @@ -53,8 +51,8 @@ def local_fingerprint(crypto: EnvelopeCrypto, config) -> str: def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]: """unwrap the local DEK and return an armed crypto plus the local key doc - raises CommandError if this machine has no key doc (not initialized / - authorized here). never logs or prints the unwrapped key. + raises CommandError if this machine has no key doc here. never logs or + prints the unwrapped key. """ crypto = EnvelopeCrypto() fingerprint = local_fingerprint(crypto, config) diff --git a/src/envelope_authorizer/commands/authorize.py b/src/envelope_authorizer/commands/authorize.py index e2cd601..574f062 100644 --- a/src/envelope_authorizer/commands/authorize.py +++ b/src/envelope_authorizer/commands/authorize.py @@ -1,11 +1,8 @@ -"""`authorizer authorize` — grant another machine access to the DEK +"""`authorizer authorize` - grant another machine access to the DEK -boots the local DEK, verifies the local key is itself an authorizer, then wraps -the same DEK to the target public key and stores a new key doc. `--can-authorize` -decides whether the new key may authorize others (omit it for servers). Refuses -a target key that already has a record — `save` upserts by `_id`, so re-authorizing -a known key (most dangerously the local key itself) would silently overwrite its -flag/friendly under a success banner instead of adding a new key. +refuses a target key that already has a record - `save` upserts by `_id`, so +re-authorizing a known key would silently overwrite its flag/friendly. see +`run()` below for the full refusal contract. """ from . import ( @@ -33,7 +30,7 @@ def run(config, storage, args) -> None: if new_fp == local_fingerprint(crypto, config): raise CommandError( "target key is the local key; authorize would silently replace the " - "local authorizer record — use a different keypair, or `authorizer " + "local authorizer record - use a different keypair, or `authorizer " "list` if you meant to check its status" ) existing = storage.get(new_fp) @@ -41,7 +38,7 @@ def run(config, storage, args) -> None: existing_friendly = existing.get("meta", {}).get("friendly", "?") raise CommandError( f"target key is already authorized as '{existing_friendly}'; " - f"authorize would silently replace that record — revoke it first " + f"authorize would silently replace that record - revoke it first " f"if you intend to re-authorize it" ) diff --git a/src/envelope_authorizer/commands/config_init.py b/src/envelope_authorizer/commands/config_init.py index 4bc8dd3..64e017f 100644 --- a/src/envelope_authorizer/commands/config_init.py +++ b/src/envelope_authorizer/commands/config_init.py @@ -1,4 +1,4 @@ -"""`authorizer config init` — scaffold a starter .authorizer.toml in cwd""" +"""`authorizer config init` - scaffold a starter .authorizer.toml in cwd""" from pathlib import Path @@ -29,7 +29,7 @@ def run(config, storage, args) -> None: """write a commented starter config; refuse to overwrite an existing one""" target = Path.cwd() / CONFIG_NAME if target.exists(): - raise CommandError(f"{target} already exists — refusing to overwrite") + raise CommandError(f"{target} already exists - refusing to overwrite") with open(target, "w", encoding="utf-8") as handle: handle.write(_TEMPLATE) print(f"[✔] Wrote starter config: {target}") diff --git a/src/envelope_authorizer/commands/init.py b/src/envelope_authorizer/commands/init.py index 5beca8c..e47b2ca 100644 --- a/src/envelope_authorizer/commands/init.py +++ b/src/envelope_authorizer/commands/init.py @@ -1,8 +1,7 @@ -"""`authorizer init` — create a fresh DEK and authorize the local key +"""`authorizer init` - create a fresh DEK and authorize the local key generates a new AES data key, wraps it to the local public key, and stores the -first key doc marked as an authorizer (allowed: True). refuses if the system is -already initialized or the friendly name is taken. +first key doc marked as an authorizer (allowed: True). """ from envelope_crypto import EnvelopeCrypto @@ -14,7 +13,7 @@ def run(config, storage, args) -> None: """initialize the key system on this machine as the first authorizer the already-initialized / duplicate-friendly checks are non-atomic (TOCTOU under two - concurrent CLIs) by design — one-shot admin tool, `save` upserts by `_id`, so the + concurrent CLIs) by design - one-shot admin tool, `save` upserts by `_id`, so the worst case is a cosmetic double-init with no security consequence. """ if storage.get_all(): @@ -33,4 +32,4 @@ def run(config, storage, args) -> None: doc = build_doc(fingerprint, wrapped, flag, config.identity, args.friendly) storage.save(doc) - print(f"[✔] Initialized — fingerprint: {fingerprint} | friendly: {args.friendly} [authorizer=True]") + print(f"[✔] Initialized - fingerprint: {fingerprint} | friendly: {args.friendly} [authorizer=True]") diff --git a/src/envelope_authorizer/commands/list_keys.py b/src/envelope_authorizer/commands/list_keys.py index 384aa81..e8682f2 100644 --- a/src/envelope_authorizer/commands/list_keys.py +++ b/src/envelope_authorizer/commands/list_keys.py @@ -1,8 +1,8 @@ -"""`authorizer list` — show every authorized key in a padded table +"""`authorizer list` - show every authorized key in a padded table boots the local DEK once, then for each doc decrypts its capability flag to show -CAN_AUTHORIZE. a doc the local key cannot unwrap shows `?` rather than crashing. -prints only fingerprint/metadata — never the wrapped key or DEK. +CAN_AUTHORIZE (`?` if unreadable here). prints only fingerprint/metadata - never +the wrapped key or DEK. """ from datetime import datetime, timezone @@ -11,11 +11,7 @@ from . import boot_local, read_flag def _can_authorize(crypto, doc) -> str: - """decrypted authority of a doc as Yes/No, or `?` if unreadable here - - `?` covers any failure (unwrap mismatch, missing/malformed doc) so the table - always renders instead of crashing on one bad row. - """ + """decrypted authority of a doc as Yes/No, or `?` if unreadable here""" try: return "Yes" if read_flag(crypto, doc["meta"]["authorizer"]) else "No" except Exception: @@ -36,8 +32,6 @@ def _created(meta: dict) -> str: try: return datetime.fromtimestamp(int(raw), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S") except (TypeError, ValueError, OverflowError, OSError): - # one hand-edited/legacy doc with a bad created_at shouldn't abort the - # whole table render return "-" diff --git a/src/envelope_authorizer/commands/revoke.py b/src/envelope_authorizer/commands/revoke.py index 982afb4..f064c5f 100644 --- a/src/envelope_authorizer/commands/revoke.py +++ b/src/envelope_authorizer/commands/revoke.py @@ -1,9 +1,8 @@ -"""`authorizer revoke` — remove a key's authorization record +"""`authorizer revoke` - remove a key's authorization record finds the target by friendly name or fingerprint prefix, refuses to revoke the -local key, deletes the record, and prints an honesty warning that revoke is -bookkeeping only — it does not rotate the DEK or scrub it from a machine that -already unwrapped it. +local key, and deletes the record. revoke is bookkeeping only - it does not +rotate the DEK or scrub it from a machine that already unwrapped it. """ from envelope_crypto import EnvelopeCrypto @@ -18,10 +17,9 @@ _WARNING = ( def _find_by_fingerprint(storage, prefix: str): - """return the single doc whose `_id` starts with the given prefix, or None + """return the single doc whose `_id` starts with the prefix, or None - rejects an empty prefix (matches everything) and an ambiguous one (matches - more than one key) instead of silently revoking the first hit. + rejects an empty or ambiguous prefix instead of silently revoking the first hit. """ if not prefix: raise CommandError("fingerprint prefix must not be empty") diff --git a/src/envelope_authorizer/commands/verify.py b/src/envelope_authorizer/commands/verify.py index bbf7c78..0beed46 100644 --- a/src/envelope_authorizer/commands/verify.py +++ b/src/envelope_authorizer/commands/verify.py @@ -1,8 +1,8 @@ -"""`authorizer verify` — health-check the local crypto setup +"""`authorizer verify` - health-check the local crypto setup boots the local DEK, then runs envelope_crypto's self_test against the local -keypair. if the installed envelope_crypto lacks self_test, falls back to a -minimal in-CLI round-trip. never prints key material. +keypair (falls back to a minimal in-CLI round-trip if self_test is absent). +never prints key material. """ from . import CommandError, boot_local diff --git a/src/envelope_authorizer/config.py b/src/envelope_authorizer/config.py index 9868abf..674c8d4 100644 --- a/src/envelope_authorizer/config.py +++ b/src/envelope_authorizer/config.py @@ -1,11 +1,8 @@ """TOML config loader for the authorizer CLI resolves a `.authorizer.toml` (cwd first, then ~), expands `~` and `$ENV_VARS` in -every path field (key paths and the JSON storage path alike), and exposes typed -accessors. no defaults are baked in: a missing required field raises a clear -error naming the field and the config path that was searched. the loaded config -is the only place key paths, identity, and storage live — this lib never imports -a host `config` module. +every path field, and exposes typed accessors. no defaults are baked in: a +missing required field raises a clear error naming the field and config path. """ import os diff --git a/src/envelope_authorizer/storage/__init__.py b/src/envelope_authorizer/storage/__init__.py index cc259ff..4ec6f7e 100644 --- a/src/envelope_authorizer/storage/__init__.py +++ b/src/envelope_authorizer/storage/__init__.py @@ -1,7 +1,7 @@ """storage backend resolution for the authorizer CLI `resolve(config)` reads `[storage].backend` and returns the matching sync -StorageBackend (json or mongo). the mongo backend is guarded — importing this +StorageBackend (json or mongo). the mongo backend is guarded - importing this package never requires the mongo extra; only selecting the mongo backend does. """ diff --git a/src/envelope_authorizer/storage/base.py b/src/envelope_authorizer/storage/base.py index a3c5bcc..46cbc2a 100644 --- a/src/envelope_authorizer/storage/base.py +++ b/src/envelope_authorizer/storage/base.py @@ -1,7 +1,7 @@ """abstract storage interface for authorizer key documents a backend persists key docs keyed by `_id` (the RSA fingerprint). the interface -is intentionally sync — the CLI is a one-shot tool; an async backend (mongo) +is intentionally sync - the CLI is a one-shot tool; an async backend (mongo) bridges to sync internally. `save` is an upsert by `_id`, never a duplicate- inserting or duplicate-raising operation. """ diff --git a/src/envelope_authorizer/storage/json_store.py b/src/envelope_authorizer/storage/json_store.py index 0fcb68c..ef329ed 100644 --- a/src/envelope_authorizer/storage/json_store.py +++ b/src/envelope_authorizer/storage/json_store.py @@ -35,12 +35,7 @@ class JsonStore(StorageBackend): return data def _write(self, docs: List[dict]) -> None: - """atomically write the docs list (unique temp file then os.replace) - - a unique temp per write (tempfile.mkstemp) keeps concurrent writers from - clobbering a shared `.tmp`; the temp is cleaned up if the write fails - before replace. - """ + """atomically write the docs list via a unique temp file then os.replace""" self.path.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp( dir=self.path.parent, prefix=self.path.name + ".", suffix=".tmp" @@ -48,13 +43,12 @@ class JsonStore(StorageBackend): wrapped = False try: with os.fdopen(fd, "w", encoding="utf-8") as handle: - wrapped = True # fdopen owns fd now; its close() handles it + wrapped = True json.dump(docs, handle, indent=2) handle.write("\n") os.replace(tmp, self.path) except BaseException: if not wrapped: - # fdopen raised before taking ownership — close the raw fd ourselves try: os.close(fd) except OSError: diff --git a/src/envelope_authorizer/storage/mongo_store.py b/src/envelope_authorizer/storage/mongo_store.py index 4da7368..c0ee405 100644 --- a/src/envelope_authorizer/storage/mongo_store.py +++ b/src/envelope_authorizer/storage/mongo_store.py @@ -2,15 +2,14 @@ bridges the async rethink-public `mongo` lib to the sync StorageBackend: each op gets its own asyncio.run (connect -> op -> close), no module-level client or -shared loop, so it never collides with a running loop. per-call connect is an -accepted tradeoff for a one-shot admin CLI. requires envelope_authorizer[mongo]; -without it every method raises a clear RuntimeError. +shared loop. requires envelope_authorizer[mongo]; without it every method raises +a clear RuntimeError. -fail-loud: ops go through the mongo lib's raw collection escape hatch (the motor -collection, which raises), not the swallow-and-default wrapped methods — this is -an auth backend, so a driver error must never read as "not initialized," and a -no-op upsert raises instead of silently reporting success. `close()` is -synchronous on the mongo lib — not awaited. +fail-loud: ops go through the mongo lib's raw collection escape hatch, not the +swallow-and-default wrapped methods - this is an auth backend, so a driver error +must never read as "not initialized," and a no-op upsert raises instead of +silently reporting success. `close()` is synchronous on the mongo lib - not +awaited. """ import asyncio