Compare commits

..

No commits in common. "main" and "v0.1.1" have entirely different histories.
main ... v0.1.1

10 changed files with 18 additions and 56 deletions

2
.gitignore vendored
View File

@ -5,4 +5,4 @@ dist/
build/ build/
.venv/ .venv/
.pytest_cache/ .pytest_cache/
.claude/ CLAUDE.md

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@v0.1.2 envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.1
``` ```
Direct: Direct:
```bash ```bash
pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.2" pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.1"
``` ```
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.2" pip install "envelope_authorizer[mongo] @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.1"
``` ```
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.2` 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
@ -224,4 +222,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.

View File

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "envelope_authorizer" name = "envelope_authorizer"
version = "0.1.2" version = "0.1.1"
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 = [

View File

@ -1 +1 @@
__version__ = "0.1.2" __version__ = "0.1.1"

View File

@ -72,9 +72,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:
# config_init writes a file (cwd may be read-only, full, or gone) — an
# OSError must print a clean [✘] line, not a raw traceback
return _fail(str(error)) return _fail(str(error))
parser.parse_args(["config", "--help"]) parser.parse_args(["config", "--help"])
return 0 return 0
@ -94,10 +92,7 @@ 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 (ConfigError, CommandError, RuntimeError, ValueError, OSError, KeyError, TypeError) as error: except (ConfigError, CommandError, RuntimeError, ValueError, FileNotFoundError) as error:
# OSError covers the FileNotFoundError/PermissionError/IsADirectoryError family;
# KeyError/TypeError cover a structurally-malformed flag/doc (unguarded indexing
# of ['iv']/['meta']['authorizer']/['key']) — all print a clean [✘] line, not a traceback
return _fail(str(error)) return _fail(str(error))

View File

@ -22,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

View File

@ -11,14 +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 (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.
"""
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"

View File

@ -11,12 +11,7 @@ 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 not readable 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.
"""
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:

View File

@ -18,18 +18,11 @@ _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 doc whose `_id` starts with the given prefix, or None"""
for doc in storage.get_all():
rejects an empty prefix (which would match every key) and an ambiguous prefix if doc.get("_id", "").startswith(prefix):
that matches more than one key, rather than silently revoking the first match. return doc
""" return None
if not prefix:
raise CommandError("fingerprint prefix must not be empty")
matches = [doc for doc in storage.get_all() if doc.get("_id", "").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:

View File

@ -45,21 +45,12 @@ class JsonStore(StorageBackend):
fd, tmp = tempfile.mkstemp( fd, tmp = tempfile.mkstemp(
dir=self.path.parent, prefix=self.path.name + ".", suffix=".tmp" dir=self.path.parent, prefix=self.path.name + ".", suffix=".tmp"
) )
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
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:
# 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)
try:
os.close(fd)
except OSError:
pass
try: try:
os.unlink(tmp) os.unlink(tmp)
except OSError: except OSError: