Compare commits
6
Commits
f150f7f957
...
1bb4979b8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb4979b8c | ||
|
|
bf7e50f91f | ||
|
|
e5c3547c51 | ||
|
|
764bcb6146 | ||
|
|
387830084a | ||
|
|
e8ea45c15e |
@@ -13,26 +13,26 @@ 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.7
|
||||
```
|
||||
|
||||
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.7"
|
||||
```
|
||||
|
||||
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.7"
|
||||
```
|
||||
|
||||
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.
|
||||
Drop the `@v0.1.7` suffix from the line above to install the latest unpinned.
|
||||
|
||||
## Trust model (read this)
|
||||
|
||||
|
||||
+2
-1
@@ -4,11 +4,12 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "envelope_authorizer"
|
||||
version = "0.1.5"
|
||||
version = "1.0.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",
|
||||
"cryptography>=42.0",
|
||||
"tomli>=2.0; python_version<'3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
__version__ = "0.1.5"
|
||||
from importlib.metadata import version, PackageNotFoundError
|
||||
|
||||
try:
|
||||
__version__ = version("envelope_authorizer")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "0.0.0+unknown"
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
`config init` runs without an existing config (it writes one); every other
|
||||
command loads config and resolves a storage backend first. expected failures
|
||||
(ConfigError, CommandError, RuntimeError from a guarded backend) print a clean
|
||||
`[✘] ...` line and exit non-zero — no traceback.
|
||||
print a clean `[✘] ...` line and exit non-zero - no traceback.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -79,8 +78,6 @@ def main() -> int:
|
||||
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
|
||||
return _fail(str(error))
|
||||
parser.parse_args(["config", "--help"])
|
||||
return 0
|
||||
@@ -99,13 +96,15 @@ def main() -> int:
|
||||
handlers[args.cmd](config, storage, args)
|
||||
return 0
|
||||
except InvalidTag:
|
||||
return _fail("capability flag failed authentication — tampered or wrong DEK")
|
||||
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, OSError, KeyError, TypeError, AttributeError,
|
||||
) as error:
|
||||
# covers a malformed config/doc/flag (incl. KeyError/TypeError from unguarded
|
||||
# indexing, AttributeError from a null field a caller assumed was a dict) -
|
||||
# all print a clean [✘] line, not a raw traceback
|
||||
return _fail(str(error))
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""command implementations + shared crypto helpers for the authorizer CLI
|
||||
|
||||
each command module exposes `run(config, storage, args)`. helpers here own the
|
||||
recurring crypto plumbing: minting/reading the encrypted capability flag and the
|
||||
boot sequence (unwrap the local DEK and arm a crypto instance). these never
|
||||
print key material.
|
||||
capability-flag and DEK-boot plumbing; none of them print key material.
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -25,7 +23,7 @@ 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.
|
||||
raising - a privilege gate must default to deny on a malformed flag.
|
||||
"""
|
||||
data = crypto.decrypt_data(blob)
|
||||
return bool(data.get("allowed", False)) if isinstance(data, dict) else False
|
||||
@@ -53,8 +51,8 @@ def local_fingerprint(crypto: EnvelopeCrypto, config) -> str:
|
||||
def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]:
|
||||
"""unwrap the local DEK and return an armed crypto plus the local key doc
|
||||
|
||||
raises CommandError if this machine has no key doc (not initialized /
|
||||
authorized here). never logs or prints the unwrapped key.
|
||||
raises CommandError if this machine has no key doc here. never logs or
|
||||
prints the unwrapped key.
|
||||
"""
|
||||
crypto = EnvelopeCrypto()
|
||||
fingerprint = local_fingerprint(crypto, config)
|
||||
@@ -70,9 +68,21 @@ def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]:
|
||||
return crypto, doc
|
||||
|
||||
|
||||
def doc_meta(doc: dict) -> dict:
|
||||
"""the doc's meta block as a dict, coercing a missing/null/malformed one to {}
|
||||
|
||||
a stored doc's `meta` can be null (shared mongo, a hand-edited JSON file) - the
|
||||
dict.get(key, {}) default only applies when the key is ABSENT, not when its
|
||||
value is null, so every meta-reading call site must go through this rather than
|
||||
`doc.get("meta", {})` directly or it raises AttributeError on a null meta.
|
||||
"""
|
||||
meta = doc.get("meta")
|
||||
return meta if isinstance(meta, dict) else {}
|
||||
|
||||
|
||||
def find_by_friendly(storage, friendly: str) -> Optional[dict]:
|
||||
"""return the doc whose meta.friendly matches, or None"""
|
||||
for doc in storage.get_all():
|
||||
if doc.get("meta", {}).get("friendly") == friendly:
|
||||
if doc_meta(doc).get("friendly") == friendly:
|
||||
return doc
|
||||
return None
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"""`authorizer authorize` — grant another machine access to the DEK
|
||||
"""`authorizer authorize` - grant another machine access to the DEK
|
||||
|
||||
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.
|
||||
refuses a target key that already has a record - `save` upserts by `_id`, so
|
||||
re-authorizing a known key would silently overwrite its flag/friendly. see
|
||||
`run()` below for the full refusal contract.
|
||||
"""
|
||||
|
||||
from . import (
|
||||
CommandError,
|
||||
boot_local,
|
||||
build_doc,
|
||||
doc_meta,
|
||||
find_by_friendly,
|
||||
local_fingerprint,
|
||||
make_flag,
|
||||
@@ -33,15 +31,15 @@ def run(config, storage, args) -> None:
|
||||
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 "
|
||||
"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", "?")
|
||||
existing_friendly = doc_meta(existing).get("friendly", "?")
|
||||
raise CommandError(
|
||||
f"target key is already authorized as '{existing_friendly}'; "
|
||||
f"authorize would silently replace that record — revoke it first "
|
||||
f"authorize would silently replace that record - revoke it first "
|
||||
f"if you intend to re-authorize it"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""`authorizer config init` — scaffold a starter .authorizer.toml in cwd"""
|
||||
"""`authorizer config init` - scaffold a starter .authorizer.toml in cwd"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
@@ -29,7 +29,7 @@ def run(config, storage, args) -> None:
|
||||
"""write a commented starter config; refuse to overwrite an existing one"""
|
||||
target = Path.cwd() / CONFIG_NAME
|
||||
if target.exists():
|
||||
raise CommandError(f"{target} already exists — refusing to overwrite")
|
||||
raise CommandError(f"{target} already exists - refusing to overwrite")
|
||||
with open(target, "w", encoding="utf-8") as handle:
|
||||
handle.write(_TEMPLATE)
|
||||
print(f"[✔] Wrote starter config: {target}")
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""`authorizer init` — create a fresh DEK and authorize the local key
|
||||
"""`authorizer init` - create a fresh DEK and authorize the local key
|
||||
|
||||
generates a new AES data key, wraps it to the local public key, and stores the
|
||||
first key doc marked as an authorizer (allowed: True). refuses if the system is
|
||||
already initialized or the friendly name is taken.
|
||||
first key doc marked as an authorizer (allowed: True).
|
||||
"""
|
||||
|
||||
from envelope_crypto import EnvelopeCrypto
|
||||
@@ -14,7 +13,7 @@ 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
|
||||
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.
|
||||
"""
|
||||
if storage.get_all():
|
||||
@@ -33,4 +32,4 @@ def run(config, storage, args) -> None:
|
||||
doc = build_doc(fingerprint, wrapped, flag, config.identity, args.friendly)
|
||||
storage.save(doc)
|
||||
|
||||
print(f"[✔] Initialized — fingerprint: {fingerprint} | friendly: {args.friendly} [authorizer=True]")
|
||||
print(f"[✔] Initialized - fingerprint: {fingerprint} | friendly: {args.friendly} [authorizer=True]")
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
"""`authorizer list` — show every authorized key in a padded table
|
||||
"""`authorizer list` - show every authorized key in a padded table
|
||||
|
||||
boots the local DEK once, then for each doc decrypts its capability flag to show
|
||||
CAN_AUTHORIZE. a doc the local key cannot unwrap shows `?` rather than crashing.
|
||||
prints only fingerprint/metadata — never the wrapped key or DEK.
|
||||
CAN_AUTHORIZE (`?` if unreadable here). prints only fingerprint/metadata - never
|
||||
the wrapped key or DEK.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import boot_local, read_flag
|
||||
from . import boot_local, doc_meta, 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 unreadable 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")
|
||||
@@ -36,8 +26,6 @@ def _created(meta: dict) -> str:
|
||||
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 "-"
|
||||
|
||||
|
||||
@@ -50,9 +38,9 @@ def run(config, storage, args) -> None:
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
for doc in docs:
|
||||
meta = _meta(doc)
|
||||
meta = doc_meta(doc)
|
||||
print(
|
||||
f"{doc.get('_id', '')[:16]:<18} "
|
||||
f"{(doc.get('_id') or '')[:16]:<18} "
|
||||
f"{str(meta.get('friendly', '-')):<16} "
|
||||
f"{str(meta.get('created_by', '-')):<18} "
|
||||
f"{_created(meta):<21} "
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""`authorizer revoke` — remove a key's authorization record
|
||||
"""`authorizer revoke` - remove a key's authorization record
|
||||
|
||||
finds the target by friendly name or fingerprint prefix, refuses to revoke the
|
||||
local key, deletes the record, and prints an honesty warning that revoke is
|
||||
bookkeeping only — it does not rotate the DEK or scrub it from a machine that
|
||||
already unwrapped it.
|
||||
local key, and deletes the record. revoke is bookkeeping only - it does not
|
||||
rotate the DEK or scrub it from a machine that already unwrapped it.
|
||||
"""
|
||||
|
||||
from envelope_crypto import EnvelopeCrypto
|
||||
|
||||
from . import CommandError, find_by_friendly, local_fingerprint
|
||||
from . import CommandError, doc_meta, find_by_friendly, local_fingerprint
|
||||
|
||||
_WARNING = (
|
||||
"[!] Revoke removes the record only. It does NOT rotate the DEK or scrub it\n"
|
||||
@@ -18,14 +17,13 @@ _WARNING = (
|
||||
|
||||
|
||||
def _find_by_fingerprint(storage, prefix: str):
|
||||
"""return the single doc whose `_id` starts with the given prefix, or None
|
||||
"""return the single doc whose `_id` starts with the 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.
|
||||
rejects an empty or ambiguous prefix 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)]
|
||||
matches = [doc for doc in storage.get_all() if (doc.get("_id") or "").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}")
|
||||
@@ -49,6 +47,6 @@ def run(config, storage, args) -> None:
|
||||
raise CommandError("refusing to revoke the local key")
|
||||
|
||||
storage.delete(doc["_id"])
|
||||
friendly = doc.get("meta", {}).get("friendly", "?")
|
||||
friendly = doc_meta(doc).get("friendly", "?")
|
||||
print(f"[✔] Revoked: {friendly} ({doc['_id'][:16]})")
|
||||
print(_WARNING)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""`authorizer verify` — health-check the local crypto setup
|
||||
"""`authorizer verify` - health-check the local crypto setup
|
||||
|
||||
boots the local DEK, then runs envelope_crypto's self_test against the local
|
||||
keypair. if the installed envelope_crypto lacks self_test, falls back to a
|
||||
minimal in-CLI round-trip. never prints key material.
|
||||
keypair (falls back to a minimal in-CLI round-trip if self_test is absent).
|
||||
never prints key material.
|
||||
"""
|
||||
|
||||
from . import CommandError, boot_local
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"""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.
|
||||
every path field, and exposes typed accessors. no defaults are baked in: a
|
||||
missing required field raises a clear error naming the field and config path.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""storage backend resolution for the authorizer CLI
|
||||
|
||||
`resolve(config)` reads `[storage].backend` and returns the matching sync
|
||||
StorageBackend (json or mongo). the mongo backend is guarded — importing this
|
||||
StorageBackend (json or mongo). the mongo backend is guarded - importing this
|
||||
package never requires the mongo extra; only selecting the mongo backend does.
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""abstract storage interface for authorizer key documents
|
||||
|
||||
a backend persists key docs keyed by `_id` (the RSA fingerprint). the interface
|
||||
is intentionally sync — the CLI is a one-shot tool; an async backend (mongo)
|
||||
is intentionally sync - the CLI is a one-shot tool; an async backend (mongo)
|
||||
bridges to sync internally. `save` is an upsert by `_id`, never a duplicate-
|
||||
inserting or duplicate-raising operation.
|
||||
"""
|
||||
|
||||
@@ -35,12 +35,7 @@ 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 via a unique temp file then os.replace"""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=self.path.parent, prefix=self.path.name + ".", suffix=".tmp"
|
||||
@@ -48,13 +43,12 @@ class JsonStore(StorageBackend):
|
||||
wrapped = False
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
wrapped = True # fdopen owns fd now; its close() handles it
|
||||
wrapped = True
|
||||
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:
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
|
||||
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.
|
||||
shared loop. 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.
|
||||
fail-loud: ops go through the mongo lib's raw collection escape hatch, 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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
Reference in New Issue
Block a user