Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2e9e5fe35 | |||
| 88e1eaef39 | |||
| 130c62e31c | |||
| 09e6d15e48 | |||
| a40a7432ef | |||
| eced5333d6 | |||
| 13bf77f0f4 | |||
| 2d01805427 | |||
| a0824c4b1a |
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,4 +5,4 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.venv/
|
.venv/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
CLAUDE.md
|
.claude/
|
||||||
|
|||||||
50
README.md
50
README.md
@ -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.0
|
envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.2
|
||||||
```
|
```
|
||||||
|
|
||||||
Direct:
|
Direct:
|
||||||
|
|
||||||
```bash
|
```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.2"
|
||||||
```
|
```
|
||||||
|
|
||||||
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.0"
|
pip install "envelope_authorizer[mongo] @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.2"
|
||||||
```
|
```
|
||||||
|
|
||||||
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.2` 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
|
||||||
@ -47,12 +49,39 @@ Each key carries an **encrypted capability flag** — `meta.authorizer` =
|
|||||||
- **Servers** are authorized with `allowed: False` → they can boot and use the DEK
|
- **Servers** are authorized with `allowed: False` → they can boot and use the DEK
|
||||||
(decrypt project data) but **cannot authorize anything else**.
|
(decrypt project data) but **cannot authorize anything else**.
|
||||||
|
|
||||||
The flag is GCM-sealed under the DEK on purpose: a server **cannot flip its own
|
The flag is GCM-sealed under the DEK on purpose. What that buys, stated honestly:
|
||||||
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
|
- **Without the DEK, the flag is inert.** A stolen database at rest cannot be
|
||||||
guarantee is precisely: *you cannot grant yourself authority without already
|
escalated — the flags are ciphertext, unforgeable and unflippable without the key
|
||||||
having it.* (A legitimate authorizer can of course mint new flags — that is its
|
(editing the stored ciphertext breaks the GCM auth tag). This stops passive DB
|
||||||
job. This stops passive DB tampering, not an authorizer.)
|
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
|
`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
|
longer unwrap at boot, but it does not rotate the DEK or scrub it from a machine
|
||||||
@ -195,5 +224,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.
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "envelope_authorizer"
|
name = "envelope_authorizer"
|
||||||
version = "0.1.0"
|
version = "0.1.2"
|
||||||
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.0"
|
__version__ = "0.1.2"
|
||||||
|
|||||||
@ -9,6 +9,8 @@ command loads config and resolves a storage backend first. expected failures
|
|||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .config import ConfigError, load_config
|
from .config import ConfigError, load_config
|
||||||
from .commands import CommandError, authorize, config_init, init, list_keys, revoke, verify
|
from .commands import CommandError, authorize, config_init, init, list_keys, revoke, verify
|
||||||
@ -70,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
|
||||||
@ -88,7 +92,12 @@ def main() -> int:
|
|||||||
storage = resolve(config)
|
storage = resolve(config)
|
||||||
handlers[args.cmd](config, storage, args)
|
handlers[args.cmd](config, storage, args)
|
||||||
return 0
|
return 0
|
||||||
except (ConfigError, CommandError, RuntimeError, ValueError, FileNotFoundError) as error:
|
except InvalidTag:
|
||||||
|
return _fail("capability flag failed authentication — tampered or wrong DEK")
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
@ -19,11 +24,16 @@ def _can_authorize(crypto, doc) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _created(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")
|
raw = doc.get("meta", {}).get("created_at")
|
||||||
if not raw:
|
if not raw:
|
||||||
return "-"
|
return "-"
|
||||||
|
try:
|
||||||
return datetime.fromtimestamp(int(raw), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
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:
|
def run(config, storage, args) -> None:
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -8,6 +8,7 @@ setups; for dev->server flows across machines, use the mongo backend.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
@ -34,13 +35,36 @@ class JsonStore(StorageBackend):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def _write(self, docs: List[dict]) -> None:
|
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)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
fd, tmp = tempfile.mkstemp(
|
||||||
with open(tmp, "w", encoding="utf-8") as handle:
|
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 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:
|
||||||
|
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:
|
||||||
|
os.unlink(tmp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
def get(self, fingerprint: str) -> Optional[dict]:
|
def get(self, fingerprint: str) -> Optional[dict]:
|
||||||
"""return the doc whose `_id` matches, or None"""
|
"""return the doc whose `_id` matches, or None"""
|
||||||
|
|||||||
@ -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
|
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
|
for a one-shot admin CLI. requires envelope_authorizer[mongo]; without it every
|
||||||
method raises a clear RuntimeError.
|
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
|
import asyncio
|
||||||
@ -36,33 +44,38 @@ class MongoStore(StorageBackend):
|
|||||||
async def _get(self, fingerprint: str) -> Optional[dict]:
|
async def _get(self, fingerprint: str) -> Optional[dict]:
|
||||||
db = Mongo(self.uri, self.database)
|
db = Mongo(self.uri, self.database)
|
||||||
try:
|
try:
|
||||||
return await db.get_document(self.collection, {"_id": fingerprint})
|
return await db.collection(self.collection).find_one({"_id": fingerprint})
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
db.close()
|
||||||
|
|
||||||
async def _get_all(self) -> List[dict]:
|
async def _get_all(self) -> List[dict]:
|
||||||
db = Mongo(self.uri, self.database)
|
db = Mongo(self.uri, self.database)
|
||||||
try:
|
try:
|
||||||
return await db.get_documents(self.collection, {})
|
cursor = db.collection(self.collection).find({})
|
||||||
|
return await cursor.to_list(length=None)
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
db.close()
|
||||||
|
|
||||||
async def _save(self, doc: dict) -> None:
|
async def _save(self, doc: dict) -> None:
|
||||||
db = Mongo(self.uri, self.database)
|
db = Mongo(self.uri, self.database)
|
||||||
try:
|
try:
|
||||||
await db.update_document(
|
result = await db.collection(self.collection).replace_one(
|
||||||
self.collection, {"_id": doc["_id"]}, doc, do_upsert=True
|
{"_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:
|
finally:
|
||||||
await db.close()
|
db.close()
|
||||||
|
|
||||||
async def _delete(self, fingerprint: str) -> bool:
|
async def _delete(self, fingerprint: str) -> bool:
|
||||||
db = Mongo(self.uri, self.database)
|
db = Mongo(self.uri, self.database)
|
||||||
try:
|
try:
|
||||||
removed = await db.delete_document(self.collection, {"_id": fingerprint})
|
result = await db.collection(self.collection).delete_one({"_id": fingerprint})
|
||||||
return bool(removed)
|
return result.deleted_count > 0
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
db.close()
|
||||||
|
|
||||||
def get(self, fingerprint: str) -> Optional[dict]:
|
def get(self, fingerprint: str) -> Optional[dict]:
|
||||||
"""return the key doc with this `_id`, or None"""
|
"""return the key doc with this `_id`, or None"""
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user