oc_chat
Loading...
Searching...
No Matches
signing.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/auth/signing.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_auth_signing.py"
6"""
7Actor HMAC signing and verification.
8
9Purpose
10-------
11Build and verify canonical HMAC-SHA256 proofs for mutating registry
12commands.
13
14Responsibilities
15----------------
16- Canonicalize the signed command payload.
17- Produce proof mappings for CLI/API callers.
18- Verify signatures with constant-time comparison and skew checks.
19
20Diagnostics
21-----------
22Domain: OC_CHAT.MAIN
23Levels:
24 L2 - lifecycle events
25 L3 - semantic details
26 L4 - deep tracing
27
28Contracts
29---------
30- The signed payload includes slot, command, args, timestamp, and key id.
31- Verification returns a structured failure reason for ``auth_events``.
32- This module performs no registry writes and reads no secret files.
33
34Dependencies
35------------
36- dataclasses (proof, credential and result records)
37- hashlib (SHA-256 for signatures and payload digests)
38- hmac (HMAC signing and constant-time comparison)
39- oc_chat.registry.canonical (canonical JSON of the signed payload)
40- oc_chat.registry.clock (timestamps for signing, skew and expiry checks)
41- oc_diagnostics (structured debug logging via _dbg)
42"""
43from __future__ import annotations
44
45from dataclasses import dataclass
46import hashlib
47import hmac
48from typing import Mapping
49
50from oc_diagnostics import _dbg
51
52from oc_chat.registry.canonical import canonical_json
53from oc_chat.registry.clock import Clock, RealClock
54
55
56_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
57
58SIGNATURE_ALGORITHM = "HMAC-SHA256"
59
60
61@dataclass(frozen=True)
63 """Client-supplied proof fields for one registry mutation."""
64
65 hmac_key_id: str
66 ts_ms: int
67 signature_hex: str
68 algorithm: str = SIGNATURE_ALGORITHM
69
70 def to_mapping(self) -> dict[str, object]:
71 """Return a JSON-safe proof mapping."""
72
73 return {
74 "algorithm": self.algorithm,
75 "hmac_key_id": self.hmac_key_id,
76 "ts_ms": self.ts_ms,
77 "signature_hex": self.signature_hex,
78 }
79
80 @classmethod
81 def from_mapping(cls, value: Mapping[str, object]) -> "HmacProof":
82 """Parse a proof mapping."""
83
84 key_id = value.get("hmac_key_id")
85 signature = value.get("signature_hex")
86 ts_ms = value.get("ts_ms")
87 algorithm = value.get("algorithm", SIGNATURE_ALGORITHM)
88 if not isinstance(key_id, str) or not key_id:
89 raise ValueError("hmac_key_id is required")
90 if not isinstance(signature, str) or not signature:
91 raise ValueError("signature_hex is required")
92 if not isinstance(ts_ms, int):
93 raise ValueError("ts_ms is required")
94 if algorithm != SIGNATURE_ALGORITHM:
95 raise ValueError("unsupported HMAC algorithm")
96 return cls(hmac_key_id=key_id, ts_ms=ts_ms, signature_hex=signature)
97
98
99@dataclass(frozen=True)
101 """Credential fields needed by HMAC verification."""
102
103 hmac_key_id: str
104 slot: str
105 raw_secret: bytes
106 rotation_phase: str
107 revoked_at: str | None = None
108 expires_at: str | None = None
109
110
111@dataclass(frozen=True)
113 """Outcome returned by ``verify_hmac``."""
114
115 ok: bool
116 hmac_key_id: str | None
117 failure_reason: str | None = None
118
119
121 *,
122 slot: str,
123 command: str,
124 args: Mapping[str, object],
125 raw_secret: bytes,
126 hmac_key_id: str,
127 clock: Clock | None = None,
128 ts_ms: int | None = None,
129) -> HmacProof:
130 """Return an HMAC proof for one command payload."""
131
132 effective_ts = _now_ms(clock) if ts_ms is None else ts_ms
133 payload = canonical_payload(
134 slot=slot,
135 command=command,
136 args=args,
137 ts_ms=effective_ts,
138 hmac_key_id=hmac_key_id,
139 )
140 return HmacProof(
141 hmac_key_id=hmac_key_id,
142 ts_ms=effective_ts,
143 signature_hex=_sign(payload, raw_secret),
144 )
145
146
148 *,
149 slot: str,
150 command: str,
151 args: Mapping[str, object],
152 proof: HmacProof,
153 credential: CredentialMaterial | None,
154 clock: Clock | None = None,
155 max_skew_seconds: int = 300,
156) -> VerifyResult:
157 """Verify one HMAC proof against resolved credential material."""
158
159 if credential is None:
160 return VerifyResult(False, proof.hmac_key_id, "no_credential")
161 if credential.slot != slot:
162 return VerifyResult(False, proof.hmac_key_id, "bad_signature")
163 if credential.revoked_at is not None:
164 return VerifyResult(False, proof.hmac_key_id, "revoked_key")
165 now_wall_func = getattr(clock or RealClock(), "now_wall")
166 now_wall = now_wall_func()
167 if credential.expires_at is not None and credential.expires_at < now_wall:
168 return VerifyResult(False, proof.hmac_key_id, "revoked_key")
169 if max_skew_seconds >= 0:
170 skew_ms = abs(_now_ms(clock) - proof.ts_ms)
171 if skew_ms > max_skew_seconds * 1000:
172 return VerifyResult(False, proof.hmac_key_id, "clock_skew")
173 expected = _sign(
175 slot=slot,
176 command=command,
177 args=args,
178 ts_ms=proof.ts_ms,
179 hmac_key_id=proof.hmac_key_id,
180 ),
181 credential.raw_secret,
182 )
183 if not hmac.compare_digest(expected, proof.signature_hex):
184 return VerifyResult(False, proof.hmac_key_id, "bad_signature")
185 return VerifyResult(True, credential.hmac_key_id)
186
187
189 *,
190 slot: str,
191 command: str,
192 args: Mapping[str, object],
193 ts_ms: int,
194 hmac_key_id: str,
195) -> str:
196 """Return canonical JSON text for the signed command."""
197
198 return canonical_json(
199 {
200 "version": 1,
201 "slot": slot,
202 "command": command,
203 "args": args,
204 "ts_ms": ts_ms,
205 "hmac_key_id": hmac_key_id,
206 }
207 )
208
209
210def payload_sha256(args: Mapping[str, object]) -> str:
211 """Return the canonical payload digest used in ``auth_events``."""
212
213 return hashlib.sha256(canonical_json(args).encode("utf-8")).hexdigest()
214
215
216def _sign(payload: str, raw_secret: bytes) -> str:
217 return hmac.new(
218 raw_secret,
219 payload.encode("utf-8"),
220 hashlib.sha256,
221 ).hexdigest()
222
223
224def _now_ms(clock: Clock | None) -> int:
225 effective_clock = RealClock() if clock is None else clock
226 return effective_clock.now_monotonic_ns() // 1_000_000
dict[str, object] to_mapping(self)
Definition signing.py:70
"HmacProof" from_mapping(cls, Mapping[str, object] value)
Definition signing.py:81
HmacProof sign_command(*, str slot, str command, Mapping[str, object] args, bytes raw_secret, str hmac_key_id, Clock|None clock=None, int|None ts_ms=None)
Definition signing.py:129
VerifyResult verify_hmac(*, str slot, str command, Mapping[str, object] args, HmacProof proof, CredentialMaterial|None credential, Clock|None clock=None, int max_skew_seconds=300)
Definition signing.py:156
str _sign(str payload, bytes raw_secret)
Definition signing.py:216
str payload_sha256(Mapping[str, object] args)
Definition signing.py:210
str canonical_payload(*, str slot, str command, Mapping[str, object] args, int ts_ms, str hmac_key_id)
Definition signing.py:195
int _now_ms(Clock|None clock)
Definition signing.py:224