Compare commits
1
Commits
v0.1.4
...
f150f7f957
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f150f7f957 |
@@ -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.4
|
||||
envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.5
|
||||
```
|
||||
|
||||
Direct:
|
||||
|
||||
```bash
|
||||
pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.4"
|
||||
pip install "envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.5"
|
||||
```
|
||||
|
||||
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.4"
|
||||
pip install "envelope_authorizer[mongo] @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v0.1.5"
|
||||
```
|
||||
|
||||
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.4` suffix from the line above to install the latest unpinned.
|
||||
Drop the `@v0.1.5` suffix from the line above to install the latest unpinned.
|
||||
|
||||
## Trust model (read this)
|
||||
|
||||
@@ -174,9 +174,10 @@ rotation.
|
||||
|
||||
## Config reference
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### JSON backend (default, stdlib only)
|
||||
|
||||
@@ -185,6 +186,7 @@ and the 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"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "envelope_authorizer"
|
||||
version = "0.1.4"
|
||||
version = "0.1.5"
|
||||
description = "CLI key-authorization manager for envelope_crypto"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.4"
|
||||
__version__ = "0.1.5"
|
||||
|
||||
@@ -16,6 +16,12 @@ 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"""
|
||||
@@ -94,6 +100,8 @@ def main() -> int:
|
||||
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
|
||||
|
||||
@@ -63,7 +63,9 @@ 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)
|
||||
aes_key = crypto.decrypt_aes_key_with_rsa(
|
||||
doc["key"], config.private_key, password=config.private_key_password
|
||||
)
|
||||
crypto.initialize(aes_key)
|
||||
return crypto, doc
|
||||
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
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 fingerprints to an existing `_id` — `save` upserts by `_id`, so
|
||||
authorizing a key that is already on record (most dangerously the local key
|
||||
itself) would silently replace that doc's capability flag and friendly name
|
||||
under a success banner instead of adding a new key.
|
||||
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.
|
||||
"""
|
||||
|
||||
from . import (
|
||||
|
||||
@@ -10,6 +10,7 @@ _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"
|
||||
|
||||
@@ -13,11 +13,9 @@ 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 (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.
|
||||
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.
|
||||
"""
|
||||
if storage.get_all():
|
||||
raise CommandError(
|
||||
|
||||
@@ -11,11 +11,10 @@ from . import boot_local, read_flag
|
||||
|
||||
|
||||
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 unreadable 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.
|
||||
`?` covers any failure (unwrap mismatch, missing/malformed doc) so the table
|
||||
always renders instead of crashing on one bad row.
|
||||
"""
|
||||
try:
|
||||
return "Yes" if read_flag(crypto, doc["meta"]["authorizer"]) else "No"
|
||||
@@ -23,9 +22,15 @@ def _can_authorize(crypto, doc) -> str:
|
||||
return "?"
|
||||
|
||||
|
||||
def _created(doc) -> str:
|
||||
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 = doc.get("meta", {}).get("created_at")
|
||||
raw = meta.get("created_at")
|
||||
if not raw:
|
||||
return "-"
|
||||
try:
|
||||
@@ -45,12 +50,12 @@ def run(config, storage, args) -> None:
|
||||
print(header)
|
||||
print("-" * len(header))
|
||||
for doc in docs:
|
||||
meta = doc.get("meta", {})
|
||||
meta = _meta(doc)
|
||||
print(
|
||||
f"{doc.get('_id', '')[:16]:<18} "
|
||||
f"{str(meta.get('friendly', '-')):<16} "
|
||||
f"{str(meta.get('created_by', '-')):<18} "
|
||||
f"{_created(doc):<21} "
|
||||
f"{_created(meta):<21} "
|
||||
f"{_can_authorize(crypto, doc):<8}"
|
||||
)
|
||||
print(f"\n{len(docs)} key(s) authorized")
|
||||
|
||||
@@ -20,8 +20,8 @@ _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 (which would match every key) and an ambiguous prefix
|
||||
that matches more than one key, rather than silently revoking the first match.
|
||||
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")
|
||||
@@ -34,7 +34,7 @@ def _find_by_fingerprint(storage, prefix: str):
|
||||
|
||||
def run(config, storage, args) -> None:
|
||||
"""delete a key record by friendly or fingerprint, guarding the local key"""
|
||||
if args.friendly:
|
||||
if args.friendly is not None:
|
||||
doc = find_by_friendly(storage, args.friendly)
|
||||
label = args.friendly
|
||||
else:
|
||||
|
||||
@@ -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)
|
||||
crypto.self_test(config.public_key, config.private_key, password=config.private_key_password)
|
||||
else:
|
||||
sample = {"_authorizer_verify": "ok", "n": 12345}
|
||||
if crypto.decrypt_data(crypto.encrypt_data(sample)) != sample:
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""TOML config loader for the authorizer CLI
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
@@ -72,6 +73,11 @@ 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"""
|
||||
@@ -82,6 +88,11 @@ 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
|
||||
|
||||
@@ -13,8 +13,7 @@ def resolve(config) -> StorageBackend:
|
||||
"""build the storage backend named by the config, or raise on unknown"""
|
||||
backend = config.storage_backend
|
||||
if backend == "json":
|
||||
path = config.require("storage", "path")
|
||||
return JsonStore(path)
|
||||
return JsonStore(config.storage_path)
|
||||
if backend == "mongo":
|
||||
from .mongo_store import MongoStore
|
||||
return MongoStore(
|
||||
|
||||
@@ -35,11 +35,11 @@ class JsonStore(StorageBackend):
|
||||
return data
|
||||
|
||||
def _write(self, docs: List[dict]) -> None:
|
||||
"""atomically write the docs list (unique temp file then replace)
|
||||
"""atomically write the docs list (unique temp file then os.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.
|
||||
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.
|
||||
"""
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
@@ -48,14 +48,13 @@ class JsonStore(StorageBackend):
|
||||
wrapped = False
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
wrapped = True # fdopen took ownership of fd; its close() handles it
|
||||
wrapped = True # fdopen owns fd now; its close() handles it
|
||||
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 so
|
||||
# it isn't leaked (the `with` only closes once fdopen returns a file object)
|
||||
# fdopen raised before taking ownership — close the raw fd ourselves
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
"""mongo storage backend (behind the [mongo] extra)
|
||||
|
||||
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.
|
||||
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: 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.
|
||||
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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
Reference in New Issue
Block a user