2 Commits
17 changed files with 105 additions and 239 deletions
+1 -1
View File
@@ -5,4 +5,4 @@ dist/
build/ build/
.venv/ .venv/
.pytest_cache/ .pytest_cache/
.claude/ CLAUDE.md
+15 -49
View File
@@ -13,27 +13,25 @@ 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@v1.0.0 envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.0
``` ```
Direct: Direct:
```bash ```bash
pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v1.0.0" pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.0"
``` ```
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@v1.0.0" pip install "envelope_authorizer[mongo] @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.0"
``` ```
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 `@v1.0.0` suffix from the line above to install the latest unpinned.
## Trust model (read this) ## Trust model (read this)
There is one shared **AES data-encryption key (DEK)** per project. Each key doc There is one shared **AES data-encryption key (DEK)** per project. Each key doc
@@ -49,39 +47,12 @@ Each key carries an **encrypted capability flag** — `meta.authorizer` =
- **Servers** are authorized with `allowed: False` → they can boot and use the DEK - **Servers** are authorized with `allowed: False` → they can boot and use the DEK
(decrypt project data) but **cannot authorize anything else**. (decrypt project data) but **cannot authorize anything else**.
The flag is GCM-sealed under the DEK on purpose. What that buys, stated honestly: The flag is GCM-sealed under the DEK on purpose: a server **cannot flip its own
flag in the database to escalate**, because forging a valid flag requires already
- **Without the DEK, the flag is inert.** A stolen database at rest cannot be holding the DEK, and editing the stored ciphertext breaks the auth tag. The
escalated — the flags are ciphertext, unforgeable and unflippable without the key guarantee is precisely: *you cannot grant yourself authority without already
(editing the stored ciphertext breaks the GCM auth tag). This stops passive DB having it.* (A legitimate authorizer can of course mint new flags — that is its
tampering. job. This stops passive DB tampering, not an authorizer.)
- **It does not defend against a party that already holds the DEK and can write
storage.** The flag is sealed under the *shared* DEK and is **not bound to key
identity**, so such a party can copy a valid sealed `True` flag from an
authorizer's doc onto its own and self-grant authority. This is **out of scope by
design**: the trust model assumes DEK-holders are trusted. A DEK-holder already
has full data access (DEK + write access is game over), so self-granting authority
exposes no additional data. We deliberately do not add per-key signing to prevent
it — the residual is handled operationally (below).
So the precise boundary is: **the capability flag is unforgeable without the DEK; it
is not a defense against a malicious DEK-holder.** (A legitimate authorizer can of
course mint new flags — that is its job.)
### Operational guidance (the residual control)
Because the DEK-holder case above is **not** prevented cryptographically in the
shared-DEK model, the intended mitigation is **detection, not crypto**. Consumers
should periodically audit the authorization state in storage:
- enumerate every key doc's capability flag (`list` shows authorizer status),
- compare the set of `allowed: True` keys against a known-good list of expected
authorizers,
- alert on any unexpected authorizer.
An unexpected `allowed: True` appearing in storage is the signal that a DEK-holder
self-granted — catch it by review, then revoke and re-authorize from a trusted
machine.
`revoke` is **bookkeeping, not rotation**: it deletes the record so the key can no `revoke` is **bookkeeping, not rotation**: it deletes the record so the key can no
longer unwrap at boot, but it does not rotate the DEK or scrub it from a machine longer unwrap at boot, but it does not rotate the DEK or scrub it from a machine
@@ -129,11 +100,7 @@ initialized or the friendly name is taken.
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. Omit 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 `--can-authorize` for servers (`allowed: False`); pass it only for trusted
dev/home machines. Refuses a target key whose fingerprint already has a record dev/home machines.
(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] [✔] Authorized Jy7k2ey7... | friendly: server1 [can_authorize=False]
@@ -174,10 +141,9 @@ rotation.
## Config reference ## Config reference
TOML, searched cwd-first then `~`: `.authorizer.toml`. Paths (`keys.public`, TOML, searched cwd-first then `~`: `.authorizer.toml`. Paths expand `~`. No
`keys.private`, `storage.path`) expand both `~` and `$ENV_VARS`. No defaults are defaults are baked in — a missing required field raises an error naming the field
baked in — a missing required field raises an error naming the field and the and the config path.
config path.
### JSON backend (default, stdlib only) ### JSON backend (default, stdlib only)
@@ -186,7 +152,6 @@ 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"
@@ -230,4 +195,5 @@ Owned by this lib (not `envelope_crypto`):
## Versioning ## Versioning
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. 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.
+2 -3
View File
@@ -4,12 +4,11 @@ build-backend = "hatchling.build"
[project] [project]
name = "envelope_authorizer" name = "envelope_authorizer"
version = "1.0.0" version = "0.1.0"
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 = [
"envelope_crypto @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_crypto.git", "envelope_crypto @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_crypto.git@v0.1.0",
"cryptography>=42.0",
"tomli>=2.0; python_version<'3.11'", "tomli>=2.0; python_version<'3.11'",
] ]
+1 -6
View File
@@ -1,6 +1 @@
from importlib.metadata import version, PackageNotFoundError __version__ = "0.1.0"
try:
__version__ = version("envelope_authorizer")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"
+4 -20
View File
@@ -2,25 +2,18 @@
`config init` runs without an existing config (it writes one); every other `config init` runs without an existing config (it writes one); every other
command loads config and resolves a storage backend first. expected failures command loads config and resolves a storage backend first. expected failures
print a clean `[✘] ...` line and exit non-zero - no traceback. (ConfigError, CommandError, RuntimeError from a guarded backend) print a clean
`[✘] ...` line and exit non-zero — no traceback.
""" """
import argparse import argparse
import sys import sys
from cryptography.exceptions import InvalidTag
from . import __version__ from . import __version__
from .config import ConfigError, load_config 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"""
@@ -77,7 +70,7 @@ def main() -> int:
try: try:
config_init.run(None, None, args) config_init.run(None, None, args)
return 0 return 0
except (CommandError, OSError) as error: except CommandError as error:
return _fail(str(error)) return _fail(str(error))
parser.parse_args(["config", "--help"]) parser.parse_args(["config", "--help"])
return 0 return 0
@@ -95,16 +88,7 @@ def main() -> int:
storage = resolve(config) storage = resolve(config)
handlers[args.cmd](config, storage, args) handlers[args.cmd](config, storage, args)
return 0 return 0
except InvalidTag: 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)) return _fail(str(error))
+8 -24
View File
@@ -1,7 +1,9 @@
"""command implementations + shared crypto helpers for the authorizer CLI """command implementations + shared crypto helpers for the authorizer CLI
each command module exposes `run(config, storage, args)`. helpers here own the each command module exposes `run(config, storage, args)`. helpers here own the
capability-flag and DEK-boot plumbing; none of them print key material. 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.
""" """
import time import time
@@ -20,11 +22,7 @@ def make_flag(crypto: EnvelopeCrypto, allowed: bool) -> dict:
def read_flag(crypto: EnvelopeCrypto, blob: dict) -> bool: 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) data = crypto.decrypt_data(blob)
return bool(data.get("allowed", False)) if isinstance(data, dict) else False return bool(data.get("allowed", False)) if isinstance(data, dict) else False
@@ -51,8 +49,8 @@ def local_fingerprint(crypto: EnvelopeCrypto, config) -> str:
def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]: def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]:
"""unwrap the local DEK and return an armed crypto plus the local key doc """unwrap the local DEK and return an armed crypto plus the local key doc
raises CommandError if this machine has no key doc here. never logs or raises CommandError if this machine has no key doc (not initialized /
prints the unwrapped key. authorized here). never logs or prints the unwrapped key.
""" """
crypto = EnvelopeCrypto() crypto = EnvelopeCrypto()
fingerprint = local_fingerprint(crypto, config) fingerprint = local_fingerprint(crypto, config)
@@ -61,28 +59,14 @@ 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( aes_key = crypto.decrypt_aes_key_with_rsa(doc["key"], config.private_key)
doc["key"], config.private_key, password=config.private_key_password
)
crypto.initialize(aes_key) crypto.initialize(aes_key)
return crypto, doc 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]: def find_by_friendly(storage, friendly: str) -> Optional[dict]:
"""return the doc whose meta.friendly matches, or None""" """return the doc whose meta.friendly matches, or None"""
for doc in storage.get_all(): for doc in storage.get_all():
if doc_meta(doc).get("friendly") == friendly: if doc.get("meta", {}).get("friendly") == friendly:
return doc return doc
return None return None
+7 -23
View File
@@ -1,17 +1,15 @@
"""`authorizer authorize` - grant another machine access to the DEK """`authorizer authorize` grant another machine access to the DEK
refuses a target key that already has a record - `save` upserts by `_id`, so boots the local DEK, verifies the local key is itself an authorizer, then wraps
re-authorizing a known key would silently overwrite its flag/friendly. see the same DEK to the target public key and stores a new key doc. `--can-authorize`
`run()` below for the full refusal contract. decides whether the new key may authorize others (omit it for servers).
""" """
from . import ( from . import (
CommandError, CommandError,
boot_local, boot_local,
build_doc, build_doc,
doc_meta,
find_by_friendly, find_by_friendly,
local_fingerprint,
make_flag, make_flag,
read_flag, read_flag,
) )
@@ -27,23 +25,9 @@ def run(config, storage, args) -> None:
if not read_flag(crypto, local_doc["meta"]["authorizer"]): if not read_flag(crypto, local_doc["meta"]["authorizer"]):
raise CommandError("this key is not permitted to authorize others") raise CommandError("this key is not permitted to authorize others")
new_fp = crypto.get_rsa_key_fingerprint(args.key) new_fp, new_wrapped = crypto.encrypt_aes_key_with_rsa(
if new_fp == local_fingerprint(crypto, config): crypto.master_key, args.key
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) flag = make_flag(crypto, args.can_authorize)
doc = build_doc(new_fp, new_wrapped, flag, config.identity, args.friendly) doc = build_doc(new_fp, new_wrapped, flag, config.identity, args.friendly)
storage.save(doc) 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 from pathlib import Path
@@ -10,7 +10,6 @@ _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"
@@ -29,7 +28,7 @@ def run(config, storage, args) -> None:
"""write a commented starter config; refuse to overwrite an existing one""" """write a commented starter config; refuse to overwrite an existing one"""
target = Path.cwd() / CONFIG_NAME target = Path.cwd() / CONFIG_NAME
if target.exists(): 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: with open(target, "w", encoding="utf-8") as handle:
handle.write(_TEMPLATE) handle.write(_TEMPLATE)
print(f"[✔] Wrote starter config: {target}") print(f"[✔] Wrote starter config: {target}")
+5 -9
View File
@@ -1,7 +1,8 @@
"""`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 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). first key doc marked as an authorizer (allowed: True). refuses if the system is
already initialized or the friendly name is taken.
""" """
from envelope_crypto import EnvelopeCrypto from envelope_crypto import EnvelopeCrypto
@@ -10,12 +11,7 @@ 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 (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(): if storage.get_all():
raise CommandError( raise CommandError(
"already initialized; use `authorizer list` to see existing keys" "already initialized; use `authorizer list` to see existing keys"
@@ -32,4 +28,4 @@ def run(config, storage, args) -> None:
doc = build_doc(fingerprint, wrapped, flag, config.identity, args.friendly) doc = build_doc(fingerprint, wrapped, flag, config.identity, args.friendly)
storage.save(doc) storage.save(doc)
print(f"[✔] Initialized - fingerprint: {fingerprint} | friendly: {args.friendly} [authorizer=True]") print(f"[✔] Initialized fingerprint: {fingerprint} | friendly: {args.friendly} [authorizer=True]")
+12 -15
View File
@@ -1,32 +1,29 @@
"""`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 boots the local DEK once, then for each doc decrypts its capability flag to show
CAN_AUTHORIZE (`?` if unreadable here). prints only fingerprint/metadata - never CAN_AUTHORIZE. a doc the local key cannot unwrap shows `?` rather than crashing.
the wrapped key or DEK. prints only fingerprint/metadata — never the wrapped key or DEK.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from . import boot_local, doc_meta, read_flag 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 unreadable here""" """decrypted authority of a doc as Yes/No, or `?` if not readable here"""
try: try:
return "Yes" if read_flag(crypto, doc["meta"]["authorizer"]) else "No" return "Yes" if read_flag(crypto, doc["meta"]["authorizer"]) else "No"
except Exception: except Exception:
return "?" return "?"
def _created(meta: dict) -> str: def _created(doc) -> str:
"""format created_at as a UTC timestamp string, or '-' if absent/unparseable""" """format created_at as a UTC timestamp string, or '-' if absent"""
raw = meta.get("created_at") raw = doc.get("meta", {}).get("created_at")
if not raw: if not raw:
return "-" return "-"
try: return datetime.fromtimestamp(int(raw), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
return datetime.fromtimestamp(int(raw), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError, OverflowError, OSError):
return "-"
def run(config, storage, args) -> None: def run(config, storage, args) -> None:
@@ -38,12 +35,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_meta(doc) meta = doc.get("meta", {})
print( print(
f"{(doc.get('_id') or '')[: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(meta):<21} " f"{_created(doc):<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")
+12 -17
View File
@@ -1,13 +1,14 @@
"""`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 finds the target by friendly name or fingerprint prefix, refuses to revoke the
local key, and deletes the record. revoke is bookkeeping only - it does not local key, deletes the record, and prints an honesty warning that revoke is
rotate the DEK or scrub it from a machine that already unwrapped it. bookkeeping only — it does not rotate the DEK or scrub it from a machine that
already unwrapped it.
""" """
from envelope_crypto import EnvelopeCrypto from envelope_crypto import EnvelopeCrypto
from . import CommandError, doc_meta, find_by_friendly, local_fingerprint from . import CommandError, find_by_friendly, local_fingerprint
_WARNING = ( _WARNING = (
"[!] Revoke removes the record only. It does NOT rotate the DEK or scrub it\n" "[!] Revoke removes the record only. It does NOT rotate the DEK or scrub it\n"
@@ -17,22 +18,16 @@ _WARNING = (
def _find_by_fingerprint(storage, prefix: str): def _find_by_fingerprint(storage, prefix: str):
"""return the single doc whose `_id` starts with the prefix, or None """return the doc whose `_id` starts with the given prefix, or None"""
for doc in storage.get_all():
rejects an empty or ambiguous prefix instead of silently revoking the first hit. if doc.get("_id", "").startswith(prefix):
""" return doc
if not prefix: return None
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: 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 is not None: if args.friendly:
doc = find_by_friendly(storage, args.friendly) doc = find_by_friendly(storage, args.friendly)
label = args.friendly label = args.friendly
else: else:
@@ -47,6 +42,6 @@ def run(config, storage, args) -> None:
raise CommandError("refusing to revoke the local key") raise CommandError("refusing to revoke the local key")
storage.delete(doc["_id"]) storage.delete(doc["_id"])
friendly = doc_meta(doc).get("friendly", "?") friendly = doc.get("meta", {}).get("friendly", "?")
print(f"[✔] Revoked: {friendly} ({doc['_id'][:16]})") print(f"[✔] Revoked: {friendly} ({doc['_id'][:16]})")
print(_WARNING) 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 boots the local DEK, then runs envelope_crypto's self_test against the local
keypair (falls back to a minimal in-CLI round-trip if self_test is absent). keypair. if the installed envelope_crypto lacks self_test, falls back to a
never prints key material. minimal in-CLI round-trip. never prints key material.
""" """
from . import CommandError, boot_local from . import CommandError, boot_local
@@ -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, password=config.private_key_password) crypto.self_test(config.public_key, config.private_key)
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:
+6 -14
View File
@@ -1,14 +1,16 @@
"""TOML config loader for the authorizer CLI """TOML config loader for the authorizer CLI
resolves a `.authorizer.toml` (cwd first, then ~), expands `~` and `$ENV_VARS` in resolves a `.authorizer.toml` (cwd first, then ~), expands user paths, and
every path field, and exposes typed accessors. no defaults are baked in: a exposes typed accessors. no defaults are baked in: a missing required field
missing required field raises a clear error naming the field and config path. 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 os
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List
if sys.version_info >= (3, 11): if sys.version_info >= (3, 11):
import tomllib import tomllib
@@ -70,11 +72,6 @@ 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"""
@@ -85,11 +82,6 @@ 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
+3 -2
View File
@@ -1,7 +1,7 @@
"""storage backend resolution for the authorizer CLI """storage backend resolution for the authorizer CLI
`resolve(config)` reads `[storage].backend` and returns the matching sync `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. package never requires the mongo extra; only selecting the mongo backend does.
""" """
@@ -13,7 +13,8 @@ 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":
return JsonStore(config.storage_path) path = config.require("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(
+1 -1
View File
@@ -1,7 +1,7 @@
"""abstract storage interface for authorizer key documents """abstract storage interface for authorizer key documents
a backend persists key docs keyed by `_id` (the RSA fingerprint). the interface 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- bridges to sync internally. `save` is an upsert by `_id`, never a duplicate-
inserting or duplicate-raising operation. inserting or duplicate-raising operation.
""" """
+6 -23
View File
@@ -8,7 +8,6 @@ setups; for dev->server flows across machines, use the mongo backend.
import json import json
import os import os
import tempfile
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
@@ -35,29 +34,13 @@ 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 via a unique temp file then os.replace""" """atomically write the docs list (temp file then replace)"""
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp( tmp = self.path.with_suffix(self.path.suffix + ".tmp")
dir=self.path.parent, prefix=self.path.name + ".", suffix=".tmp" with open(tmp, "w", encoding="utf-8") as handle:
) json.dump(docs, handle, indent=2)
wrapped = False handle.write("\n")
try: os.replace(tmp, self.path)
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:
pass
raise
def get(self, fingerprint: str) -> Optional[dict]: def get(self, fingerprint: str) -> Optional[dict]:
"""return the doc whose `_id` matches, or None""" """return the doc whose `_id` matches, or None"""
+16 -25
View File
@@ -1,15 +1,11 @@
"""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: each op bridges the async rethink-public `mongo` lib to the sync StorageBackend by
gets its own asyncio.run (connect -> op -> close), no module-level client or wrapping each operation in its own asyncio.run: connect -> op -> close, fully
shared loop. requires envelope_authorizer[mongo]; without it every method raises self-contained per call. no module-level client and no shared event loop, so it
a clear RuntimeError. 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
fail-loud: ops go through the mongo lib's raw collection escape hatch, not the method raises a clear RuntimeError.
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 import asyncio
@@ -40,38 +36,33 @@ class MongoStore(StorageBackend):
async def _get(self, fingerprint: str) -> Optional[dict]: async def _get(self, fingerprint: str) -> Optional[dict]:
db = Mongo(self.uri, self.database) db = Mongo(self.uri, self.database)
try: try:
return await db.collection(self.collection).find_one({"_id": fingerprint}) return await db.get_document(self.collection, {"_id": fingerprint})
finally: finally:
db.close() await db.close()
async def _get_all(self) -> List[dict]: async def _get_all(self) -> List[dict]:
db = Mongo(self.uri, self.database) db = Mongo(self.uri, self.database)
try: try:
cursor = db.collection(self.collection).find({}) return await db.get_documents(self.collection, {})
return await cursor.to_list(length=None)
finally: finally:
db.close() await db.close()
async def _save(self, doc: dict) -> None: async def _save(self, doc: dict) -> None:
db = Mongo(self.uri, self.database) db = Mongo(self.uri, self.database)
try: try:
result = await db.collection(self.collection).replace_one( await db.update_document(
{"_id": doc["_id"]}, doc, upsert=True self.collection, {"_id": doc["_id"]}, doc, do_upsert=True
) )
if not (result.matched_count or result.upserted_id):
raise RuntimeError(
f"mongo upsert affected no document for _id={doc['_id']!r}"
)
finally: finally:
db.close() await db.close()
async def _delete(self, fingerprint: str) -> bool: async def _delete(self, fingerprint: str) -> bool:
db = Mongo(self.uri, self.database) db = Mongo(self.uri, self.database)
try: try:
result = await db.collection(self.collection).delete_one({"_id": fingerprint}) removed = await db.delete_document(self.collection, {"_id": fingerprint})
return result.deleted_count > 0 return bool(removed)
finally: finally:
db.close() await db.close()
def get(self, fingerprint: str) -> Optional[dict]: def get(self, fingerprint: str) -> Optional[dict]:
"""return the key doc with this `_id`, or None""" """return the key doc with this `_id`, or None"""