Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfeee80712 | ||
|
|
f2e9e5fe35 | ||
|
|
88e1eaef39 | ||
|
|
130c62e31c | ||
|
|
09e6d15e48 | ||
|
|
a40a7432ef |
+1
-1
@@ -5,4 +5,4 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.venv/
|
.venv/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
CLAUDE.md
|
.claude/
|
||||||
|
|||||||
@@ -13,25 +13,27 @@ 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.1
|
envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.3
|
||||||
```
|
```
|
||||||
|
|
||||||
Direct:
|
Direct:
|
||||||
|
|
||||||
```bash
|
```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.3"
|
||||||
```
|
```
|
||||||
|
|
||||||
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.1"
|
pip install "envelope_authorizer[mongo] @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.3"
|
||||||
```
|
```
|
||||||
|
|
||||||
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.3` 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
|
||||||
@@ -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
|
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.
|
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]
|
[✔] Authorized Jy7k2ey7... | friendly: server1 [can_authorize=False]
|
||||||
@@ -222,5 +228,4 @@ Owned by this lib (not `envelope_crypto`):
|
|||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
Tagged `vX.Y.Z`. Pin the tag. `envelope_crypto` is pinned at `v0.1.0` in
|
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.
|
||||||
`pyproject.toml`; to change it, edit the pin and re-test.
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "envelope_authorizer"
|
name = "envelope_authorizer"
|
||||||
version = "0.1.1"
|
version = "0.1.3"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.1.1"
|
__version__ = "0.1.3"
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
config_init.run(None, None, args)
|
config_init.run(None, None, args)
|
||||||
return 0
|
return 0
|
||||||
except CommandError as error:
|
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
|
||||||
return _fail(str(error))
|
return _fail(str(error))
|
||||||
parser.parse_args(["config", "--help"])
|
parser.parse_args(["config", "--help"])
|
||||||
return 0
|
return 0
|
||||||
@@ -92,7 +94,10 @@ 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, FileNotFoundError) as error:
|
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
|
||||||
return _fail(str(error))
|
return _fail(str(error))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
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. `--can-authorize`
|
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).
|
decides whether the new key may authorize others (omit it for servers). Refuses
|
||||||
|
a target key that fingerprints to an existing `_id` — `save` upserts by `_id`, so
|
||||||
|
authorizing a key that is already on record (most dangerously the local key
|
||||||
|
itself) would silently replace that doc's capability flag and friendly name
|
||||||
|
under a success banner instead of adding a new key.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
@@ -10,6 +14,7 @@ from . import (
|
|||||||
boot_local,
|
boot_local,
|
||||||
build_doc,
|
build_doc,
|
||||||
find_by_friendly,
|
find_by_friendly,
|
||||||
|
local_fingerprint,
|
||||||
make_flag,
|
make_flag,
|
||||||
read_flag,
|
read_flag,
|
||||||
)
|
)
|
||||||
@@ -25,9 +30,23 @@ 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, new_wrapped = crypto.encrypt_aes_key_with_rsa(
|
new_fp = crypto.get_rsa_key_fingerprint(args.key)
|
||||||
crypto.master_key, 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 = existing.get("meta", {}).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)
|
||||||
|
|||||||
@@ -11,7 +11,14 @@ 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"
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ 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:
|
||||||
|
|||||||
@@ -18,11 +18,18 @@ _WARNING = (
|
|||||||
|
|
||||||
|
|
||||||
def _find_by_fingerprint(storage, prefix: str):
|
def _find_by_fingerprint(storage, prefix: str):
|
||||||
"""return the doc whose `_id` starts with the given prefix, or None"""
|
"""return the single doc whose `_id` starts with the given prefix, or None
|
||||||
for doc in storage.get_all():
|
|
||||||
if doc.get("_id", "").startswith(prefix):
|
rejects an empty prefix (which would match every key) and an ambiguous prefix
|
||||||
return doc
|
that matches more than one key, rather than silently revoking the first match.
|
||||||
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:
|
||||||
|
|||||||
@@ -45,12 +45,21 @@ 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:
|
||||||
|
|||||||
Reference in New Issue
Block a user