Compare commits
18
Commits
v0.1.1
...
30a2b9b2a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30a2b9b2a6 | ||
|
|
eb7a2a3d18 | ||
|
|
d11cefe3df | ||
|
|
1e6f3bc44f | ||
|
|
9cfbbb80ee | ||
|
|
bf7e50f91f | ||
|
|
e5c3547c51 | ||
|
|
764bcb6146 | ||
|
|
387830084a | ||
|
|
e8ea45c15e | ||
|
|
f150f7f957 | ||
|
|
0d0558d11b | ||
|
|
bfeee80712 | ||
|
|
f2e9e5fe35 | ||
|
|
88e1eaef39 | ||
|
|
130c62e31c | ||
|
|
09e6d15e48 | ||
|
|
a40a7432ef |
+1
-1
@@ -5,4 +5,4 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.venv/
|
.venv/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
CLAUDE.md
|
.claude/
|
||||||
|
|||||||
@@ -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.1
|
envelope_authorizer @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v1.0.2
|
||||||
```
|
```
|
||||||
|
|
||||||
Direct:
|
Direct:
|
||||||
|
|
||||||
```bash
|
```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@v1.0.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.1"
|
pip install "envelope_authorizer[mongo] @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_authorizer.git@v1.0.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 `@v1.0.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
|
||||||
@@ -127,7 +129,11 @@ initialized or the friendly name is taken.
|
|||||||
Boots the local DEK, verifies the local key is itself an authorizer, then wraps
|
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. Omit
|
the same DEK to the target public key and stores a new key doc. Omit
|
||||||
`--can-authorize` for servers (`allowed: False`); pass it only for trusted
|
`--can-authorize` for servers (`allowed: False`); pass it only for trusted
|
||||||
dev/home machines.
|
dev/home machines. Refuses a target key whose fingerprint already has a record
|
||||||
|
(most importantly the local key itself) — `save` upserts by `_id`, so
|
||||||
|
authorizing an already-known key would silently replace its existing doc
|
||||||
|
(capability flag and friendly name) under a success banner instead of adding a
|
||||||
|
new key. Revoke the existing record first if you intend to re-authorize it.
|
||||||
|
|
||||||
```
|
```
|
||||||
[✔] Authorized Jy7k2ey7... | friendly: server1 [can_authorize=False]
|
[✔] Authorized Jy7k2ey7... | friendly: server1 [can_authorize=False]
|
||||||
@@ -168,9 +174,10 @@ rotation.
|
|||||||
|
|
||||||
## Config reference
|
## Config reference
|
||||||
|
|
||||||
TOML, searched cwd-first then `~`: `.authorizer.toml`. Paths expand `~`. No
|
TOML, searched cwd-first then `~`: `.authorizer.toml`. Paths (`keys.public`,
|
||||||
defaults are baked in — a missing required field raises an error naming the field
|
`keys.private`, `storage.path`) expand both `~` and `$ENV_VARS`. No defaults are
|
||||||
and the config path.
|
baked in — a missing required field raises an error naming the field and the
|
||||||
|
config path.
|
||||||
|
|
||||||
### JSON backend (default, stdlib only)
|
### JSON backend (default, stdlib only)
|
||||||
|
|
||||||
@@ -179,6 +186,7 @@ and the config path.
|
|||||||
public = "~/.ssh/id_rsa.pub"
|
public = "~/.ssh/id_rsa.pub"
|
||||||
private = "~/.ssh/id_rsa"
|
private = "~/.ssh/id_rsa"
|
||||||
identity = "user@hostname" # stamped as created_by on every key doc
|
identity = "user@hostname" # stamped as created_by on every key doc
|
||||||
|
# password = "..." # only if the private key above is encrypted
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
backend = "json"
|
backend = "json"
|
||||||
@@ -222,5 +230,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
-3
@@ -4,17 +4,18 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "envelope_authorizer"
|
name = "envelope_authorizer"
|
||||||
version = "0.1.1"
|
version = "1.1.0"
|
||||||
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 = [
|
||||||
"envelope_crypto @ git+ssh://git@git.rethinkstudios.io/rethink-public/envelope_crypto.git@v0.1.0",
|
"envelope_crypto @ git+https://git.rethinkstudios.io/rethink-public/envelope_crypto.git@v1.0.0",
|
||||||
|
"cryptography>=42.0",
|
||||||
"tomli>=2.0; python_version<'3.11'",
|
"tomli>=2.0; python_version<'3.11'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
mongo = [
|
mongo = [
|
||||||
"mongo @ git+ssh://git@git.rethinkstudios.io/rethink-public/mongo.git@v0.1.0",
|
"mongo @ git+https://git.rethinkstudios.io/rethink-public/mongo.git@v1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
__version__ = "0.1.1"
|
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
|
`config init` runs without an existing config (it writes one); every other
|
||||||
command loads config and resolves a storage backend first. expected failures
|
command loads config and resolves a storage backend first. expected failures
|
||||||
(ConfigError, CommandError, RuntimeError from a guarded backend) print a clean
|
print a clean `[✘] ...` line and exit non-zero - no traceback.
|
||||||
`[✘] ...` line and exit non-zero — no traceback.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -16,6 +15,12 @@ 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
|
||||||
from .storage import resolve
|
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:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
"""construct the argument parser with all subcommands"""
|
"""construct the argument parser with all subcommands"""
|
||||||
@@ -72,7 +77,7 @@ 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:
|
||||||
return _fail(str(error))
|
return _fail(str(error))
|
||||||
parser.parse_args(["config", "--help"])
|
parser.parse_args(["config", "--help"])
|
||||||
return 0
|
return 0
|
||||||
@@ -91,8 +96,15 @@ def main() -> int:
|
|||||||
handlers[args.cmd](config, storage, args)
|
handlers[args.cmd](config, storage, args)
|
||||||
return 0
|
return 0
|
||||||
except InvalidTag:
|
except InvalidTag:
|
||||||
return _fail("capability flag failed authentication — tampered or wrong DEK")
|
return _fail("capability flag failed authentication - tampered or wrong DEK")
|
||||||
except (ConfigError, CommandError, RuntimeError, ValueError, FileNotFoundError) as error:
|
except PyMongoError as error:
|
||||||
|
return _fail(f"storage backend error: {error}")
|
||||||
|
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))
|
return _fail(str(error))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""command implementations + shared crypto helpers for the authorizer CLI
|
"""command implementations + shared crypto helpers for the authorizer CLI
|
||||||
|
|
||||||
each command module exposes `run(config, storage, args)`. helpers here own the
|
each command module exposes `run(config, storage, args)`. helpers here own the
|
||||||
recurring crypto plumbing: minting/reading the encrypted capability flag and the
|
capability-flag and DEK-boot plumbing; none of them print key material.
|
||||||
boot sequence (unwrap the local DEK and arm a crypto instance). these never
|
|
||||||
print key material.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
@@ -22,7 +20,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
|
||||||
|
|
||||||
@@ -49,8 +51,8 @@ def local_fingerprint(crypto: EnvelopeCrypto, config) -> str:
|
|||||||
def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]:
|
def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]:
|
||||||
"""unwrap the local DEK and return an armed crypto plus the local key doc
|
"""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 /
|
raises CommandError if this machine has no key doc here. never logs or
|
||||||
authorized here). never logs or prints the unwrapped key.
|
prints the unwrapped key.
|
||||||
"""
|
"""
|
||||||
crypto = EnvelopeCrypto()
|
crypto = EnvelopeCrypto()
|
||||||
fingerprint = local_fingerprint(crypto, config)
|
fingerprint = local_fingerprint(crypto, config)
|
||||||
@@ -59,14 +61,28 @@ def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]:
|
|||||||
raise CommandError(
|
raise CommandError(
|
||||||
"not initialized on this machine (no key doc for the local public key)"
|
"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)
|
crypto.initialize(aes_key)
|
||||||
return crypto, doc
|
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]:
|
def find_by_friendly(storage, friendly: str) -> Optional[dict]:
|
||||||
"""return the doc whose meta.friendly matches, or None"""
|
"""return the doc whose meta.friendly matches, or None"""
|
||||||
for doc in storage.get_all():
|
for doc in storage.get_all():
|
||||||
if doc.get("meta", {}).get("friendly") == friendly:
|
if doc_meta(doc).get("friendly") == friendly:
|
||||||
return doc
|
return doc
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
"""`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
|
refuses a target key that already has a record - `save` upserts by `_id`, so
|
||||||
the same DEK to the target public key and stores a new key doc. `--can-authorize`
|
re-authorizing a known key would silently overwrite its flag/friendly. see
|
||||||
decides whether the new key may authorize others (omit it for servers).
|
`run()` below for the full refusal contract.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
CommandError,
|
CommandError,
|
||||||
boot_local,
|
boot_local,
|
||||||
build_doc,
|
build_doc,
|
||||||
|
doc_meta,
|
||||||
find_by_friendly,
|
find_by_friendly,
|
||||||
|
local_fingerprint,
|
||||||
make_flag,
|
make_flag,
|
||||||
read_flag,
|
read_flag,
|
||||||
)
|
)
|
||||||
@@ -25,9 +27,23 @@ def run(config, storage, args) -> None:
|
|||||||
if not read_flag(crypto, local_doc["meta"]["authorizer"]):
|
if not read_flag(crypto, local_doc["meta"]["authorizer"]):
|
||||||
raise CommandError("this key is not permitted to authorize others")
|
raise CommandError("this key is not permitted to authorize others")
|
||||||
|
|
||||||
new_fp, new_wrapped = crypto.encrypt_aes_key_with_rsa(
|
new_fp = crypto.get_rsa_key_fingerprint(args.key)
|
||||||
crypto.master_key, args.key
|
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 "
|
||||||
|
"list` if you meant to check its status"
|
||||||
)
|
)
|
||||||
|
existing = storage.get(new_fp)
|
||||||
|
if existing:
|
||||||
|
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"if you intend to re-authorize it"
|
||||||
|
)
|
||||||
|
|
||||||
|
_, new_wrapped = crypto.encrypt_aes_key_with_rsa(crypto.master_key, args.key)
|
||||||
flag = make_flag(crypto, args.can_authorize)
|
flag = make_flag(crypto, args.can_authorize)
|
||||||
doc = build_doc(new_fp, new_wrapped, flag, config.identity, args.friendly)
|
doc = build_doc(new_fp, new_wrapped, flag, config.identity, args.friendly)
|
||||||
storage.save(doc)
|
storage.save(doc)
|
||||||
|
|||||||
@@ -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
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ _TEMPLATE = """[keys]
|
|||||||
public = "~/.ssh/id_rsa.pub"
|
public = "~/.ssh/id_rsa.pub"
|
||||||
private = "~/.ssh/id_rsa"
|
private = "~/.ssh/id_rsa"
|
||||||
identity = "user@hostname"
|
identity = "user@hostname"
|
||||||
|
# password = "..." # only if the private key above is encrypted
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
backend = "json"
|
backend = "json"
|
||||||
@@ -28,7 +29,7 @@ def run(config, storage, args) -> None:
|
|||||||
"""write a commented starter config; refuse to overwrite an existing one"""
|
"""write a commented starter config; refuse to overwrite an existing one"""
|
||||||
target = Path.cwd() / CONFIG_NAME
|
target = Path.cwd() / CONFIG_NAME
|
||||||
if target.exists():
|
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:
|
with open(target, "w", encoding="utf-8") as handle:
|
||||||
handle.write(_TEMPLATE)
|
handle.write(_TEMPLATE)
|
||||||
print(f"[✔] Wrote starter config: {target}")
|
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
|
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
|
first key doc marked as an authorizer (allowed: True).
|
||||||
already initialized or the friendly name is taken.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from envelope_crypto import EnvelopeCrypto
|
from envelope_crypto import EnvelopeCrypto
|
||||||
@@ -11,7 +10,12 @@ 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 (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():
|
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"
|
||||||
@@ -28,4 +32,4 @@ def run(config, storage, args) -> None:
|
|||||||
doc = build_doc(fingerprint, wrapped, flag, config.identity, args.friendly)
|
doc = build_doc(fingerprint, wrapped, flag, config.identity, args.friendly)
|
||||||
storage.save(doc)
|
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,46 @@
|
|||||||
"""`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
|
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.
|
CAN_AUTHORIZE (`?` if unreadable here). prints only fingerprint/metadata - never
|
||||||
prints only fingerprint/metadata — never the wrapped key or DEK.
|
the wrapped key or DEK.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from . import boot_local, read_flag
|
from cryptography.exceptions import InvalidTag
|
||||||
|
|
||||||
|
from . import boot_local, doc_meta, read_flag
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
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 unreadable here
|
||||||
|
|
||||||
|
a malformed/foreign flag degrades to `?` for display continuity. a GCM auth-tag
|
||||||
|
failure is logged first (WARNING): it is either a foreign key this host can't
|
||||||
|
decrypt or a tampered authz record, and this layer can't tell them apart, so the
|
||||||
|
security signal must stay visible rather than render identically to a `?`.
|
||||||
|
"""
|
||||||
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 (KeyError, TypeError, ValueError):
|
||||||
|
return "?"
|
||||||
|
except InvalidTag:
|
||||||
|
friendly = doc_meta(doc).get("friendly", doc.get("_id", "?"))
|
||||||
|
log.warning("auth-tag verification failed for flag %s - foreign key or tampered record", friendly)
|
||||||
return "?"
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
def _created(doc) -> str:
|
def _created(meta: dict) -> str:
|
||||||
"""format created_at as a UTC timestamp string, or '-' if absent/unparseable"""
|
"""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:
|
if not raw:
|
||||||
return "-"
|
return "-"
|
||||||
try:
|
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):
|
except (TypeError, ValueError, OverflowError, OSError):
|
||||||
# one hand-edited/legacy doc with a bad created_at shouldn't abort the
|
|
||||||
# whole table render
|
|
||||||
return "-"
|
return "-"
|
||||||
|
|
||||||
|
|
||||||
@@ -40,12 +53,12 @@ def run(config, storage, args) -> None:
|
|||||||
print(header)
|
print(header)
|
||||||
print("-" * len(header))
|
print("-" * len(header))
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
meta = doc.get("meta", {})
|
meta = doc_meta(doc)
|
||||||
print(
|
print(
|
||||||
f"{doc.get('_id', '')[:16]:<18} "
|
f"{(doc.get('_id') or '')[:16]:<18} "
|
||||||
f"{str(meta.get('friendly', '-')):<16} "
|
f"{str(meta.get('friendly', '-')):<16} "
|
||||||
f"{str(meta.get('created_by', '-')):<18} "
|
f"{str(meta.get('created_by', '-')):<18} "
|
||||||
f"{_created(doc):<21} "
|
f"{_created(meta):<21} "
|
||||||
f"{_can_authorize(crypto, doc):<8}"
|
f"{_can_authorize(crypto, doc):<8}"
|
||||||
)
|
)
|
||||||
print(f"\n{len(docs)} key(s) authorized")
|
print(f"\n{len(docs)} key(s) authorized")
|
||||||
|
|||||||
@@ -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
|
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
|
local key, and deletes the record. revoke is bookkeeping only - it does not
|
||||||
bookkeeping only — it does not rotate the DEK or scrub it from a machine that
|
rotate the DEK or scrub it from a machine that already unwrapped it.
|
||||||
already unwrapped it.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from envelope_crypto import EnvelopeCrypto
|
from envelope_crypto import EnvelopeCrypto
|
||||||
|
|
||||||
from . import CommandError, find_by_friendly, local_fingerprint
|
from . import CommandError, doc_meta, find_by_friendly, local_fingerprint
|
||||||
|
|
||||||
_WARNING = (
|
_WARNING = (
|
||||||
"[!] Revoke removes the record only. It does NOT rotate the DEK or scrub it\n"
|
"[!] Revoke removes the record only. It does NOT rotate the DEK or scrub it\n"
|
||||||
@@ -18,16 +17,22 @@ _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 prefix, or None
|
||||||
for doc in storage.get_all():
|
|
||||||
if doc.get("_id", "").startswith(prefix):
|
rejects an empty or ambiguous prefix instead of silently revoking the first hit.
|
||||||
return doc
|
"""
|
||||||
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") 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}")
|
||||||
|
return matches[0] if matches else None
|
||||||
|
|
||||||
|
|
||||||
def run(config, storage, args) -> None:
|
def run(config, storage, args) -> None:
|
||||||
"""delete a key record by friendly or fingerprint, guarding the local key"""
|
"""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)
|
doc = find_by_friendly(storage, args.friendly)
|
||||||
label = args.friendly
|
label = args.friendly
|
||||||
else:
|
else:
|
||||||
@@ -42,6 +47,6 @@ def run(config, storage, args) -> None:
|
|||||||
raise CommandError("refusing to revoke the local key")
|
raise CommandError("refusing to revoke the local key")
|
||||||
|
|
||||||
storage.delete(doc["_id"])
|
storage.delete(doc["_id"])
|
||||||
friendly = doc.get("meta", {}).get("friendly", "?")
|
friendly = doc_meta(doc).get("friendly", "?")
|
||||||
print(f"[✔] Revoked: {friendly} ({doc['_id'][:16]})")
|
print(f"[✔] Revoked: {friendly} ({doc['_id'][:16]})")
|
||||||
print(_WARNING)
|
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
|
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
|
keypair (falls back to a minimal in-CLI round-trip if self_test is absent).
|
||||||
minimal in-CLI round-trip. never prints key material.
|
never prints key material.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from . import CommandError, boot_local
|
from . import CommandError, boot_local
|
||||||
@@ -13,7 +13,7 @@ def run(config, storage, args) -> None:
|
|||||||
crypto, _ = boot_local(config, storage)
|
crypto, _ = boot_local(config, storage)
|
||||||
|
|
||||||
if hasattr(crypto, "self_test"):
|
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:
|
else:
|
||||||
sample = {"_authorizer_verify": "ok", "n": 12345}
|
sample = {"_authorizer_verify": "ok", "n": 12345}
|
||||||
if crypto.decrypt_data(crypto.encrypt_data(sample)) != sample:
|
if crypto.decrypt_data(crypto.encrypt_data(sample)) != sample:
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
"""TOML config loader for the authorizer CLI
|
"""TOML config loader for the authorizer CLI
|
||||||
|
|
||||||
resolves a `.authorizer.toml` (cwd first, then ~), expands user paths, and
|
resolves a `.authorizer.toml` (cwd first, then ~), expands `~` and `$ENV_VARS` in
|
||||||
exposes typed accessors. no defaults are baked in: a missing required field
|
every path field, and exposes typed accessors. no defaults are baked in: a
|
||||||
raises a clear error naming the field and the config path that was searched.
|
missing required field raises a clear error naming the field and config path.
|
||||||
the loaded config is the only place key paths, identity, and storage live —
|
|
||||||
this lib never imports a host `config` module.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
if sys.version_info >= (3, 11):
|
if sys.version_info >= (3, 11):
|
||||||
import tomllib
|
import tomllib
|
||||||
@@ -72,6 +70,11 @@ class Config:
|
|||||||
"""path to the local RSA private key (expanded)"""
|
"""path to the local RSA private key (expanded)"""
|
||||||
return _expand(self.require("keys", "private"))
|
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
|
@property
|
||||||
def identity(self) -> str:
|
def identity(self) -> str:
|
||||||
"""human identity stamped as created_by on every key doc"""
|
"""human identity stamped as created_by on every key doc"""
|
||||||
@@ -82,6 +85,11 @@ class Config:
|
|||||||
"""selected storage backend name ("json" or "mongo")"""
|
"""selected storage backend name ("json" or "mongo")"""
|
||||||
return self.require("storage", "backend")
|
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:
|
def load_config() -> Config:
|
||||||
"""find and parse the authorizer config, or raise with guidance
|
"""find and parse the authorizer config, or raise with guidance
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""storage backend resolution for the authorizer CLI
|
"""storage backend resolution for the authorizer CLI
|
||||||
|
|
||||||
`resolve(config)` reads `[storage].backend` and returns the matching sync
|
`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.
|
package never requires the mongo extra; only selecting the mongo backend does.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -13,8 +13,7 @@ def resolve(config) -> StorageBackend:
|
|||||||
"""build the storage backend named by the config, or raise on unknown"""
|
"""build the storage backend named by the config, or raise on unknown"""
|
||||||
backend = config.storage_backend
|
backend = config.storage_backend
|
||||||
if backend == "json":
|
if backend == "json":
|
||||||
path = config.require("storage", "path")
|
return JsonStore(config.storage_path)
|
||||||
return JsonStore(path)
|
|
||||||
if backend == "mongo":
|
if backend == "mongo":
|
||||||
from .mongo_store import MongoStore
|
from .mongo_store import MongoStore
|
||||||
return MongoStore(
|
return MongoStore(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""abstract storage interface for authorizer key documents
|
"""abstract storage interface for authorizer key documents
|
||||||
|
|
||||||
a backend persists key docs keyed by `_id` (the RSA fingerprint). the interface
|
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-
|
bridges to sync internally. `save` is an upsert by `_id`, never a duplicate-
|
||||||
inserting or duplicate-raising operation.
|
inserting or duplicate-raising operation.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -35,22 +35,24 @@ 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 (unique temp file then replace)
|
"""atomically write the docs list via a 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.
|
|
||||||
"""
|
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
fd, tmp = tempfile.mkstemp(
|
fd, tmp = tempfile.mkstemp(
|
||||||
dir=self.path.parent, prefix=self.path.name + ".", suffix=".tmp"
|
dir=self.path.parent, prefix=self.path.name + ".", suffix=".tmp"
|
||||||
)
|
)
|
||||||
|
wrapped = False
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
wrapped = True
|
||||||
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:
|
except BaseException:
|
||||||
|
if not wrapped:
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
os.unlink(tmp)
|
os.unlink(tmp)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
"""mongo storage backend (behind the [mongo] extra)
|
"""mongo storage backend (behind the [mongo] extra)
|
||||||
|
|
||||||
bridges the async rethink-public `mongo` lib to the sync StorageBackend by
|
bridges the async rethink-public `mongo` lib to the sync StorageBackend: each op
|
||||||
wrapping each operation in its own asyncio.run: connect -> op -> close, fully
|
gets its own asyncio.run (connect -> op -> close), no module-level client or
|
||||||
self-contained per call. no module-level client and no shared event loop, so it
|
shared loop. requires envelope_authorizer[mongo]; without it every method raises
|
||||||
never collides with a running loop. a per-call connect is an accepted tradeoff
|
a clear RuntimeError.
|
||||||
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
|
fail-loud: ops go through the mongo lib's raw collection escape hatch, not the
|
||||||
(`db.collection(name)`, the motor collection — which raises) rather than the
|
swallow-and-default wrapped methods - this is an auth backend, so a driver error
|
||||||
swallow-and-return-default wrapped methods. this is an auth backend; conflating
|
must never read as "not initialized," and a no-op upsert raises instead of
|
||||||
a backend error with "no document / not initialized" in the thing that gates
|
silently reporting success. `close()` is synchronous on the mongo lib - not
|
||||||
authority is the worst place for the swallow anti-pattern, so a driver error
|
awaited.
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user