diff --git a/README.md b/README.md index c5537f6..0e9a750 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.4 +envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.5 ``` Direct: ```bash -pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.4" +pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.5" ``` 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.4" +pip install "envelope_authorizer[mongo] @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.5" ``` 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.4` suffix from the line above to install the latest unpinned. +Drop the `@v0.1.5` suffix from the line above to install the latest unpinned. ## Trust model (read this) @@ -174,9 +174,10 @@ rotation. ## Config reference -TOML, searched cwd-first then `~`: `.authorizer.toml`. Paths expand `~`. No -defaults are baked in — a missing required field raises an error naming the field -and the config path. +TOML, searched cwd-first then `~`: `.authorizer.toml`. Paths (`keys.public`, +`keys.private`, `storage.path`) expand both `~` and `$ENV_VARS`. No defaults are +baked in — a missing required field raises an error naming the field and the +config path. ### JSON backend (default, stdlib only) @@ -185,6 +186,7 @@ and the config path. public = "~/.ssh/id_rsa.pub" private = "~/.ssh/id_rsa" identity = "user@hostname" # stamped as created_by on every key doc +# password = "..." # only if the private key above is encrypted [storage] backend = "json" diff --git a/pyproject.toml b/pyproject.toml index 4bf605c..b9df23d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "envelope_authorizer" -version = "0.1.4" +version = "0.1.5" 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 bbab024..1276d02 100644 --- a/src/envelope_authorizer/__init__.py +++ b/src/envelope_authorizer/__init__.py @@ -1 +1 @@ -__version__ = "0.1.4" +__version__ = "0.1.5" diff --git a/src/envelope_authorizer/cli.py b/src/envelope_authorizer/cli.py index 4a8b357..1ce66c5 100644 --- a/src/envelope_authorizer/cli.py +++ b/src/envelope_authorizer/cli.py @@ -16,6 +16,12 @@ from .config import ConfigError, load_config from .commands import CommandError, authorize, config_init, init, list_keys, revoke, verify from .storage import resolve +try: + from pymongo.errors import PyMongoError +except ImportError: + class PyMongoError(Exception): + """stand-in when the [mongo] extra is not installed; never actually raised""" + def _build_parser() -> argparse.ArgumentParser: """construct the argument parser with all subcommands""" @@ -94,6 +100,8 @@ def main() -> int: return 0 except InvalidTag: 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 diff --git a/src/envelope_authorizer/commands/__init__.py b/src/envelope_authorizer/commands/__init__.py index 25c97cd..0d24d70 100644 --- a/src/envelope_authorizer/commands/__init__.py +++ b/src/envelope_authorizer/commands/__init__.py @@ -63,7 +63,9 @@ def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]: raise CommandError( "not initialized on this machine (no key doc for the local public key)" ) - aes_key = crypto.decrypt_aes_key_with_rsa(doc["key"], config.private_key) + aes_key = crypto.decrypt_aes_key_with_rsa( + doc["key"], config.private_key, password=config.private_key_password + ) crypto.initialize(aes_key) return crypto, doc diff --git a/src/envelope_authorizer/commands/authorize.py b/src/envelope_authorizer/commands/authorize.py index 2f67a43..e2cd601 100644 --- a/src/envelope_authorizer/commands/authorize.py +++ b/src/envelope_authorizer/commands/authorize.py @@ -3,10 +3,9 @@ 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 fingerprints to an existing `_id` — `save` upserts by `_id`, so -authorizing a key that is already on record (most dangerously the local key -itself) would silently replace that doc's capability flag and friendly name -under a success banner instead of adding a new key. +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. """ from . import ( diff --git a/src/envelope_authorizer/commands/config_init.py b/src/envelope_authorizer/commands/config_init.py index e465b67..4bc8dd3 100644 --- a/src/envelope_authorizer/commands/config_init.py +++ b/src/envelope_authorizer/commands/config_init.py @@ -10,6 +10,7 @@ _TEMPLATE = """[keys] public = "~/.ssh/id_rsa.pub" private = "~/.ssh/id_rsa" identity = "user@hostname" +# password = "..." # only if the private key above is encrypted [storage] backend = "json" diff --git a/src/envelope_authorizer/commands/init.py b/src/envelope_authorizer/commands/init.py index 595c3ae..5beca8c 100644 --- a/src/envelope_authorizer/commands/init.py +++ b/src/envelope_authorizer/commands/init.py @@ -13,11 +13,9 @@ from . import CommandError, build_doc, find_by_friendly, make_flag 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 (a check-then-act - TOCTOU under two concurrent CLIs), but this is a one-shot human admin tool and `save` - upserts by `_id`, so key material can never collide — the worst case is a cosmetic - double-init under a race, which carries no security consequence in the trusted- - DEK-holder threat model. left non-atomic by design. + 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 + worst case is a cosmetic double-init with no security consequence. """ if storage.get_all(): raise CommandError( diff --git a/src/envelope_authorizer/commands/list_keys.py b/src/envelope_authorizer/commands/list_keys.py index 8c810f1..384aa81 100644 --- a/src/envelope_authorizer/commands/list_keys.py +++ b/src/envelope_authorizer/commands/list_keys.py @@ -11,11 +11,10 @@ from . import boot_local, read_flag def _can_authorize(crypto, doc) -> str: - """decrypted authority of a doc as Yes/No, or `?` if not readable here + """decrypted authority of a doc as Yes/No, or `?` if unreadable here - `?` means the flag could not be read for ANY reason — the local key can't unwrap it, - or the doc is missing/malformed — so the table always renders rather than crashing on - one bad row. it is not specifically a corruption signal. + `?` covers any failure (unwrap mismatch, missing/malformed doc) so the table + always renders instead of crashing on one bad row. """ try: return "Yes" if read_flag(crypto, doc["meta"]["authorizer"]) else "No" @@ -23,9 +22,15 @@ def _can_authorize(crypto, doc) -> str: return "?" -def _created(doc) -> str: +def _meta(doc) -> dict: + """the doc's meta block as a dict, coercing a missing/null/malformed one to {}""" + meta = doc.get("meta") + return meta if isinstance(meta, dict) else {} + + +def _created(meta: dict) -> str: """format created_at as a UTC timestamp string, or '-' if absent/unparseable""" - raw = doc.get("meta", {}).get("created_at") + raw = meta.get("created_at") if not raw: return "-" try: @@ -45,12 +50,12 @@ def run(config, storage, args) -> None: print(header) print("-" * len(header)) for doc in docs: - meta = doc.get("meta", {}) + meta = _meta(doc) print( f"{doc.get('_id', '')[:16]:<18} " f"{str(meta.get('friendly', '-')):<16} " f"{str(meta.get('created_by', '-')):<18} " - f"{_created(doc):<21} " + f"{_created(meta):<21} " f"{_can_authorize(crypto, doc):<8}" ) print(f"\n{len(docs)} key(s) authorized") diff --git a/src/envelope_authorizer/commands/revoke.py b/src/envelope_authorizer/commands/revoke.py index 6403007..982afb4 100644 --- a/src/envelope_authorizer/commands/revoke.py +++ b/src/envelope_authorizer/commands/revoke.py @@ -20,8 +20,8 @@ _WARNING = ( def _find_by_fingerprint(storage, prefix: str): """return the single doc whose `_id` starts with the given prefix, or None - rejects an empty prefix (which would match every key) and an ambiguous prefix - that matches more than one key, rather than silently revoking the first match. + rejects an empty prefix (matches everything) and an ambiguous one (matches + more than one key) instead of silently revoking the first hit. """ if not prefix: raise CommandError("fingerprint prefix must not be empty") @@ -34,7 +34,7 @@ def _find_by_fingerprint(storage, prefix: str): def run(config, storage, args) -> None: """delete a key record by friendly or fingerprint, guarding the local key""" - if args.friendly: + if args.friendly is not None: doc = find_by_friendly(storage, args.friendly) label = args.friendly else: diff --git a/src/envelope_authorizer/commands/verify.py b/src/envelope_authorizer/commands/verify.py index 4d2136d..bbf7c78 100644 --- a/src/envelope_authorizer/commands/verify.py +++ b/src/envelope_authorizer/commands/verify.py @@ -13,7 +13,7 @@ def run(config, storage, args) -> None: crypto, _ = boot_local(config, storage) if hasattr(crypto, "self_test"): - crypto.self_test(config.public_key, config.private_key) + crypto.self_test(config.public_key, config.private_key, password=config.private_key_password) else: sample = {"_authorizer_verify": "ok", "n": 12345} if crypto.decrypt_data(crypto.encrypt_data(sample)) != sample: diff --git a/src/envelope_authorizer/config.py b/src/envelope_authorizer/config.py index 2db6ca0..9868abf 100644 --- a/src/envelope_authorizer/config.py +++ b/src/envelope_authorizer/config.py @@ -1,16 +1,17 @@ """TOML config loader for the authorizer CLI -resolves a `.authorizer.toml` (cwd first, then ~), expands user paths, 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. +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. """ import os import sys from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional if sys.version_info >= (3, 11): import tomllib @@ -72,6 +73,11 @@ class Config: """path to the local RSA private key (expanded)""" return _expand(self.require("keys", "private")) + @property + def private_key_password(self) -> Optional[str]: + """passphrase for an encrypted local private key, or None if unset""" + return self.optional("keys", "password") + @property def identity(self) -> str: """human identity stamped as created_by on every key doc""" @@ -82,6 +88,11 @@ class Config: """selected storage backend name ("json" or "mongo")""" return self.require("storage", "backend") + @property + def storage_path(self) -> str: + """path to the JSON storage file (expanded, same as the key paths)""" + return _expand(self.require("storage", "path")) + def load_config() -> Config: """find and parse the authorizer config, or raise with guidance diff --git a/src/envelope_authorizer/storage/__init__.py b/src/envelope_authorizer/storage/__init__.py index a2f7766..cc259ff 100644 --- a/src/envelope_authorizer/storage/__init__.py +++ b/src/envelope_authorizer/storage/__init__.py @@ -13,8 +13,7 @@ def resolve(config) -> StorageBackend: """build the storage backend named by the config, or raise on unknown""" backend = config.storage_backend if backend == "json": - path = config.require("storage", "path") - return JsonStore(path) + return JsonStore(config.storage_path) if backend == "mongo": from .mongo_store import MongoStore return MongoStore( diff --git a/src/envelope_authorizer/storage/json_store.py b/src/envelope_authorizer/storage/json_store.py index 940a107..0fcb68c 100644 --- a/src/envelope_authorizer/storage/json_store.py +++ b/src/envelope_authorizer/storage/json_store.py @@ -35,11 +35,11 @@ class JsonStore(StorageBackend): return data def _write(self, docs: List[dict]) -> None: - """atomically write the docs list (unique temp file then replace) + """atomically write the docs list (unique temp file then os.replace) - the temp name is unique per write (tempfile.mkstemp in the same dir) so two - concurrent writers can't clobber a shared `.tmp`; os.replace is atomic on the - same filesystem. the temp is cleaned up if the write fails before 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. """ self.path.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp( @@ -48,14 +48,13 @@ class JsonStore(StorageBackend): wrapped = False try: with os.fdopen(fd, "w", encoding="utf-8") as handle: - wrapped = True # fdopen took ownership of fd; its close() handles it + wrapped = True # fdopen owns fd now; its close() handles it 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 so - # it isn't leaked (the `with` only closes once fdopen returns a file object) + # 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 238fd8d..4da7368 100644 --- a/src/envelope_authorizer/storage/mongo_store.py +++ b/src/envelope_authorizer/storage/mongo_store.py @@ -1,19 +1,16 @@ """mongo storage backend (behind the [mongo] extra) -bridges the async rethink-public `mongo` lib to the sync StorageBackend by -wrapping each operation in its own asyncio.run: connect -> op -> close, fully -self-contained per call. no module-level client and no shared event loop, so it -never collides with a running loop. a 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. +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. -fail-loud: operations go through the mongo lib's RAW collection escape hatch -(`db.collection(name)`, the motor collection — which raises) rather than the -swallow-and-return-default wrapped methods. this is an auth backend; conflating -a backend error with "no document / not initialized" in the thing that gates -authority is the worst place for the swallow anti-pattern, so a driver error -propagates and a no-op upsert raises instead of silently reporting success. -`close()` is synchronous on the mongo lib (motor's close is sync) — not awaited. +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. """ import asyncio