7JSON-backed actor credential storage.
11Load and write the M11.5 ``agent_secrets.json`` file that stores local
12actor HMAC material outside SQLite.
16- Parse the slot/phase secret-file shape used by doctor and CLI flows.
17- Encode and fingerprint raw HMAC secrets.
18- Write updates atomically and apply restrictive POSIX file modes.
30- Raw secret bytes never enter the registry database.
31- Existing unknown JSON fields are ignored on read.
32- Writes use replace semantics so readers never observe partial JSON.
36- base64 (encode and decode the secret material)
37- dataclasses (the SecretBlock record)
38- hashlib (SHA-256 secret fingerprints)
39- json (read and write the secrets file)
40- os (fsync and atomic replace on write)
41- pathlib (secrets file paths)
42- secrets (fresh HMAC secrets and temporary file names)
43- oc_diagnostics (structured debug logging via _dbg)
45from __future__
import annotations
48from dataclasses
import dataclass
52from pathlib
import Path
54from typing
import Mapping
56from oc_diagnostics
import _dbg
59_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
62SECRET_FILE_MODE = 0o600
63DEFAULT_SECRETS_PATH = Path(
".option_c") /
"agent_secrets.json"
64ROTATION_PHASES = {
"active",
"rotating_old",
"rotating_new"}
67@dataclass(frozen=True)
69 """One slot credential block from ``agent_secrets.json``."""
75 secret_fingerprint: str
76 created_at: str |
None =
None
77 expires_at: str |
None =
None
81 """Decode the base64 secret material."""
83 return base64.b64decode(self.secret_b64.encode(
"ascii"), validate=
True)
86 """Return the canonical JSON block shape."""
88 data: dict[str, object] = {
89 "hmac_key_id": self.hmac_key_id,
90 "secret_b64": self.secret_b64,
91 "secret_fingerprint": self.secret_fingerprint,
101 """Return a fresh 256-bit HMAC secret."""
103 return secrets.token_bytes(32)
107 """Return base64 text for raw HMAC secret bytes."""
109 return base64.b64encode(raw_secret).decode(
"ascii")
113 """Return the DB-stored fingerprint for raw HMAC secret bytes."""
115 return hashlib.sha256(raw_secret).hexdigest()
124 created_at: str |
None =
None,
125 expires_at: str |
None =
None,
127 """Build a :class:`SecretBlock` from raw bytes."""
129 if phase
not in ROTATION_PHASES:
130 raise ValueError(f
"invalid rotation phase: {phase}")
134 hmac_key_id=hmac_key_id,
137 created_at=created_at,
138 expires_at=expires_at,
142def load_secrets(path: str | Path = DEFAULT_SECRETS_PATH) -> dict[str, dict[str, SecretBlock]]:
143 """Load secret blocks keyed by ``slot`` then ``rotation_phase``."""
146 if not source.is_file():
148 data = json.loads(source.read_text(encoding=
"utf-8"))
149 if not isinstance(data, Mapping):
150 raise ValueError(
"agent_secrets.json root must be an object")
151 blocks: dict[str, dict[str, SecretBlock]] = {}
152 for slot, slot_value
in data.items():
153 if not isinstance(slot, str)
or not isinstance(slot_value, Mapping):
155 phase_blocks: dict[str, SecretBlock] = {}
156 for phase, phase_value
in slot_value.items():
157 if not isinstance(phase, str)
or not isinstance(phase_value, Mapping):
159 if phase
not in ROTATION_PHASES:
162 if block
is not None:
163 phase_blocks[phase] = block
165 blocks[slot] = phase_blocks
170 blocks: Mapping[str, Mapping[str, SecretBlock]],
171 path: str | Path = DEFAULT_SECRETS_PATH,
173 """Atomically write secret blocks and return the target path."""
176 target.parent.mkdir(parents=
True, exist_ok=
True)
179 phase: block.to_json()
180 for phase, block
in sorted(phase_blocks.items())
182 for slot, phase_blocks
in sorted(blocks.items())
184 text = json.dumps(payload, indent=2, sort_keys=
True) +
"\n"
185 tmp = target.with_name(f
".tmp-{target.name}-{secrets.token_hex(8)}")
186 with tmp.open(
"w", encoding=
"utf-8", newline=
"\n")
as handle:
189 os.fsync(handle.fileno())
191 os.replace(tmp, target)
197 blocks: Mapping[str, Mapping[str, SecretBlock]],
200 hmac_key_id: str |
None =
None,
201 phase_preference: tuple[str, ...] = (
"rotating_new",
"active",
"rotating_old"),
202) -> SecretBlock |
None:
203 """Return the matching block for a slot/key id."""
205 slot_blocks = blocks.get(slot)
206 if slot_blocks
is None:
208 if hmac_key_id
is not None:
209 for block
in slot_blocks.values():
210 if block.hmac_key_id == hmac_key_id:
213 for phase
in phase_preference:
214 block = slot_blocks.get(phase)
215 if block
is not None:
223 value: Mapping[str, object],
224) -> SecretBlock |
None:
225 key_id = value.get(
"hmac_key_id")
226 secret_b64 = value.get(
"secret_b64")
227 fingerprint = value.get(
"secret_fingerprint")
228 if not isinstance(key_id, str)
or not isinstance(secret_b64, str):
230 if not isinstance(fingerprint, str):
232 created_at = value.get(
"created_at")
233 expires_at = value.get(
"expires_at")
238 secret_b64=secret_b64,
239 secret_fingerprint=fingerprint,
240 created_at=created_at
if isinstance(created_at, str)
else None,
241 expires_at=expires_at
if isinstance(expires_at, str)
else None,
247 path.chmod(SECRET_FILE_MODE)
dict[str, object] to_json(self)
SecretBlock|None select_secret(Mapping[str, Mapping[str, SecretBlock]] blocks, *, str slot, str|None hmac_key_id=None, tuple[str,...] phase_preference=("rotating_new", "active", "rotating_old"))
Path write_secrets(Mapping[str, Mapping[str, SecretBlock]] blocks, str|Path path=DEFAULT_SECRETS_PATH)
SecretBlock|None _parse_block(str slot, str phase, Mapping[str, object] value)
SecretBlock block_from_secret(*, str slot, str phase, str hmac_key_id, bytes raw_secret, str|None created_at=None, str|None expires_at=None)
dict[str, dict[str, SecretBlock]] load_secrets(str|Path path=DEFAULT_SECRETS_PATH)
None _chmod_secret(Path path)
str encode_secret(bytes raw_secret)
str fingerprint_secret(bytes raw_secret)