2 Commits
7 changed files with 29 additions and 93 deletions
+9 -36
View File
@@ -13,20 +13,20 @@ 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.1
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.1"
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.1"
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,
@@ -47,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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "envelope_authorizer"
version = "0.1.1"
version = "0.1.0"
description = "CLI key-authorization manager for envelope_crypto"
requires-python = ">=3.10"
dependencies = [
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.1"
__version__ = "0.1.0"
-4
View File
@@ -9,8 +9,6 @@ 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
@@ -90,8 +88,6 @@ 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 (ConfigError, CommandError, RuntimeError, ValueError, FileNotFoundError) as error:
return _fail(str(error))
@@ -19,16 +19,11 @@ def _can_authorize(crypto, doc) -> str:
def _created(doc) -> str:
"""format created_at as a UTC timestamp string, or '-' if absent/unparseable"""
"""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:
+3 -18
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,27 +34,13 @@ class JsonStore(StorageBackend):
return data
def _write(self, docs: List[dict]) -> None:
"""atomically write the docs list (unique temp file then replace)
the temp name is unique per write (tempfile.mkstemp in the same dir) so two
concurrent writers can't clobber a shared `.tmp`; os.replace is atomic on the
same filesystem. 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"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
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:
try:
os.unlink(tmp)
except OSError:
pass
raise
def get(self, fingerprint: str) -> Optional[dict]:
"""return the doc whose `_id` matches, or None"""
+10 -23
View File
@@ -6,14 +6,6 @@ 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.
fail-loud: operations go through the mongo lib's RAW collection escape hatch
(`db.collection(name)`, the motor collection — which raises) rather than the
swallow-and-return-default wrapped methods. this is an auth backend; conflating
a backend error with "no document / not initialized" in the thing that gates
authority is the worst place for the swallow anti-pattern, so a driver error
propagates and a no-op upsert raises instead of silently reporting success.
`close()` is synchronous on the mongo lib (motor's close is sync) — not awaited.
"""
import asyncio
@@ -44,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"""