fix: a stored key doc with meta:null no longer crashes authorize/revoke

find_by_friendly, authorize.py's existing-target check, and revoke.py's
success-print all did doc.get("meta", {}).get(...) directly - the {} default
only applies when the key is ABSENT, not when its value is null (plausible in
shared mongo or a hand-edited JSON store), so a null meta raised AttributeError.
revoke's crash landed AFTER storage.delete() already succeeded, so a completed
revoke was reported as a raw traceback instead of a clean exit. list_keys._meta
already coerced correctly; that fix is now hoisted into commands/__init__.py as
doc_meta, the single source every meta-reading call site goes through. cli.py's
catch tuple also gains AttributeError as defense in depth for any future
unguarded meta access.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-06 00:16:52 -04:00
parent 764bcb6146
commit e5c3547c51
5 changed files with 24 additions and 14 deletions
+5 -2
View File
@@ -99,9 +99,12 @@ def main() -> int:
return _fail("capability flag failed authentication - tampered or wrong DEK") return _fail("capability flag failed authentication - tampered or wrong DEK")
except PyMongoError as error: except PyMongoError as error:
return _fail(f"storage backend error: {error}") return _fail(f"storage backend error: {error}")
except (ConfigError, CommandError, RuntimeError, ValueError, OSError, KeyError, TypeError) as error: except (
ConfigError, CommandError, RuntimeError, ValueError, OSError, KeyError, TypeError, AttributeError,
) as error:
# covers a malformed config/doc/flag (incl. KeyError/TypeError from unguarded # covers a malformed config/doc/flag (incl. KeyError/TypeError from unguarded
# indexing) - all print a clean [✘] line, not a raw traceback # 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))
+13 -1
View File
@@ -68,9 +68,21 @@ def boot_local(config, storage) -> Tuple[EnvelopeCrypto, dict]:
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
@@ -9,6 +9,7 @@ from . import (
CommandError, CommandError,
boot_local, boot_local,
build_doc, build_doc,
doc_meta,
find_by_friendly, find_by_friendly,
local_fingerprint, local_fingerprint,
make_flag, make_flag,
@@ -35,7 +36,7 @@ def run(config, storage, args) -> None:
) )
existing = storage.get(new_fp) existing = storage.get(new_fp)
if existing: if existing:
existing_friendly = existing.get("meta", {}).get("friendly", "?") existing_friendly = doc_meta(existing).get("friendly", "?")
raise CommandError( raise CommandError(
f"target key is already authorized as '{existing_friendly}'; " 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 "
@@ -7,7 +7,7 @@ the wrapped key or DEK.
from datetime import datetime, timezone 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: def _can_authorize(crypto, doc) -> str:
@@ -18,12 +18,6 @@ def _can_authorize(crypto, doc) -> str:
return "?" 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: 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 = meta.get("created_at") raw = meta.get("created_at")
@@ -44,7 +38,7 @@ 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 = _meta(doc) meta = doc_meta(doc)
print( print(
f"{doc.get('_id', '')[:16]:<18} " f"{doc.get('_id', '')[:16]:<18} "
f"{str(meta.get('friendly', '-')):<16} " f"{str(meta.get('friendly', '-')):<16} "
+2 -2
View File
@@ -7,7 +7,7 @@ rotate the DEK or scrub it from a machine that 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"
@@ -47,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)