gabriel / musehub public
musehub_auth.py python
308 lines 10.7 KB
Raw
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
1 """Pydantic models for public-key challenge-response authentication.
2
3 Wire protocol
4 -------------
5 Step 1 — POST /api/auth/challenge
6 Request: ChallengeRequest {"fingerprint": "<hex64>", "algorithm": "ed25519"}
7 Response: ChallengeResponse {"challenge_token": "<nonce_hex>", "is_new_key": bool}
8
9 ``challenge_token`` is a signed hex nonce (5 min TTL) containing the raw bytes
10 the client must sign with its Ed25519 private key.
11 The client must sign ``bytes.fromhex(challenge_token)`` with its private key.
12
13 Step 2 — POST /api/auth/verify
14 Request: VerifyRequest {challenge_token, public_key_b64, signature_b64, ?handle, ?label, ?identity_type}
15 Response: VerifyResponse {"handle": "<handle>", "identity_id": "...", "auth_method": "ed25519", "key": {...}}
16
17 ``public_key_b64`` — URL-safe base64 (no padding) of the raw public key bytes.
18 ``signature_b64`` — URL-safe base64 of the signature over the raw nonce bytes.
19 ``handle`` — required when registering a new key (is_new_key=True).
20 ``identity_type`` — "human" (default) or "agent".
21
22 Agent provisioning — POST /api/identities/agent (operator-authenticated via MSign)
23 Request: AgentRegistrationRequest {handle, public_key_b64, fingerprint, algorithm, ...}
24 Response: AgentRegistrationResponse {handle, identity_id, is_new_identity}
25
26 The operator proves their identity via MSign; the server creates an agent
27 identity with ``spawned_by`` set to the operator's handle. No challenge-
28 response is required — trust is established at provisioning time.
29
30 Algorithm identifiers
31 ----------------------
32 "ed25519" — RFC 8032 / FIPS 186-5; classical, not quantum-safe.
33 "ml-dsa-65" — FIPS 204; NIST post-quantum standard. (Pending implementation.)
34 """
35
36 from pydantic import BaseModel, Field, field_validator, model_validator
37
38 from musehub.crypto.keys import KeyAlgorithm, IMPLEMENTED_ALGORITHMS, PUBLIC_KEY_SIZES
39
40 # Sorted list of algorithm strings for error messages / OpenAPI docs
41 _IMPLEMENTED_ALGO_VALUES = sorted(a.value for a in IMPLEMENTED_ALGORITHMS)
42
43 class ChallengeRequest(BaseModel):
44 """Request a challenge nonce for the given public key fingerprint."""
45
46 fingerprint: str = Field(
47 ...,
48 min_length=71,
49 max_length=71,
50 pattern=r"^sha256:[0-9a-f]{64}$",
51 description="Canonical sha256:-prefixed fingerprint of the raw public key bytes.",
52 )
53 algorithm: str = Field(
54 default=KeyAlgorithm.ED25519.value,
55 description=(
56 "Signing algorithm for this key. "
57 f"Implemented: {_IMPLEMENTED_ALGO_VALUES}."
58 ),
59 )
60
61 @field_validator("algorithm")
62 @classmethod
63 def _validate_algorithm(cls, v: str) -> str:
64 try:
65 algo = KeyAlgorithm(v)
66 except ValueError:
67 known = [a.value for a in KeyAlgorithm]
68 raise ValueError(
69 f"Unknown algorithm '{v}'. Known: {known}. "
70 f"Implemented: {_IMPLEMENTED_ALGO_VALUES}."
71 )
72 if algo not in IMPLEMENTED_ALGORITHMS:
73 raise ValueError(
74 f"Algorithm '{v}' is defined but not yet implemented. "
75 f"Currently implemented: {_IMPLEMENTED_ALGO_VALUES}."
76 )
77 return v
78
79 class ChallengeResponse(BaseModel):
80 """Challenge nonce to sign and submit to /api/auth/verify."""
81
82 challenge_token: str = Field(
83 ...,
84 description=(
85 "64-char hex nonce (32 random bytes from CSPRNG). "
86 "Sign bytes.fromhex(challenge_token) with your private key."
87 ),
88 )
89 is_new_key: bool = Field(
90 ...,
91 description="True when the fingerprint is not yet registered.",
92 )
93 expires_in: int = Field(300, description="Challenge validity in seconds.")
94 algorithm: str = Field(..., description="The algorithm the challenge was issued for.")
95
96 class VerifyRequest(BaseModel):
97 """Submit a signed challenge to complete authentication."""
98
99 challenge_token: str = Field(..., description="The nonce hex string from /api/auth/challenge.")
100 public_key_b64: str = Field(
101 ...,
102 min_length=1,
103 description="Canonically-prefixed public key: 'ed25519:<base64url>'.",
104 )
105 signature_b64: str = Field(
106 ...,
107 min_length=1,
108 description=(
109 "Signature over raw nonce bytes (bytes.fromhex(challenge_token)), "
110 "canonically prefixed: 'ed25519:<base64url>'."
111 ),
112 )
113 handle: str | None = Field(
114 None,
115 min_length=1,
116 max_length=64,
117 description="Desired handle (username). Required when registering a new key.",
118 )
119 display_name: str | None = Field(
120 None,
121 max_length=128,
122 description="Optional display name shown on the profile.",
123 )
124 label: str | None = Field(
125 None,
126 max_length=255,
127 description='Optional friendly key label, e.g. "MacBook Pro" or "CI agent".',
128 )
129 identity_type: str = Field(
130 default="human",
131 description='Identity type: "human" (default) or "agent".',
132 )
133
134 @field_validator("handle")
135 @classmethod
136 def _validate_handle(cls, v: str | None) -> str | None:
137 if v is None:
138 return None
139 normalized = v.strip().lower()
140 import re
141 if not re.fullmatch(r"[a-z0-9_-]+", normalized):
142 raise ValueError(
143 "Handle must contain only lowercase letters, digits, underscores, and hyphens."
144 )
145 return normalized
146
147 @field_validator("identity_type")
148 @classmethod
149 def _validate_identity_type(cls, v: str) -> str:
150 if v not in ("human", "agent"):
151 raise ValueError('identity_type must be "human" or "agent".')
152 return v
153
154 class RotateKeyRequest(BaseModel):
155 """Add a new key to an existing identity during key rotation.
156
157 Requires ``Authorization: MSign`` from an existing key of the same identity
158 (old key proves account ownership). The ``challenge_token`` + ``signature_b64``
159 pair proves the caller also owns the corresponding new private key.
160 """
161
162 challenge_token: str = Field(
163 ...,
164 description="The nonce hex string returned by POST /api/auth/challenge.",
165 )
166 public_key_b64: str = Field(
167 ...,
168 min_length=1,
169 description="Canonically-prefixed public key: 'ed25519:<base64url>'.",
170 )
171 signature_b64: str = Field(
172 ...,
173 min_length=1,
174 description=(
175 "Signature over raw nonce bytes (bytes.fromhex(challenge_token)), "
176 "canonically prefixed: 'ed25519:<base64url>'."
177 ),
178 )
179 label: str | None = Field(
180 None,
181 max_length=255,
182 description='Optional friendly key label, e.g. "rotation-v2".',
183 )
184
185 class AuthKeyResponse(BaseModel):
186 """Public summary of a registered auth key (never exposes key material)."""
187
188 key_id: str
189 algorithm: str
190 fingerprint: str
191 label: str
192 created_at: str
193 last_used_at: str | None
194
195 class VerifyResponse(BaseModel):
196 """Successful key registration / re-registration response.
197
198 No token is returned — authentication is per-request via MSign.
199 Use the registered key to sign subsequent requests.
200 """
201
202 handle: str
203 identity_id: str
204 is_new_identity: bool = Field(
205 ...,
206 description="True when this verify call created a new identity.",
207 )
208 auth_method: str = Field(
209 ...,
210 description="Algorithm used to authenticate (e.g. 'ed25519', 'ml-dsa-65').",
211 )
212 key: AuthKeyResponse
213
214 class AgentRegistrationRequest(BaseModel):
215 """Operator-signed agent provisioning request — ``POST /api/identities/agent``.
216
217 The caller proves their identity via ``Authorization: MSign …``. No
218 challenge-response is required — the operator's MSign header establishes
219 authority; the server sets ``spawned_by`` to the authenticated handle.
220
221 ``public_key_b64`` is URL-safe base64 (no padding) of the raw 32-byte
222 Ed25519 public key — identical to the format used by ``/api/auth/verify``.
223 ``fingerprint`` is the canonical ``sha256:``-prefixed fingerprint of those
224 32 bytes (format: ``sha256:<64-hex>``, 71 chars). Both are derived from the
225 same ephemeral keypair generated at agent spawn time.
226 """
227
228 handle: str = Field(
229 ...,
230 min_length=1,
231 max_length=64,
232 description="Desired agent handle, e.g. 'agentception-abc123'.",
233 )
234 public_key_b64: str = Field(
235 ...,
236 min_length=1,
237 description="URL-safe base64 (no padding) of the raw Ed25519 public key.",
238 )
239 fingerprint: str = Field(
240 ...,
241 min_length=71,
242 max_length=71,
243 pattern=r"^sha256:[0-9a-f]{64}$",
244 description="Canonical sha256:-prefixed fingerprint of the raw public key bytes.",
245 )
246 algorithm: str = Field(
247 default=KeyAlgorithm.ED25519,
248 description="Signing algorithm for this key.",
249 )
250 agent_model: str = Field(
251 default="",
252 max_length=255,
253 description="LLM model this agent will run (e.g. 'claude-sonnet-4-6').",
254 )
255 scope: list[str] = Field(
256 default_factory=list,
257 description="Permitted operation scopes, e.g. ['push:agentception'].",
258 )
259 expires_at: str | None = Field(
260 None,
261 description="ISO-8601 UTC expiry time for this agent key. null = no expiry.",
262 )
263 label: str = Field(
264 default="",
265 max_length=255,
266 description='Key label, e.g. "ephemeral/agentception-abc123".',
267 )
268
269 @field_validator("handle")
270 @classmethod
271 def _validate_handle(cls, v: str) -> str:
272 import re
273 normalized = v.strip().lower()
274 if not re.fullmatch(r"[a-z0-9_-]+", normalized):
275 raise ValueError(
276 "Handle must contain only lowercase letters, digits, underscores, and hyphens."
277 )
278 return normalized
279
280 @field_validator("algorithm")
281 @classmethod
282 def _validate_algorithm(cls, v: str) -> str:
283 try:
284 algo = KeyAlgorithm(v)
285 except ValueError:
286 known = [a.value for a in KeyAlgorithm]
287 raise ValueError(f"Unknown algorithm '{v}'. Known: {known}.")
288 if algo not in IMPLEMENTED_ALGORITHMS:
289 raise ValueError(
290 f"Algorithm '{v}' is defined but not yet implemented. "
291 f"Currently implemented: {sorted(a.value for a in IMPLEMENTED_ALGORITHMS)}."
292 )
293 return v
294
295 class AgentRegistrationResponse(BaseModel):
296 """Response for ``POST /api/identities/agent``."""
297
298 handle: str
299 identity_id: str
300 is_new_identity: bool = Field(
301 ...,
302 description="True when a new identity was created.",
303 )
304 spawned_by: str = Field(
305 ...,
306 description="Handle of the operator who provisioned this agent.",
307 )
308 key: AuthKeyResponse
File History 11 commits
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 15 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 17 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 30 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 34 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 35 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 36 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 36 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 36 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 48 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 50 days ago