fix: clean mongo errors, null-meta list crash, empty-friendly revoke, key passphrase, storage path env expansion (v0.1.5)

envauth-2: catch PyMongoError in cli.py (guarded import) so an unreachable
mongo backend fails fast with a clean [x] line instead of an uncaught
traceback after the driver's server-selection timeout.

envauth-3: coerce a non-dict meta block to {} before rendering `list`, so a
"meta": null doc no longer crashes the table mid-render.

envauth-4: revoke checks args.friendly is not None instead of truthiness, so
--friendly "" correctly routes to the friendly-name lookup instead of
misrouting to the fingerprint branch.

envauth-5: add an optional [keys].password config field and thread it through
boot_local's decrypt_aes_key_with_rsa and verify's self_test, so an encrypted
local private key can be unwrapped.

envauth-6: add Config.storage_path (routed through the existing _expand
helper) so the JSON storage path expands $ENV_VARS the same way the key
paths already do, not just ~.

envauth-7: verified CLAUDE.md and README already correctly describe
authorize's re-run-is-refused behavior (fixed under envauth-1); no doc
change needed.

Also compresses several essay-length docstrings/comments (init's TOCTOU
note, authorize's replace-guard note, mongo_store's module docstring,
json_store's _write docstring, list_keys' _can_authorize docstring) with no
behavior change; re-verified the full CLI flow (JSON + live mongod) after.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-02 23:32:10 -04:00
parent 0d0558d11b
commit f150f7f957
15 changed files with 80 additions and 59 deletions
+9 -7
View File
@@ -13,26 +13,26 @@ authorization system and the key-document schema; the crypto primitives live in
## Install ## 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: Direct:
```bash ```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 The base install uses a local JSON file for storage (stdlib only). For shared
dev→server storage, install the mongo extra: dev→server storage, install the mongo extra:
```bash ```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, Installing pulls `envelope_crypto` (and `mongo` with the extra). After install,
the `authorizer` command is on your PATH; `python -m envelope_authorizer` also works. 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) ## Trust model (read this)
@@ -174,9 +174,10 @@ rotation.
## Config reference ## Config reference
TOML, searched cwd-first then `~`: `.authorizer.toml`. Paths expand `~`. No TOML, searched cwd-first then `~`: `.authorizer.toml`. Paths (`keys.public`,
defaults are baked in — a missing required field raises an error naming the field `keys.private`, `storage.path`) expand both `~` and `$ENV_VARS`. No defaults are
and the config path. baked in — a missing required field raises an error naming the field and the
config path.
### JSON backend (default, stdlib only) ### JSON backend (default, stdlib only)
@@ -185,6 +186,7 @@ and the config path.
public = "~/.ssh/id_rsa.pub" public = "~/.ssh/id_rsa.pub"
private = "~/.ssh/id_rsa" private = "~/.ssh/id_rsa"
identity = "user@hostname" # stamped as created_by on every key doc identity = "user@hostname" # stamped as created_by on every key doc
# password = "..." # only if the private key above is encrypted
[storage] [storage]
backend = "json" backend = "json"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "envelope_authorizer" name = "envelope_authorizer"
version = "0.1.4" version = "0.1.5"
description = "CLI key-authorization manager for envelope_crypto" description = "CLI key-authorization manager for envelope_crypto"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.4" __version__ = "0.1.5"
+8
View File
@@ -16,6 +16,12 @@ from .config import ConfigError, load_config
from .commands import CommandError, authorize, config_init, init, list_keys, revoke, verify from .commands import CommandError, authorize, config_init, init, list_keys, revoke, verify
from .storage import resolve 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: def _build_parser() -> argparse.ArgumentParser:
"""construct the argument parser with all subcommands""" """construct the argument parser with all subcommands"""
@@ -94,6 +100,8 @@ def main() -> int:
return 0 return 0
except InvalidTag: 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: except (ConfigError, CommandError, RuntimeError, ValueError, OSError, KeyError, TypeError) as error:
# OSError covers the FileNotFoundError/PermissionError/IsADirectoryError family; # OSError covers the FileNotFoundError/PermissionError/IsADirectoryError family;
# KeyError/TypeError cover a structurally-malformed flag/doc (unguarded indexing # KeyError/TypeError cover a structurally-malformed flag/doc (unguarded indexing
+3 -1
View File
@@ -63,7 +63,9 @@ def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]:
raise CommandError( raise CommandError(
"not initialized on this machine (no key doc for the local public key)" "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) crypto.initialize(aes_key)
return crypto, doc return crypto, doc
@@ -3,10 +3,9 @@
boots the local DEK, verifies the local key is itself an authorizer, then wraps 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` 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 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 a target key that already has a record — `save` upserts by `_id`, so re-authorizing
authorizing a key that is already on record (most dangerously the local key a known key (most dangerously the local key itself) would silently overwrite its
itself) would silently replace that doc's capability flag and friendly name flag/friendly under a success banner instead of adding a new key.
under a success banner instead of adding a new key.
""" """
from . import ( from . import (
@@ -10,6 +10,7 @@ _TEMPLATE = """[keys]
public = "~/.ssh/id_rsa.pub" public = "~/.ssh/id_rsa.pub"
private = "~/.ssh/id_rsa" private = "~/.ssh/id_rsa"
identity = "user@hostname" identity = "user@hostname"
# password = "..." # only if the private key above is encrypted
[storage] [storage]
backend = "json" backend = "json"
+3 -5
View File
@@ -13,11 +13,9 @@ from . import CommandError, build_doc, find_by_friendly, make_flag
def run(config, storage, args) -> None: def run(config, storage, args) -> None:
"""initialize the key system on this machine as the first authorizer """initialize the key system on this machine as the first authorizer
the already-initialized / duplicate-friendly checks are non-atomic (a check-then-act the already-initialized / duplicate-friendly checks are non-atomic (TOCTOU under two
TOCTOU under two concurrent CLIs), but this is a one-shot human admin tool and `save` concurrent CLIs) by design — one-shot admin tool, `save` upserts by `_id`, so the
upserts by `_id`, so key material can never collide — the worst case is a cosmetic worst case is a cosmetic double-init with no security consequence.
double-init under a race, which carries no security consequence in the trusted-
DEK-holder threat model. left non-atomic by design.
""" """
if storage.get_all(): if storage.get_all():
raise CommandError( raise CommandError(
+13 -8
View File
@@ -11,11 +11,10 @@ from . import boot_local, read_flag
def _can_authorize(crypto, doc) -> str: 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, `?` covers any failure (unwrap mismatch, missing/malformed doc) so the table
or the doc is missing/malformed — so the table always renders rather than crashing on always renders instead of crashing on one bad row.
one bad row. it is not specifically a corruption signal.
""" """
try: try:
return "Yes" if read_flag(crypto, doc["meta"]["authorizer"]) else "No" return "Yes" if read_flag(crypto, doc["meta"]["authorizer"]) else "No"
@@ -23,9 +22,15 @@ def _can_authorize(crypto, doc) -> str:
return "?" 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""" """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: if not raw:
return "-" return "-"
try: try:
@@ -45,12 +50,12 @@ def run(config, storage, args) -> None:
print(header) print(header)
print("-" * len(header)) print("-" * len(header))
for doc in docs: for doc in docs:
meta = doc.get("meta", {}) meta = _meta(doc)
print( print(
f"{doc.get('_id', '')[:16]:<18} " f"{doc.get('_id', '')[:16]:<18} "
f"{str(meta.get('friendly', '-')):<16} " f"{str(meta.get('friendly', '-')):<16} "
f"{str(meta.get('created_by', '-')):<18} " f"{str(meta.get('created_by', '-')):<18} "
f"{_created(doc):<21} " f"{_created(meta):<21} "
f"{_can_authorize(crypto, doc):<8}" f"{_can_authorize(crypto, doc):<8}"
) )
print(f"\n{len(docs)} key(s) authorized") print(f"\n{len(docs)} key(s) authorized")
+3 -3
View File
@@ -20,8 +20,8 @@ _WARNING = (
def _find_by_fingerprint(storage, prefix: str): 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 given prefix, or None
rejects an empty prefix (which would match every key) and an ambiguous prefix rejects an empty prefix (matches everything) and an ambiguous one (matches
that matches more than one key, rather than silently revoking the first match. more than one key) instead of silently revoking the first hit.
""" """
if not prefix: if not prefix:
raise CommandError("fingerprint prefix must not be empty") 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: def run(config, storage, args) -> None:
"""delete a key record by friendly or fingerprint, guarding the local key""" """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) doc = find_by_friendly(storage, args.friendly)
label = args.friendly label = args.friendly
else: else:
+1 -1
View File
@@ -13,7 +13,7 @@ def run(config, storage, args) -> None:
crypto, _ = boot_local(config, storage) crypto, _ = boot_local(config, storage)
if hasattr(crypto, "self_test"): 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: else:
sample = {"_authorizer_verify": "ok", "n": 12345} sample = {"_authorizer_verify": "ok", "n": 12345}
if crypto.decrypt_data(crypto.encrypt_data(sample)) != sample: if crypto.decrypt_data(crypto.encrypt_data(sample)) != sample:
+17 -6
View File
@@ -1,16 +1,17 @@
"""TOML config loader for the authorizer CLI """TOML config loader for the authorizer CLI
resolves a `.authorizer.toml` (cwd first, then ~), expands user paths, and resolves a `.authorizer.toml` (cwd first, then ~), expands `~` and `$ENV_VARS` in
exposes typed accessors. no defaults are baked in: a missing required field every path field (key paths and the JSON storage path alike), and exposes typed
raises a clear error naming the field and the config path that was searched. accessors. no defaults are baked in: a missing required field raises a clear
the loaded config is the only place key paths, identity, and storage live — error naming the field and the config path that was searched. the loaded config
this lib never imports a host `config` module. is the only place key paths, identity, and storage live — this lib never imports
a host `config` module.
""" """
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict, List, Optional
if sys.version_info >= (3, 11): if sys.version_info >= (3, 11):
import tomllib import tomllib
@@ -72,6 +73,11 @@ class Config:
"""path to the local RSA private key (expanded)""" """path to the local RSA private key (expanded)"""
return _expand(self.require("keys", "private")) 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 @property
def identity(self) -> str: def identity(self) -> str:
"""human identity stamped as created_by on every key doc""" """human identity stamped as created_by on every key doc"""
@@ -82,6 +88,11 @@ class Config:
"""selected storage backend name ("json" or "mongo")""" """selected storage backend name ("json" or "mongo")"""
return self.require("storage", "backend") 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: def load_config() -> Config:
"""find and parse the authorizer config, or raise with guidance """find and parse the authorizer config, or raise with guidance
+1 -2
View File
@@ -13,8 +13,7 @@ def resolve(config) -> StorageBackend:
"""build the storage backend named by the config, or raise on unknown""" """build the storage backend named by the config, or raise on unknown"""
backend = config.storage_backend backend = config.storage_backend
if backend == "json": if backend == "json":
path = config.require("storage", "path") return JsonStore(config.storage_path)
return JsonStore(path)
if backend == "mongo": if backend == "mongo":
from .mongo_store import MongoStore from .mongo_store import MongoStore
return MongoStore( return MongoStore(
@@ -35,11 +35,11 @@ class JsonStore(StorageBackend):
return data return data
def _write(self, docs: List[dict]) -> None: 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 a unique temp per write (tempfile.mkstemp) keeps concurrent writers from
concurrent writers can't clobber a shared `.tmp`; os.replace is atomic on the clobbering a shared `.tmp`; the temp is cleaned up if the write fails
same filesystem. the temp is cleaned up if the write fails before replace. before replace.
""" """
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp( fd, tmp = tempfile.mkstemp(
@@ -48,14 +48,13 @@ class JsonStore(StorageBackend):
wrapped = False wrapped = False
try: try:
with os.fdopen(fd, "w", encoding="utf-8") as handle: 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) json.dump(docs, handle, indent=2)
handle.write("\n") handle.write("\n")
os.replace(tmp, self.path) os.replace(tmp, self.path)
except BaseException: except BaseException:
if not wrapped: if not wrapped:
# fdopen raised before taking ownership — close the raw fd ourselves so # fdopen raised before taking ownership — close the raw fd ourselves
# it isn't leaked (the `with` only closes once fdopen returns a file object)
try: try:
os.close(fd) os.close(fd)
except OSError: except OSError:
+10 -13
View File
@@ -1,19 +1,16 @@
"""mongo storage backend (behind the [mongo] extra) """mongo storage backend (behind the [mongo] extra)
bridges the async rethink-public `mongo` lib to the sync StorageBackend by bridges the async rethink-public `mongo` lib to the sync StorageBackend: each op
wrapping each operation in its own asyncio.run: connect -> op -> close, fully gets its own asyncio.run (connect -> op -> close), no module-level client or
self-contained per call. no module-level client and no shared event loop, so it shared loop, so it never collides with a running loop. per-call connect is an
never collides with a running loop. a per-call connect is an accepted tradeoff accepted tradeoff for a one-shot admin CLI. requires envelope_authorizer[mongo];
for a one-shot admin CLI. requires envelope_authorizer[mongo]; without it every without it every method raises a clear RuntimeError.
method raises a clear RuntimeError.
fail-loud: operations go through the mongo lib's RAW collection escape hatch fail-loud: ops go through the mongo lib's raw collection escape hatch (the motor
(`db.collection(name)`, the motor collection — which raises) rather than the collection, which raises), not the swallow-and-default wrapped methods — this is
swallow-and-return-default wrapped methods. this is an auth backend; conflating an auth backend, so a driver error must never read as "not initialized," and a
a backend error with "no document / not initialized" in the thing that gates no-op upsert raises instead of silently reporting success. `close()` is
authority is the worst place for the swallow anti-pattern, so a driver error synchronous on the mongo lib — not awaited.
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.
""" """
import asyncio import asyncio