Compare commits
No commits in common. "main" and "v0.1.1" have entirely different histories.
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,4 +5,4 @@ dist/
|
||||
build/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
11
README.md
11
README.md
@ -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.
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -1 +1 @@
|
||||
__version__ = "0.1.2"
|
||||
__version__ = "0.1.1"
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user