2 Commits
16 changed files with 72 additions and 218 deletions
+1 -1
View File
@@ -5,4 +5,4 @@ dist/
build/
.venv/
.pytest_cache/
.claude/
CLAUDE.md
+15 -49
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.5
envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.0
```
Direct:
```bash
pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.5"
pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.0"
```
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.5"
pip install "envelope_authorizer[mongo] @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.0"
```
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.5` 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
@@ -49,39 +47,12 @@ Each key carries an **encrypted capability flag** — `meta.authorizer` =
- **Servers** are authorized with `allowed: False` → they can boot and use the DEK
(decrypt project data) but **cannot authorize anything else**.
The flag is GCM-sealed under the DEK on purpose. What that buys, stated honestly:
- **Without the DEK, the flag is inert.** A stolen database at rest cannot be
escalated — the flags are ciphertext, unforgeable and unflippable without the key
(editing the stored ciphertext breaks the GCM auth tag). This stops passive DB
tampering.
- **It does not defend against a party that already holds the DEK and can write
storage.** The flag is sealed under the *shared* DEK and is **not bound to key
identity**, so such a party can copy a valid sealed `True` flag from an
authorizer's doc onto its own and self-grant authority. This is **out of scope by
design**: the trust model assumes DEK-holders are trusted. A DEK-holder already
has full data access (DEK + write access is game over), so self-granting authority
exposes no additional data. We deliberately do not add per-key signing to prevent
it — the residual is handled operationally (below).
So the precise boundary is: **the capability flag is unforgeable without the DEK; it
is not a defense against a malicious DEK-holder.** (A legitimate authorizer can of
course mint new flags — that is its job.)
### Operational guidance (the residual control)
Because the DEK-holder case above is **not** prevented cryptographically in the
shared-DEK model, the intended mitigation is **detection, not crypto**. Consumers
should periodically audit the authorization state in storage:
- enumerate every key doc's capability flag (`list` shows authorizer status),
- compare the set of `allowed: True` keys against a known-good list of expected
authorizers,
- alert on any unexpected authorizer.
An unexpected `allowed: True` appearing in storage is the signal that a DEK-holder
self-granted — catch it by review, then revoke and re-authorize from a trusted
machine.
The flag is GCM-sealed under the DEK on purpose: a server **cannot flip its own
flag in the database to escalate**, because forging a valid flag requires already
holding the DEK, and editing the stored ciphertext breaks the auth tag. The
guarantee is precisely: *you cannot grant yourself authority without already
having it.* (A legitimate authorizer can of course mint new flags — that is its
job. This stops passive DB tampering, not an authorizer.)
`revoke` is **bookkeeping, not rotation**: it deletes the record so the key can no
longer unwrap at boot, but it does not rotate the DEK or scrub it from a machine
@@ -129,11 +100,7 @@ initialized or the friendly name is taken.
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
`--can-authorize` for servers (`allowed: False`); pass it only for trusted
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.
dev/home machines.
```
[✔] Authorized Jy7k2ey7... | friendly: server1 [can_authorize=False]
@@ -174,10 +141,9 @@ rotation.
## Config reference
TOML, searched cwd-first then `~`: `.authorizer.toml`. Paths (`keys.public`,
`keys.private`, `storage.path`) expand both `~` and `$ENV_VARS`. No defaults are
baked in — a missing required field raises an error naming the field and the
config path.
TOML, searched cwd-first then `~`: `.authorizer.toml`. Paths expand `~`. No
defaults are baked in — a missing required field raises an error naming the field
and the config path.
### JSON backend (default, stdlib only)
@@ -186,7 +152,6 @@ config path.
public = "~/.ssh/id_rsa.pub"
private = "~/.ssh/id_rsa"
identity = "user@hostname" # stamped as created_by on every key doc
# password = "..." # only if the private key above is encrypted
[storage]
backend = "json"
@@ -230,4 +195,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.
+2 -2
View File
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
[project]
name = "envelope_authorizer"
version = "0.1.5"
version = "0.1.0"
description = "CLI key-authorization manager for envelope_crypto"
requires-python = ">=3.10"
dependencies = [
"envelope_crypto @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_crypto.git",
"envelope_crypto @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_crypto.git@v0.1.0",
"tomli>=2.0; python_version<'3.11'",
]
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.5"
__version__ = "0.1.0"
+2 -19
View File
@@ -9,19 +9,11 @@ command loads config and resolves a storage backend first. expected failures
import argparse
import sys
from cryptography.exceptions import InvalidTag
from . import __version__
from .config import ConfigError, load_config
from .commands import CommandError, authorize, config_init, init, list_keys, revoke, verify
from .storage import resolve
try:
from pymongo.errors import PyMongoError
except ImportError:
class PyMongoError(Exception):
"""stand-in when the [mongo] extra is not installed; never actually raised"""
def _build_parser() -> argparse.ArgumentParser:
"""construct the argument parser with all subcommands"""
@@ -78,9 +70,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
@@ -98,14 +88,7 @@ def main() -> int:
storage = resolve(config)
handlers[args.cmd](config, storage, args)
return 0
except InvalidTag:
return _fail("capability flag failed authentication — tampered or wrong DEK")
except PyMongoError as error:
return _fail(f"storage backend error: {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
except (ConfigError, CommandError, RuntimeError, ValueError, FileNotFoundError) as error:
return _fail(str(error))
+2 -8
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
@@ -63,9 +59,7 @@ def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]:
raise CommandError(
"not initialized on this machine (no key doc for the local public key)"
)
aes_key = crypto.decrypt_aes_key_with_rsa(
doc["key"], config.private_key, password=config.private_key_password
)
aes_key = crypto.decrypt_aes_key_with_rsa(doc["key"], config.private_key)
crypto.initialize(aes_key)
return crypto, doc
+3 -21
View File
@@ -2,10 +2,7 @@
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`
decides whether the new key may authorize others (omit it for servers). Refuses
a target key that already has a record — `save` upserts by `_id`, so re-authorizing
a known key (most dangerously the local key itself) would silently overwrite its
flag/friendly under a success banner instead of adding a new key.
decides whether the new key may authorize others (omit it for servers).
"""
from . import (
@@ -13,7 +10,6 @@ from . import (
boot_local,
build_doc,
find_by_friendly,
local_fingerprint,
make_flag,
read_flag,
)
@@ -29,23 +25,9 @@ def run(config, storage, args) -> None:
if not read_flag(crypto, local_doc["meta"]["authorizer"]):
raise CommandError("this key is not permitted to authorize others")
new_fp = crypto.get_rsa_key_fingerprint(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"
new_fp, new_wrapped = crypto.encrypt_aes_key_with_rsa(
crypto.master_key, args.key
)
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)
doc = build_doc(new_fp, new_wrapped, flag, config.identity, args.friendly)
storage.save(doc)
@@ -10,7 +10,6 @@ _TEMPLATE = """[keys]
public = "~/.ssh/id_rsa.pub"
private = "~/.ssh/id_rsa"
identity = "user@hostname"
# password = "..." # only if the private key above is encrypted
[storage]
backend = "json"
+1 -6
View File
@@ -11,12 +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 (TOCTOU under two
concurrent CLIs) by design — one-shot admin tool, `save` upserts by `_id`, so the
worst case is a cosmetic double-init with no security consequence.
"""
"""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"
+6 -21
View File
@@ -11,34 +11,19 @@ from . import boot_local, read_flag
def _can_authorize(crypto, doc) -> str:
"""decrypted authority of a doc as Yes/No, or `?` if unreadable here
`?` covers any failure (unwrap mismatch, missing/malformed doc) so the table
always renders instead of crashing on one bad row.
"""
"""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:
return "?"
def _meta(doc) -> dict:
"""the doc's meta block as a dict, coercing a missing/null/malformed one to {}"""
meta = doc.get("meta")
return meta if isinstance(meta, dict) else {}
def _created(meta: dict) -> str:
"""format created_at as a UTC timestamp string, or '-' if absent/unparseable"""
raw = meta.get("created_at")
def _created(doc) -> str:
"""format created_at as a UTC timestamp string, or '-' if absent"""
raw = doc.get("meta", {}).get("created_at")
if not raw:
return "-"
try:
return datetime.fromtimestamp(int(raw), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError, OverflowError, OSError):
# one hand-edited/legacy doc with a bad created_at shouldn't abort the
# whole table render
return "-"
def run(config, storage, args) -> None:
@@ -50,12 +35,12 @@ def run(config, storage, args) -> None:
print(header)
print("-" * len(header))
for doc in docs:
meta = _meta(doc)
meta = doc.get("meta", {})
print(
f"{doc.get('_id', '')[:16]:<18} "
f"{str(meta.get('friendly', '-')):<16} "
f"{str(meta.get('created_by', '-')):<18} "
f"{_created(meta):<21} "
f"{_created(doc):<21} "
f"{_can_authorize(crypto, doc):<8}"
)
print(f"\n{len(docs)} key(s) authorized")
+6 -13
View File
@@ -18,23 +18,16 @@ _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 (matches everything) and an ambiguous one (matches
more than one key) instead of silently revoking the first hit.
"""
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:
"""delete a key record by friendly or fingerprint, guarding the local key"""
if args.friendly is not None:
if args.friendly:
doc = find_by_friendly(storage, args.friendly)
label = args.friendly
else:
+1 -1
View File
@@ -13,7 +13,7 @@ def run(config, storage, args) -> None:
crypto, _ = boot_local(config, storage)
if hasattr(crypto, "self_test"):
crypto.self_test(config.public_key, config.private_key, password=config.private_key_password)
crypto.self_test(config.public_key, config.private_key)
else:
sample = {"_authorizer_verify": "ok", "n": 12345}
if crypto.decrypt_data(crypto.encrypt_data(sample)) != sample:
+6 -17
View File
@@ -1,17 +1,16 @@
"""TOML config loader for the authorizer CLI
resolves a `.authorizer.toml` (cwd first, then ~), expands `~` and `$ENV_VARS` in
every path field (key paths and the JSON storage path alike), and exposes typed
accessors. no defaults are baked in: a missing required field raises a clear
error naming the field and the config path that was searched. the loaded config
is the only place key paths, identity, and storage live — this lib never imports
a host `config` module.
resolves a `.authorizer.toml` (cwd first, then ~), expands user paths, and
exposes typed accessors. no defaults are baked in: a missing required field
raises a clear error naming the field and the config path that was searched.
the loaded config is the only place key paths, identity, and storage live —
this lib never imports a host `config` module.
"""
import os
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
if sys.version_info >= (3, 11):
import tomllib
@@ -73,11 +72,6 @@ class Config:
"""path to the local RSA private key (expanded)"""
return _expand(self.require("keys", "private"))
@property
def private_key_password(self) -> Optional[str]:
"""passphrase for an encrypted local private key, or None if unset"""
return self.optional("keys", "password")
@property
def identity(self) -> str:
"""human identity stamped as created_by on every key doc"""
@@ -88,11 +82,6 @@ class Config:
"""selected storage backend name ("json" or "mongo")"""
return self.require("storage", "backend")
@property
def storage_path(self) -> str:
"""path to the JSON storage file (expanded, same as the key paths)"""
return _expand(self.require("storage", "path"))
def load_config() -> Config:
"""find and parse the authorizer config, or raise with guidance
+2 -1
View File
@@ -13,7 +13,8 @@ def resolve(config) -> StorageBackend:
"""build the storage backend named by the config, or raise on unknown"""
backend = config.storage_backend
if backend == "json":
return JsonStore(config.storage_path)
path = config.require("storage", "path")
return JsonStore(path)
if backend == "mongo":
from .mongo_store import MongoStore
return MongoStore(
+3 -26
View File
@@ -8,7 +8,6 @@ setups; for dev->server flows across machines, use the mongo backend.
import json
import os
import tempfile
from pathlib import Path
from typing import List, Optional
@@ -35,35 +34,13 @@ class JsonStore(StorageBackend):
return data
def _write(self, docs: List[dict]) -> None:
"""atomically write the docs list (unique temp file then os.replace)
a unique temp per write (tempfile.mkstemp) keeps concurrent writers from
clobbering a shared `.tmp`; the temp is cleaned up if the write fails
before replace.
"""
"""atomically write the docs list (temp file then replace)"""
self.path.parent.mkdir(parents=True, exist_ok=True)
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 owns fd now; its close() handles it
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as handle:
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
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp)
except OSError:
pass
raise
def get(self, fingerprint: str) -> Optional[dict]:
"""return the doc whose `_id` matches, or None"""
+16 -26
View File
@@ -1,16 +1,11 @@
"""mongo storage backend (behind the [mongo] extra)
bridges the async rethink-public `mongo` lib to the sync StorageBackend: each op
gets its own asyncio.run (connect -> op -> close), no module-level client or
shared loop, so it never collides with a running loop. per-call connect is an
accepted tradeoff for a one-shot admin CLI. requires envelope_authorizer[mongo];
without it every method raises a clear RuntimeError.
fail-loud: ops go through the mongo lib's raw collection escape hatch (the motor
collection, which raises), not the swallow-and-default wrapped methods — this is
an auth backend, so a driver error must never read as "not initialized," and a
no-op upsert raises instead of silently reporting success. `close()` is
synchronous on the mongo lib — not awaited.
bridges the async rethink-public `mongo` lib to the sync StorageBackend by
wrapping each operation in its own asyncio.run: connect -> op -> close, fully
self-contained per call. no module-level client and no shared event loop, so it
never collides with a running loop. a per-call connect is an accepted tradeoff
for a one-shot admin CLI. requires envelope_authorizer[mongo]; without it every
method raises a clear RuntimeError.
"""
import asyncio
@@ -41,38 +36,33 @@ class MongoStore(StorageBackend):
async def _get(self, fingerprint: str) -> Optional[dict]:
db = Mongo(self.uri, self.database)
try:
return await db.collection(self.collection).find_one({"_id": fingerprint})
return await db.get_document(self.collection, {"_id": fingerprint})
finally:
db.close()
await db.close()
async def _get_all(self) -> List[dict]:
db = Mongo(self.uri, self.database)
try:
cursor = db.collection(self.collection).find({})
return await cursor.to_list(length=None)
return await db.get_documents(self.collection, {})
finally:
db.close()
await db.close()
async def _save(self, doc: dict) -> None:
db = Mongo(self.uri, self.database)
try:
result = await db.collection(self.collection).replace_one(
{"_id": doc["_id"]}, doc, upsert=True
)
if not (result.matched_count or result.upserted_id):
raise RuntimeError(
f"mongo upsert affected no document for _id={doc['_id']!r}"
await db.update_document(
self.collection, {"_id": doc["_id"]}, doc, do_upsert=True
)
finally:
db.close()
await db.close()
async def _delete(self, fingerprint: str) -> bool:
db = Mongo(self.uri, self.database)
try:
result = await db.collection(self.collection).delete_one({"_id": fingerprint})
return result.deleted_count > 0
removed = await db.delete_document(self.collection, {"_id": fingerprint})
return bool(removed)
finally:
db.close()
await db.close()
def get(self, fingerprint: str) -> Optional[dict]:
"""return the key doc with this `_id`, or None"""