oc_chat
Loading...
Searching...
No Matches
secrets_store.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/auth/secrets_store.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_auth_signing.py and test_auth_rotation.py"
6"""
7JSON-backed actor credential storage.
8
9Purpose
10-------
11Load and write the M11.5 ``agent_secrets.json`` file that stores local
12actor HMAC material outside SQLite.
13
14Responsibilities
15----------------
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.
19
20Diagnostics
21-----------
22Domain: OC_CHAT.MAIN
23Levels:
24 L2 - lifecycle events
25 L3 - semantic details
26 L4 - deep tracing
27
28Contracts
29---------
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.
33
34Dependencies
35------------
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)
44"""
45from __future__ import annotations
46
47import base64
48from dataclasses import dataclass
49import hashlib
50import json
51import os
52from pathlib import Path
53import secrets
54from typing import Mapping
55
56from oc_diagnostics import _dbg
57
58
59_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
60
61
62SECRET_FILE_MODE = 0o600
63DEFAULT_SECRETS_PATH = Path(".option_c") / "agent_secrets.json"
64ROTATION_PHASES = {"active", "rotating_old", "rotating_new"}
65
66
67@dataclass(frozen=True)
69 """One slot credential block from ``agent_secrets.json``."""
70
71 slot: str
72 phase: str
73 hmac_key_id: str
74 secret_b64: str
75 secret_fingerprint: str
76 created_at: str | None = None
77 expires_at: str | None = None
78
79 @property
80 def raw_secret(self) -> bytes:
81 """Decode the base64 secret material."""
82
83 return base64.b64decode(self.secret_b64.encode("ascii"), validate=True)
84
85 def to_json(self) -> dict[str, object]:
86 """Return the canonical JSON block shape."""
87
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,
92 }
93 if self.created_at is not None:
94 data["created_at"] = self.created_at
95 if self.expires_at is not None:
96 data["expires_at"] = self.expires_at
97 return data
98
99
100def generate_secret() -> bytes:
101 """Return a fresh 256-bit HMAC secret."""
102
103 return secrets.token_bytes(32)
104
105
106def encode_secret(raw_secret: bytes) -> str:
107 """Return base64 text for raw HMAC secret bytes."""
108
109 return base64.b64encode(raw_secret).decode("ascii")
110
111
112def fingerprint_secret(raw_secret: bytes) -> str:
113 """Return the DB-stored fingerprint for raw HMAC secret bytes."""
114
115 return hashlib.sha256(raw_secret).hexdigest()
116
117
119 *,
120 slot: str,
121 phase: str,
122 hmac_key_id: str,
123 raw_secret: bytes,
124 created_at: str | None = None,
125 expires_at: str | None = None,
126) -> SecretBlock:
127 """Build a :class:`SecretBlock` from raw bytes."""
128
129 if phase not in ROTATION_PHASES:
130 raise ValueError(f"invalid rotation phase: {phase}")
131 return SecretBlock(
132 slot=slot,
133 phase=phase,
134 hmac_key_id=hmac_key_id,
135 secret_b64=encode_secret(raw_secret),
136 secret_fingerprint=fingerprint_secret(raw_secret),
137 created_at=created_at,
138 expires_at=expires_at,
139 )
140
141
142def load_secrets(path: str | Path = DEFAULT_SECRETS_PATH) -> dict[str, dict[str, SecretBlock]]:
143 """Load secret blocks keyed by ``slot`` then ``rotation_phase``."""
144
145 source = Path(path)
146 if not source.is_file():
147 return {}
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):
154 continue
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):
158 continue
159 if phase not in ROTATION_PHASES:
160 continue
161 block = _parse_block(slot, phase, phase_value)
162 if block is not None:
163 phase_blocks[phase] = block
164 if phase_blocks:
165 blocks[slot] = phase_blocks
166 return blocks
167
168
170 blocks: Mapping[str, Mapping[str, SecretBlock]],
171 path: str | Path = DEFAULT_SECRETS_PATH,
172) -> Path:
173 """Atomically write secret blocks and return the target path."""
174
175 target = Path(path)
176 target.parent.mkdir(parents=True, exist_ok=True)
177 payload = {
178 slot: {
179 phase: block.to_json()
180 for phase, block in sorted(phase_blocks.items())
181 }
182 for slot, phase_blocks in sorted(blocks.items())
183 }
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:
187 handle.write(text)
188 handle.flush()
189 os.fsync(handle.fileno())
190 _chmod_secret(tmp)
191 os.replace(tmp, target)
192 _chmod_secret(target)
193 return target
194
195
197 blocks: Mapping[str, Mapping[str, SecretBlock]],
198 *,
199 slot: str,
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."""
204
205 slot_blocks = blocks.get(slot)
206 if slot_blocks is None:
207 return 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:
211 return block
212 return None
213 for phase in phase_preference:
214 block = slot_blocks.get(phase)
215 if block is not None:
216 return block
217 return None
218
219
221 slot: str,
222 phase: str,
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):
229 return None
230 if not isinstance(fingerprint, str):
231 fingerprint = ""
232 created_at = value.get("created_at")
233 expires_at = value.get("expires_at")
234 return SecretBlock(
235 slot=slot,
236 phase=phase,
237 hmac_key_id=key_id,
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,
242 )
243
244
245def _chmod_secret(path: Path) -> None:
246 try:
247 path.chmod(SECRET_FILE_MODE)
248 except OSError:
249 pass
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)
str encode_secret(bytes raw_secret)
str fingerprint_secret(bytes raw_secret)