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/
.venv/
.pytest_cache/
.claude/
CLAUDE.md

View File

@ -13,27 +13,25 @@ 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.2
envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.1
```
Direct:
```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
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.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,
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)
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
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]
name = "envelope_authorizer"
version = "0.1.2"
version = "0.1.1"
description = "CLI key-authorization manager for envelope_crypto"
requires-python = ">=3.10"
dependencies = [

View File

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

View File

@ -72,9 +72,7 @@ def main() -> int:
try:
config_init.run(None, None, args)
return 0
except (CommandError, OSError) 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
except CommandError as error:
return _fail(str(error))
parser.parse_args(["config", "--help"])
return 0
@ -94,10 +92,7 @@ def main() -> int:
return 0
except InvalidTag:
return _fail("capability flag failed authentication — tampered or wrong DEK")
except (ConfigError, CommandError, RuntimeError, ValueError, OSError, KeyError, TypeError) as error:
# OSError covers the FileNotFoundError/PermissionError/IsADirectoryError family;
# KeyError/TypeError cover a structurally-malformed flag/doc (unguarded indexing
# of ['iv']/['meta']['authorizer']/['key']) — all print a clean [✘] line, not a traceback
except (ConfigError, CommandError, RuntimeError, ValueError, FileNotFoundError) as 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:
"""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.
"""
"""decrypt a capability flag; return the `allowed` bool"""
data = crypto.decrypt_data(blob)
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:
"""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.
"""
"""initialize the key system on this machine as the first authorizer"""
if storage.get_all():
raise CommandError(
"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:
"""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.
"""
"""decrypted authority of a doc as Yes/No, or `?` if not readable here"""
try:
return "Yes" if read_flag(crypto, doc["meta"]["authorizer"]) else "No"
except Exception:

View File

@ -18,18 +18,11 @@ _WARNING = (
def _find_by_fingerprint(storage, prefix: str):
"""return the single doc whose `_id` starts with the given prefix, or None
rejects an empty prefix (which would match every key) and an ambiguous prefix
that matches more than one key, rather than silently revoking the first match.
"""
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
"""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
def run(config, storage, args) -> None:

View File

@ -45,21 +45,12 @@ class JsonStore(StorageBackend):
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 # fdopen took ownership of fd; its close() handles it
json.dump(docs, handle, indent=2)
handle.write("\n")
os.replace(tmp, self.path)
except BaseException:
if not wrapped:
# fdopen raised before taking ownership — close the raw fd ourselves so
# it isn't leaked (the `with` only closes once fdopen returns a file object)
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp)
except OSError: