14 Commits
Author SHA1 Message Date
dsql 1bb4979b8c release: 1.0.0
first stable release. pre-1.0.0 verification complete: all surviving MED regressions and
gaps resolved and independently re-fired, tree audited clean across the suite.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 21:21:00 -04:00
dsql bf7e50f91f fix: list tolerates a null _id; declare the cryptography dependency
A stored doc with a present-but-null _id crashed the whole list table: doc.get('_id',
'')[:16] slices None (the '' default only applies when _id is absent), so one bad doc
aborted the render - now uses (doc.get('_id') or '')[:16], mirroring revoke's finder, so
it renders a blank placeholder row. cli.py imports cryptography.exceptions.InvalidTag but
pyproject only pulled cryptography transitively via envelope_crypto - declare it directly.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 19:31:38 -04:00
dsql e5c3547c51 fix: a stored key doc with meta:null no longer crashes authorize/revoke
find_by_friendly, authorize.py's existing-target check, and revoke.py's
success-print all did doc.get("meta", {}).get(...) directly - the {} default
only applies when the key is ABSENT, not when its value is null (plausible in
shared mongo or a hand-edited JSON store), so a null meta raised AttributeError.
revoke's crash landed AFTER storage.delete() already succeeded, so a completed
revoke was reported as a raw traceback instead of a clean exit. list_keys._meta
already coerced correctly; that fix is now hoisted into commands/__init__.py as
doc_meta, the single source every meta-reading call site goes through. cli.py's
catch tuple also gains AttributeError as defense in depth for any future
unguarded meta access.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-06 00:16:52 -04:00
dsql 764bcb6146 refactor: derive __version__ from package metadata (single source)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 17:00:00 -04:00
dsql 387830084a fix: coerce null _id to empty string in revoke fingerprint match (v0.1.7)
a key doc with an explicit `_id: null` made doc.get("_id", "") return None
instead of the default, so .startswith(prefix) raised an uncaught
AttributeError instead of the contracted clean [✘] + exit 1.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 16:15:53 -04:00
dsql e8ea45c15e docs: compress prose/module docstrings, em-dash->hyphen (de-bloat wave 1)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-03 00:16:10 -04:00
dsql f150f7f957 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>
2026-07-02 23:32:10 -04:00
dsql 0d0558d11b chore: unpin envelope_crypto dependency (track latest release)
envauth used envelope_crypto pinned at v0.1.0, two data-loss fixes behind (the lib
is now v0.1.4). envauth uses only stable crypto primitives (initialize, encrypt_data/
decrypt_data, create_aes_key, *_aes_key_with_rsa, get_rsa_key_fingerprint, self_test)
— never the record-rotation functions whose contract changed — so tracking latest is
safe and keeps the crypto fixes flowing without a manual bump each release.

Verified: full CLI round-trip (init -> authorize server -> privilege gate -> envauth-1
self-authorize refusal -> list) against envelope_crypto v0.1.4 source; all 8 used
primitives present. v0.1.4.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 16:50:55 -04:00
dsql bfeee80712 fix: EA-1 refuse authorize of an already-recorded key (v0.1.3)
authorize never checked the target fingerprint against existing docs before
save()'s upsert-by-_id, so authorizing the local machine's own public key
under a new friendly name silently replaced the local authorizer record
(can_authorize demoted to False) while printing a success banner. With a
sole authorizer this bricks the CLI: authorize refuses (not permitted),
init refuses (already initialized), and revoke of the local key refuses
(refusing to revoke the local key) -- no in-CLI recovery. Mirror revoke's
local-key guard and extend it to any existing _id, so a duplicate target
is refused with a clear message instead of silently replacing the record.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 16:41:09 -04:00
dsql f2e9e5fe35 chore: ignore .claude/ dir (CLAUDE.md now lives under .claude/)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:55:13 -04:00
dsql 88e1eaef39 fix: EA-1 clean error on malformed flag/doc; EA-2 fingerprint ambiguity + fd-leak
EA-1: main dispatch catches KeyError/TypeError so a structurally-malformed flag/doc prints
a clean [x] line instead of a traceback. EA-2: fingerprint revoke rejects an empty prefix
and an ambiguous prefix (was: silently revoked the first match). json_store closes the raw
fd if os.fdopen raises before taking ownership (was: leaked). init TOCTOU documented as
by-design (trusted-DEK model, save upserts by _id). list '?' wording clarified.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:35:03 -04:00
dsql 130c62e31c docs: pin install line to release, note unpinned-latest option
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:13:52 -04:00
dsql 09e6d15e48 docs: show unpinned install line; note tag-pinning for reproducibility
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:07:37 -04:00
dsql a40a7432ef fix: clean error on OS-level write failures in config init and dispatch (v0.1.2)
- config init catches OSError (read-only dir, ENOSPC, gone cwd) alongside CommandError
  and prints a clean [x] line; the main dispatch catches the full OSError family instead
  of only FileNotFoundError (L13)
- document read_flag's fail-closed (non-dict -> not allowed) as a deliberate privilege-
  gate default (nit).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 17:58:09 -04:00
17 changed files with 167 additions and 97 deletions
+1 -1
View File
@@ -5,4 +5,4 @@ dist/
build/
.venv/
.pytest_cache/
CLAUDE.md
.claude/
+16 -9
View File
@@ -13,25 +13,27 @@ 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.1
envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.7
```
Direct:
```bash
pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.1"
pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.7"
```
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.1"
pip install "envelope_authorizer[mongo] @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.7"
```
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.7` suffix from the line above to install the latest unpinned.
## Trust model (read this)
There is one shared **AES data-encryption key (DEK)** per project. Each key doc
@@ -127,7 +129,11 @@ initialized or the friendly name is taken.
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. Omit
`--can-authorize` for servers (`allowed: False`); pass it only for trusted
dev/home machines.
dev/home machines. Refuses a target key whose fingerprint already has a record
(most importantly the local key itself) — `save` upserts by `_id`, so
authorizing an already-known key would silently replace its existing doc
(capability flag and friendly name) under a success banner instead of adding a
new key. Revoke the existing record first if you intend to re-authorize it.
```
[✔] Authorized Jy7k2ey7... | friendly: server1 [can_authorize=False]
@@ -168,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)
@@ -179,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"
@@ -222,5 +230,4 @@ Owned by this lib (not `envelope_crypto`):
## Versioning
Tagged `vX.Y.Z`. Pin the tag. `envelope_crypto` is pinned at `v0.1.0` in
`pyproject.toml`; to change it, edit the pin and re-test.
Releases are tagged `vX.Y.Z`. The install line above pins a release; drop the `@vX.Y.Z` suffix to install the latest unpinned. Pin deliberately for reproducible installs.
+3 -2
View File
@@ -4,11 +4,12 @@ build-backend = "hatchling.build"
[project]
name = "envelope_authorizer"
version = "0.1.1"
version = "1.0.0"
description = "CLI key-authorization manager for envelope_crypto"
requires-python = ">=3.10"
dependencies = [
"envelope_crypto @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_crypto.git@v0.1.0",
"envelope_crypto @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_crypto.git",
"cryptography>=42.0",
"tomli>=2.0; python_version<'3.11'",
]
+6 -1
View File
@@ -1 +1,6 @@
__version__ = "0.1.1"
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("envelope_authorizer")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
+17 -5
View File
@@ -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
@@ -16,6 +15,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"""
@@ -72,7 +77,7 @@ def main() -> int:
try:
config_init.run(None, None, args)
return 0
except CommandError as error:
except (CommandError, OSError) as error:
return _fail(str(error))
parser.parse_args(["config", "--help"])
return 0
@@ -91,8 +96,15 @@ def main() -> int:
handlers[args.cmd](config, storage, args)
return 0
except InvalidTag:
return _fail("capability flag failed authentication tampered or wrong DEK")
except (ConfigError, CommandError, RuntimeError, ValueError, FileNotFoundError) as error:
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, AttributeError,
) as error:
# covers a malformed config/doc/flag (incl. KeyError/TypeError from unguarded
# indexing, AttributeError from a null field a caller assumed was a dict) -
# all print a clean [✘] line, not a raw traceback
return _fail(str(error))
+24 -8
View File
@@ -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
@@ -22,7 +20,11 @@ def make_flag(crypto: EnvelopeCrypto, allowed: bool) -> dict:
def read_flag(crypto: EnvelopeCrypto, blob: dict) -> bool:
"""decrypt a capability flag; return the `allowed` 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.
"""
data = crypto.decrypt_data(blob)
return bool(data.get("allowed", False)) if isinstance(data, dict) else False
@@ -49,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)
@@ -59,14 +61,28 @@ 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
def doc_meta(doc: dict) -> dict:
"""the doc's meta block as a dict, coercing a missing/null/malformed one to {}
a stored doc's `meta` can be null (shared mongo, a hand-edited JSON file) - the
dict.get(key, {}) default only applies when the key is ABSENT, not when its
value is null, so every meta-reading call site must go through this rather than
`doc.get("meta", {})` directly or it raises AttributeError on a null meta.
"""
meta = doc.get("meta")
return meta if isinstance(meta, dict) else {}
def find_by_friendly(storage, friendly: str) -> Optional[dict]:
"""return the doc whose meta.friendly matches, or None"""
for doc in storage.get_all():
if doc.get("meta", {}).get("friendly") == friendly:
if doc_meta(doc).get("friendly") == friendly:
return doc
return None
+23 -7
View File
@@ -1,15 +1,17 @@
"""`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 would silently overwrite its flag/friendly. see
`run()` below for the full refusal contract.
"""
from . import (
CommandError,
boot_local,
build_doc,
doc_meta,
find_by_friendly,
local_fingerprint,
make_flag,
read_flag,
)
@@ -25,9 +27,23 @@ def run(config, storage, args) -> None:
if not read_flag(crypto, local_doc["meta"]["authorizer"]):
raise CommandError("this key is not permitted to authorize others")
new_fp, new_wrapped = crypto.encrypt_aes_key_with_rsa(
crypto.master_key, args.key
)
new_fp = crypto.get_rsa_key_fingerprint(args.key)
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 "
"list` if you meant to check its status"
)
existing = storage.get(new_fp)
if existing:
existing_friendly = doc_meta(existing).get("friendly", "?")
raise CommandError(
f"target key is already authorized as '{existing_friendly}'; "
f"authorize would silently replace that record - revoke it first "
f"if you intend to re-authorize it"
)
_, new_wrapped = crypto.encrypt_aes_key_with_rsa(crypto.master_key, args.key)
flag = make_flag(crypto, args.can_authorize)
doc = build_doc(new_fp, new_wrapped, flag, config.identity, args.friendly)
storage.save(doc)
@@ -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
@@ -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"
@@ -28,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}")
+9 -5
View File
@@ -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
@@ -11,7 +10,12 @@ 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"""
"""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
worst case is a cosmetic double-init with no security consequence.
"""
if storage.get_all():
raise CommandError(
"already initialized; use `authorizer list` to see existing keys"
@@ -28,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]")
+10 -12
View File
@@ -1,33 +1,31 @@
"""`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
from . import boot_local, read_flag
from . import boot_local, doc_meta, 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"""
try:
return "Yes" if read_flag(crypto, doc["meta"]["authorizer"]) else "No"
except Exception:
return "?"
def _created(doc) -> str:
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:
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 "-"
@@ -40,12 +38,12 @@ def run(config, storage, args) -> None:
print(header)
print("-" * len(header))
for doc in docs:
meta = doc.get("meta", {})
meta = doc_meta(doc)
print(
f"{doc.get('_id', '')[:16]:<18} "
f"{(doc.get('_id') or '')[: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")
+17 -12
View File
@@ -1,14 +1,13 @@
"""`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
from . import CommandError, find_by_friendly, local_fingerprint
from . import CommandError, doc_meta, find_by_friendly, local_fingerprint
_WARNING = (
"[!] Revoke removes the record only. It does NOT rotate the DEK or scrub it\n"
@@ -18,16 +17,22 @@ _WARNING = (
def _find_by_fingerprint(storage, prefix: str):
"""return the doc whose `_id` starts with the given prefix, or None"""
for doc in storage.get_all():
if doc.get("_id", "").startswith(prefix):
return doc
return None
"""return the single doc whose `_id` starts with the prefix, or None
rejects an empty or ambiguous prefix instead of silently revoking the first hit.
"""
if not prefix:
raise CommandError("fingerprint prefix must not be empty")
matches = [doc for doc in storage.get_all() if (doc.get("_id") or "").startswith(prefix)]
if len(matches) > 1:
ids = ", ".join(d["_id"][:16] for d in matches)
raise CommandError(f"fingerprint prefix '{prefix}' is ambiguous; matches: {ids}")
return matches[0] if matches else None
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:
@@ -42,6 +47,6 @@ def run(config, storage, args) -> None:
raise CommandError("refusing to revoke the local key")
storage.delete(doc["_id"])
friendly = doc.get("meta", {}).get("friendly", "?")
friendly = doc_meta(doc).get("friendly", "?")
print(f"[✔] Revoked: {friendly} ({doc['_id'][:16]})")
print(_WARNING)
+4 -4
View File
@@ -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
@@ -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:
+14 -6
View File
@@ -1,16 +1,14 @@
"""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, 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
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 +70,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 +85,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
+2 -3
View File
@@ -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.
"""
@@ -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(
+1 -1
View File
@@ -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.
"""
@@ -35,22 +35,24 @@ class JsonStore(StorageBackend):
return data
def _write(self, docs: List[dict]) -> None:
"""atomically write the docs list (unique temp file then 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.
"""
"""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"
)
wrapped = False
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
wrapped = True
json.dump(docs, handle, indent=2)
handle.write("\n")
os.replace(tmp, self.path)
except BaseException:
if not wrapped:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp)
except OSError:
+9 -13
View File
@@ -1,19 +1,15 @@
"""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. 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, 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