4 Commits
Author SHA1 Message Date
dsql eced5333d6 fix: surface a tampered capability flag as a clean error, not a traceback
a tampered or foreign GCM capability flag raises cryptography's InvalidTag (subclasses Exception, not ValueError/RuntimeError), which escaped the CLI's catch tuple as a raw traceback on the authorize/verify paths. main() now catches InvalidTag and surfaces '[\xe2\x9c\x98] capability flag failed authentication — tampered or wrong DEK'. also corrected the stale CLAUDE.md storage note that still described the swallow-wrapped mongo methods instead of the fail-loud raw-collection path.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-29 01:10:25 -04:00
dsql 13bf77f0f4 fix: mongo backend — sync close() + fail-loud via raw collection
two regressions in the [mongo] storage backend: (1) the four finally blocks did 'await db.close()' but the mongo lib's close() became synchronous this session, so await None raised TypeError on every op — dropped the await. (2) the backend consumed mongo's swallow-and-return-default wrapped methods raw, conflating a driver error with 'no document / not initialized' in the lib that gates authority; it now goes through the raw db.collection(name) escape hatch (the motor collection, which raises) and raises on a no-op upsert, matching the CLI's fail-loud stance.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 18:45:25 -04:00
dsql 2d01805427 docs: correct capability-flag threat-model boundary; add detection guidance
the docs claimed 'you cannot grant yourself authority without already having it', which is false in the shared-DEK model: a DEK-holder with write access can copy a sealed True flag onto its own doc. replaced with the honest boundary (the flag is unforgeable WITHOUT the DEK, but is not a defense against a malicious DEK-holder, which is out of scope by design) and added operational guidance to detect a self-grant by auditing authorization state. no code or storage-format change.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 17:18:28 -04:00
dsql a0824c4b1a fix: unique temp on JSON write + tolerant created_at render (v0.1.1)
- JsonStore._write used a fixed '<path>.tmp' name with no lock, so two concurrent
  authorizer invocations could clobber each other's temp and corrupt/lose the key
  store. use tempfile.mkstemp in the same dir (unique per write) then os.replace
  (atomic), cleaning up the temp on failure.
- list 'created_at' formatting did int(raw) unguarded; one hand-edited/legacy doc
  with a bad timestamp aborted the whole table. guard per-row, fall back to '-'.

verified by execution: 20 concurrent writers -> 0 errors, file stays valid JSON,
no leftover .tmp; upsert still dedupes/updates; bad/absent created_at -> '-'.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-28 15:47:58 -04:00
7 changed files with 93 additions and 29 deletions
+36 -9
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.0
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.0"
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.0"
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,
@@ -47,12 +47,39 @@ 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: 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.)
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.
`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.0"
version = "0.1.1"
description = "CLI key-authorization manager for envelope_crypto"
requires-python = ">=3.10"
dependencies = [
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.1.0"
__version__ = "0.1.1"
+4
View File
@@ -9,6 +9,8 @@ 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
@@ -88,6 +90,8 @@ 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,11 +19,16 @@ def _can_authorize(crypto, doc) -> str:
def _created(doc) -> str:
"""format created_at as a UTC timestamp string, or '-' if absent"""
"""format created_at as a UTC timestamp string, or '-' if absent/unparseable"""
raw = doc.get("meta", {}).get("created_at")
if not raw:
return "-"
return datetime.fromtimestamp(int(raw), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
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:
+21 -6
View File
@@ -8,6 +8,7 @@ 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
@@ -34,13 +35,27 @@ class JsonStore(StorageBackend):
return data
def _write(self, docs: List[dict]) -> None:
"""atomically write the docs list (temp file then replace)"""
"""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.
"""
self.path.parent.mkdir(parents=True, exist_ok=True)
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)
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:
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"""
+23 -10
View File
@@ -6,6 +6,14 @@ 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
@@ -36,33 +44,38 @@ class MongoStore(StorageBackend):
async def _get(self, fingerprint: str) -> Optional[dict]:
db = Mongo(self.uri, self.database)
try:
return await db.get_document(self.collection, {"_id": fingerprint})
return await db.collection(self.collection).find_one({"_id": fingerprint})
finally:
await db.close()
db.close()
async def _get_all(self) -> List[dict]:
db = Mongo(self.uri, self.database)
try:
return await db.get_documents(self.collection, {})
cursor = db.collection(self.collection).find({})
return await cursor.to_list(length=None)
finally:
await db.close()
db.close()
async def _save(self, doc: dict) -> None:
db = Mongo(self.uri, self.database)
try:
await db.update_document(
self.collection, {"_id": doc["_id"]}, doc, do_upsert=True
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}"
)
finally:
await db.close()
db.close()
async def _delete(self, fingerprint: str) -> bool:
db = Mongo(self.uri, self.database)
try:
removed = await db.delete_document(self.collection, {"_id": fingerprint})
return bool(removed)
result = await db.collection(self.collection).delete_one({"_id": fingerprint})
return result.deleted_count > 0
finally:
await db.close()
db.close()
def get(self, fingerprint: str) -> Optional[dict]:
"""return the key doc with this `_id`, or None"""