7 Commits
Author SHA1 Message Date
dsql 0d0558d11b chore: unpin envelope_crypto dependency (track latest release)
envauth used envelope_crypto pinned at v0.1.0, two data-loss fixes behind (the lib
is now v0.1.4). envauth uses only stable crypto primitives (initialize, encrypt_data/
decrypt_data, create_aes_key, *_aes_key_with_rsa, get_rsa_key_fingerprint, self_test)
— never the record-rotation functions whose contract changed — so tracking latest is
safe and keeps the crypto fixes flowing without a manual bump each release.

Verified: full CLI round-trip (init -> authorize server -> privilege gate -> envauth-1
self-authorize refusal -> list) against envelope_crypto v0.1.4 source; all 8 used
primitives present. v0.1.4.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 16:50:55 -04:00
dsql bfeee80712 fix: EA-1 refuse authorize of an already-recorded key (v0.1.3)
authorize never checked the target fingerprint against existing docs before
save()'s upsert-by-_id, so authorizing the local machine's own public key
under a new friendly name silently replaced the local authorizer record
(can_authorize demoted to False) while printing a success banner. With a
sole authorizer this bricks the CLI: authorize refuses (not permitted),
init refuses (already initialized), and revoke of the local key refuses
(refusing to revoke the local key) -- no in-CLI recovery. Mirror revoke's
local-key guard and extend it to any existing _id, so a duplicate target
is refused with a clear message instead of silently replacing the record.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-07-02 16:41:09 -04:00
dsql f2e9e5fe35 chore: ignore .claude/ dir (CLAUDE.md now lives under .claude/)
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:55:13 -04:00
dsql 88e1eaef39 fix: EA-1 clean error on malformed flag/doc; EA-2 fingerprint ambiguity + fd-leak
EA-1: main dispatch catches KeyError/TypeError so a structurally-malformed flag/doc prints
a clean [x] line instead of a traceback. EA-2: fingerprint revoke rejects an empty prefix
and an ambiguous prefix (was: silently revoked the first match). json_store closes the raw
fd if os.fdopen raises before taking ownership (was: leaked). init TOCTOU documented as
by-design (trusted-DEK model, save upserts by _id). list '?' wording clarified.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 21:35:03 -04:00
dsql 130c62e31c docs: pin install line to release, note unpinned-latest option
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:13:52 -04:00
dsql 09e6d15e48 docs: show unpinned install line; note tag-pinning for reproducibility
Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 18:07:37 -04:00
dsql a40a7432ef fix: clean error on OS-level write failures in config init and dispatch (v0.1.2)
- config init catches OSError (read-only dir, ENOSPC, gone cwd) alongside CommandError
  and prints a clean [x] line; the main dispatch catches the full OSError family instead
  of only FileNotFoundError (L13)
- document read_flag's fail-closed (non-dict -> not allowed) as a deliberate privilege-
  gate default (nit).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 17:58:09 -04:00
11 changed files with 85 additions and 24 deletions
+1 -1
View File
@@ -5,4 +5,4 @@ dist/
build/ build/
.venv/ .venv/
.pytest_cache/ .pytest_cache/
CLAUDE.md .claude/
+11 -6
View File
@@ -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.4
``` ```
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.4"
``` ```
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.4"
``` ```
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.4` 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.
+2 -2
View File
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
[project] [project]
name = "envelope_authorizer" name = "envelope_authorizer"
version = "0.1.1" version = "0.1.4"
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@v0.1.0", "envelope_crypto @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_crypto.git",
"tomli>=2.0; python_version<'3.11'", "tomli>=2.0; python_version<'3.11'",
] ]
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.1" __version__ = "0.1.4"
+7 -2
View File
@@ -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))
+5 -1
View File
@@ -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
+22 -3
View File
@@ -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)
+8 -1
View File
@@ -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:
+12 -5
View File
@@ -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: