gabriel / musehub public
test_msign.py python
1,116 lines 49.7 KB
Raw
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago
1 """MSign authentication — 7-layer test suite.
2
3 MSign is MuseHub's per-request Ed25519 authentication protocol. Every
4 authenticated HTTP request carries:
5
6 Authorization: MSign handle="<handle>" alg="ed25519" ts=<unix_ts> sig="<b64url>"
7
8 where ``sig`` is the Ed25519 signature over the canonical message:
9
10 {algorithm}\\n{METHOD}\\n{host}\\n{path_with_query}\\n{ts}\\n{sha256_hex(body)}
11
12 For ``application/x-muse-wire`` (streaming push) the body hash is always
13 SHA-256("") — the body is content-addressed per-frame; MSign only covers
14 identity + replay protection.
15
16 Canonical prefix invariant
17 --------------------------
18 Every cryptographic value stored in the database is canonically prefixed:
19 - ``public_key_b64`` → ``"ed25519:<base64url>"``
20 - ``fingerprint`` → ``"sha256:<64-hex>"``
21
22 ``b64url_decode`` in ``musehub.crypto.keys`` handles both prefixed and bare
23 values for backward compatibility, but all new data must be prefixed.
24
25 Wire protocol format (MWP)
26 --------------------------
27 Push requests use ``application/x-muse-wire`` with framed binary data:
28
29 b"muse" 4 bytes magic
30 0x01 1 byte version
31 uint32 BE 4 bytes envelope_len
32 msgpack dict {ft, sz, id} envelope (ft=frame type, sz=payload bytes, id=blob_id)
33 uint64 BE 8 bytes payload_len
34 raw bytes msgpack payload
35
36 Frame types: H (HEADER), O (OBJECT), OC (OBJECT_CHUNK), C (COMMIT_PACK), E (END).
37 A minimal valid push is H + C + E with no objects and no commits.
38
39 Test layers
40 -----------
41 1. Unit — MSignContext dataclass, TokenClaims alias, _parse_msign_header
42 edge cases, build_canonical_message query-string handling
43 2. Integration — _verify_msign via FastAPI deps with a real DB, no HTTP
44 (invoking require_signed_request / optional_signed_request
45 through the dependency chain — service-layer behaviour)
46 3. E2E — Full HTTP stack: query-string in signature, agent identity
47 type, method/path mismatch, soft-deleted identity
48 4. Stress — 28 rapid signed GET requests, concurrent identities,
49 header parsing under repeated calls
50 5. Data — MSignContext field accuracy, multi-key iteration order,
51 revocation takes immediate effect
52 6. Security — WWW-Authenticate header on 401, handle injection, invalid
53 base64 sig, stale timestamp, no key-material leakage
54 7. Performance — Header parsing throughput, canonical message latency,
55 Ed25519 sign latency, end-to-end verification budget
56
57 Notes
58 -----
59 - ``AUTH_LIMIT = "10000/minute"`` in the test environment — 401s do not trip
60 the rate limiter.
61 - The ``client`` fixture uses autouse ``db_session`` so the test DB is shared
62 across requests within a test.
63 - ``auth_headers`` bypass fixture is NOT used here — all tests exercise the
64 real MSign code path.
65 """
66 from __future__ import annotations
67
68 import hashlib
69 import secrets
70 import struct
71 import time
72
73 import msgpack
74 import pytest
75 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
76 from httpx import AsyncClient
77 from sqlalchemy.ext.asyncio import AsyncSession
78
79 from muse.core.types import blob_id, encode_pubkey
80 from musehub.types.json_types import JSONObject
81 from musehub.auth.request_signing import (
82 REPLAY_WINDOW_SECONDS,
83 MSignContext,
84 _parse_msign_header,
85 build_canonical_message,
86 )
87 from musehub.auth.dependencies import (
88 TokenClaims,
89 optional_token,
90 require_valid_token,
91 )
92 from musehub.crypto.keys import b64url_encode, b64url_decode, key_fingerprint
93 from musehub.db.musehub_auth_models import MusehubAuthKey
94 from musehub.db import musehub_models as db
95 from tests.factories import create_repo as factory_create_repo
96
97
98 # ── Wire protocol helpers ──────────────────────────────────────────────────────
99
100
101 def _encode_mwp_frame(ft: str, payload_dict: JSONObject) -> bytes:
102 """Encode a single MWP wire frame with the correct 5-part binary layout.
103
104 Layout: b"muse" | 0x01 | uint32(envelope_len) | envelope_msgpack |
105 uint64(payload_len) | payload_msgpack
106
107 The envelope carries {ft, sz, id} where:
108 - ``ft`` — frame type string ("H", "C", "E", …)
109 - ``sz`` — byte length of the payload
110 - ``id`` — blob_id (``sha256:<64-hex>``) of the payload bytes
111
112 The ``id`` field is verified by the server before processing, so it must
113 be correct. ``blob_id`` from ``muse.core.types`` produces the expected
114 ``sha256:<hex>`` format.
115 """
116 payload = msgpack.packb(payload_dict, use_bin_type=True)
117 payload_id = blob_id(payload)
118 envelope = msgpack.packb({"ft": ft, "sz": len(payload), "id": payload_id}, use_bin_type=True)
119 return (
120 b"muse"
121 + b"\x01"
122 + struct.pack(">I", len(envelope))
123 + envelope
124 + struct.pack(">Q", len(payload))
125 + payload
126 )
127
128
129 def _minimal_push_body(branch: str = "main") -> bytes:
130 """Return the minimal valid MWP wire body for a no-op push.
131
132 Sends H (HEADER) → C (COMMIT_PACK with empty lists) → E (END).
133 Zero objects, zero commits. The server accepts this as a valid push
134 that advances no branch and stores nothing.
135 """
136 h = _encode_mwp_frame("H", {
137 "t": "H",
138 "branch": branch,
139 "force": False,
140 "head": None,
141 "have": [],
142 "n_objects": 0,
143 "n_commits": 0,
144 })
145 c = _encode_mwp_frame("C", {"t": "C", "commits": [], "snapshots": []})
146 e = _encode_mwp_frame("E", {"t": "E"})
147 return h + c + e
148
149
150 # ── General helpers ────────────────────────────────────────────────────────────
151
152
153 def _uid() -> str:
154 return secrets.token_hex(16)
155
156
157 def _keypair() -> tuple[Ed25519PrivateKey, bytes]:
158 priv = Ed25519PrivateKey.generate()
159 pub = priv.public_key().public_bytes_raw()
160 return priv, pub
161
162
163 def _msign_header(
164 priv: Ed25519PrivateKey,
165 handle: str,
166 method: str,
167 path: str,
168 body: bytes,
169 ts: int | None = None,
170 host: str = "test",
171 ) -> str:
172 """Build a valid ``Authorization: MSign …`` header for test requests.
173
174 ``body`` is the bytes that the client would hash into the canonical
175 message. For ``application/x-muse-wire`` pushes, always pass ``b""``
176 because the server uses an empty body hash for streaming wire requests
177 (body integrity is provided by per-frame content addressing).
178 """
179 ts = ts if ts is not None else int(time.time())
180 canonical = build_canonical_message(method, path, ts, body, host=host)
181 sig_bytes = priv.sign(canonical)
182 sig_b64 = b64url_encode(sig_bytes)
183 return f'MSign handle="{handle}" alg="ed25519" ts={ts} sig="{sig_b64}"'
184
185
186 async def _seed(
187 session: AsyncSession,
188 handle: str,
189 priv: Ed25519PrivateKey,
190 pub: bytes,
191 identity_type: str = "human",
192 deleted: bool = False,
193 ) -> db.MusehubIdentity:
194 """Insert a MusehubIdentity and one MusehubAuthKey row for test use.
195
196 ``public_key_b64`` is stored with the canonical ``"ed25519:"`` prefix —
197 this matches the invariant enforced throughout the Muse ecosystem.
198 ``b64url_decode`` on the read path handles both prefixed and bare values.
199 """
200 from datetime import datetime, timezone
201
202 identity = db.MusehubIdentity(
203 identity_id=_uid(),
204 handle=handle,
205 identity_type=identity_type,
206 display_name=handle,
207 )
208 if deleted:
209 identity.deleted_at = datetime(2020, 1, 1, tzinfo=timezone.utc)
210 session.add(identity)
211 await session.flush()
212
213 key_row = MusehubAuthKey(
214 key_id=_uid(),
215 identity_id=identity.identity_id,
216 algorithm="ed25519",
217 public_key_b64=encode_pubkey("ed25519", pub), # canonical prefix always
218 fingerprint=key_fingerprint(pub),
219 label="test-key",
220 )
221 session.add(key_row)
222 await session.commit()
223 await session.refresh(identity)
224 return identity
225
226
227 async def _push(
228 client: AsyncClient,
229 priv: Ed25519PrivateKey,
230 handle: str,
231 owner: str,
232 slug: str,
233 branch: str = "main",
234 ) -> int:
235 """Execute a minimal MWP push and return the HTTP status code.
236
237 Signs the request correctly for ``application/x-muse-wire``: the body
238 hash in the canonical message is SHA-256("") regardless of the actual
239 wire bytes sent — per MSign spec for streaming wire content types.
240 """
241 path = f"/{owner}/{slug}/push/stream"
242 body = _minimal_push_body(branch)
243 auth = _msign_header(priv, handle, "POST", path, b"") # empty body for wire signing
244 resp = await client.post(
245 path,
246 content=body,
247 headers={"Content-Type": "application/x-muse-wire", "Authorization": auth},
248 )
249 return resp.status_code
250
251
252 # ══════════════════════════════════════════════════════════════════════════════
253 # 1. Unit
254 # ══════════════════════════════════════════════════════════════════════════════
255
256
257 class TestMSignContextUnit:
258 """MSignContext dataclass fields and alias exports from auth.dependencies."""
259
260 def test_msign_context_is_dataclass(self) -> None:
261 import dataclasses
262 assert dataclasses.is_dataclass(MSignContext)
263
264 def test_token_claims_is_msign_context(self) -> None:
265 """TokenClaims is the canonical re-export of MSignContext."""
266 assert TokenClaims is MSignContext
267
268 def test_require_valid_token_is_require_signed_request(self) -> None:
269 from musehub.auth.request_signing import require_signed_request
270 assert require_valid_token is require_signed_request
271
272 def test_optional_token_is_optional_signed_request(self) -> None:
273 from musehub.auth.request_signing import optional_signed_request
274 assert optional_token is optional_signed_request
275
276 def test_context_scope_defaults_to_none_for_humans(self) -> None:
277 ctx = MSignContext(handle="gabriel", identity_id="abc", is_agent=False, is_admin=False)
278 assert ctx.scope is None
279
280 def test_context_scope_can_be_set_for_agents(self) -> None:
281 ctx = MSignContext(
282 handle="bot",
283 identity_id="abc",
284 is_agent=True,
285 is_admin=False,
286 scope=["issue:write", "proposal:write"],
287 )
288 assert ctx.scope == ["issue:write", "proposal:write"]
289 assert "issue:write" in ctx.scope
290
291 def test_context_human_is_not_agent(self) -> None:
292 ctx = MSignContext(handle="human", identity_id="x", is_agent=False, is_admin=False)
293 assert not ctx.is_agent
294
295 def test_context_agent_flag(self) -> None:
296 ctx = MSignContext(handle="bot", identity_id="x", is_agent=True, is_admin=False)
297 assert ctx.is_agent
298
299 def test_context_is_admin_false_by_default(self) -> None:
300 ctx = MSignContext(handle="x", identity_id="y", is_agent=False, is_admin=False)
301 assert not ctx.is_admin
302
303
304 class TestBuildCanonicalMessageUnit:
305 """Edge cases for build_canonical_message."""
306
307 def test_query_string_included_in_canonical(self) -> None:
308 """Signing with vs. without query string produces different bytes."""
309 msg_with_q = build_canonical_message("GET", "/x/y?ref=main", 1, b"")
310 msg_no_q = build_canonical_message("GET", "/x/y", 1, b"")
311 assert msg_with_q != msg_no_q
312
313 def test_query_string_appears_verbatim_in_fourth_line(self) -> None:
314 """The path (line 4 of 6) is stored verbatim including the query string."""
315 path = "/owner/repo/refs?format=json"
316 msg = build_canonical_message("GET", path, 1, b"").decode()
317 assert msg.split("\n")[3] == path
318
319 def test_large_body_sha256_is_hex(self) -> None:
320 """Body hash line must be the canonical ``sha256:<64-hex>`` form (71 chars)."""
321 body = b"x" * 100_000
322 msg = build_canonical_message("POST", "/", 0, body).decode()
323 body_hash = msg.split("\n")[5]
324 assert body_hash.startswith("sha256:")
325 assert len(body_hash) == 71
326 assert all(c in "0123456789abcdef" for c in body_hash[7:])
327
328 def test_output_is_utf8_bytes(self) -> None:
329 msg = build_canonical_message("POST", "/path", 1234567890, b"data")
330 assert msg.decode("utf-8").encode("utf-8") == msg
331
332 def test_empty_body_hash_is_blob_id_of_empty(self) -> None:
333 """Empty body produces ``blob_id(b"")`` — the canonical ``sha256:`` prefixed form."""
334 msg = build_canonical_message("POST", "/push/stream", 1, b"").decode()
335 body_hash = msg.split("\n")[5]
336 assert body_hash == blob_id(b"")
337
338 def test_algorithm_is_first_line(self) -> None:
339 """Algorithm identifier must be the first line for downgrade-attack protection."""
340 msg = build_canonical_message("POST", "/path", 1, b"").decode()
341 assert msg.split("\n")[0] == "ed25519"
342
343
344 class TestParseMsignHeaderUnit:
345 """Edge cases for _parse_msign_header."""
346
347 def test_leading_whitespace_stripped(self) -> None:
348 sig = b64url_encode(b"x" * 64)
349 hdr = f' MSign handle="gabriel" alg="ed25519" ts=1 sig="{sig}"'
350 result = _parse_msign_header(hdr)
351 assert result is not None
352 assert result[0] == "gabriel"
353
354 def test_ts_zero_parses_as_int(self) -> None:
355 sig = b64url_encode(b"y" * 64)
356 hdr = f'MSign handle="x" alg="ed25519" ts=0 sig="{sig}"'
357 result = _parse_msign_header(hdr)
358 assert result is not None
359 assert result[2] == 0
360
361 def test_large_ts_parses(self) -> None:
362 sig = b64url_encode(b"z" * 64)
363 hdr = f'MSign handle="x" alg="ed25519" ts=9999999999 sig="{sig}"'
364 result = _parse_msign_header(hdr)
365 assert result is not None
366 assert result[2] == 9999999999
367
368 def test_basic_scheme_rejected(self) -> None:
369 assert _parse_msign_header("Basic dXNlcjpwYXNz") is None
370
371 def test_empty_handle_rejected(self) -> None:
372 """handle="" cannot parse — regex requires [^"]+ (at least one char)."""
373 sig = b64url_encode(b"x" * 64)
374 hdr = f'MSign handle="" alg="ed25519" ts=1 sig="{sig}"'
375 assert _parse_msign_header(hdr) is None
376
377 def test_returns_four_tuple(self) -> None:
378 """Successful parse returns (handle, alg, ts, sig_b64)."""
379 sig = b64url_encode(b"x" * 64)
380 hdr = f'MSign handle="gabriel" alg="ed25519" ts=1700000000 sig="{sig}"'
381 result = _parse_msign_header(hdr)
382 assert result is not None
383 handle, alg, ts, sig_out = result
384 assert handle == "gabriel"
385 assert alg == "ed25519"
386 assert ts == 1700000000
387 assert sig_out == sig
388
389
390 class TestMWPFrameEncoderUnit:
391 """Unit tests for the _encode_mwp_frame / _minimal_push_body helpers.
392
393 Verifies the helpers produce spec-compliant binary frames before any
394 integration or E2E tests depend on them.
395 """
396
397 def test_magic_bytes_correct(self) -> None:
398 frame = _encode_mwp_frame("H", {"t": "H", "branch": "main", "force": False,
399 "head": None, "have": [], "n_objects": 0, "n_commits": 0})
400 assert frame[:4] == b"muse"
401
402 def test_version_byte_is_one(self) -> None:
403 frame = _encode_mwp_frame("E", {"t": "E"})
404 assert frame[4:5] == b"\x01"
405
406 def test_envelope_is_valid_msgpack(self) -> None:
407 frame = _encode_mwp_frame("E", {"t": "E"})
408 header_len = struct.unpack(">I", frame[5:9])[0]
409 envelope = msgpack.unpackb(frame[9:9 + header_len], raw=False)
410 assert envelope["ft"] == "E"
411 assert "sz" in envelope
412 assert "id" in envelope
413
414 def test_payload_hash_matches_envelope_id(self) -> None:
415 """Server verifies blob_id(payload) == envelope['id'] — must be correct."""
416 frame = _encode_mwp_frame("C", {"t": "C", "commits": [], "snapshots": []})
417 header_len = struct.unpack(">I", frame[5:9])[0]
418 envelope = msgpack.unpackb(frame[9:9 + header_len], raw=False)
419 payload_start = 9 + header_len + 8 # +8 for uint64 payload_len prefix
420 payload = frame[payload_start:]
421 assert blob_id(payload) == envelope["id"]
422
423 def test_minimal_push_body_starts_with_header_frame(self) -> None:
424 body = _minimal_push_body()
425 header_len = struct.unpack(">I", body[5:9])[0]
426 first_envelope = msgpack.unpackb(body[9:9 + header_len], raw=False)
427 assert first_envelope["ft"] == "H"
428
429 def test_minimal_push_body_ends_with_end_frame(self) -> None:
430 body = _minimal_push_body()
431 # Walk frames to find the last one
432 pos = 0
433 last_ft = None
434 while pos < len(body):
435 assert body[pos:pos + 4] == b"muse"
436 header_len = struct.unpack(">I", body[pos + 5:pos + 9])[0]
437 envelope = msgpack.unpackb(body[pos + 9:pos + 9 + header_len], raw=False)
438 payload_len = struct.unpack(">Q", body[pos + 9 + header_len:pos + 9 + header_len + 8])[0]
439 last_ft = envelope["ft"]
440 pos = pos + 9 + header_len + 8 + payload_len
441 assert last_ft == "E"
442
443
444 # ══════════════════════════════════════════════════════════════════════════════
445 # 2. Integration
446 # ══════════════════════════════════════════════════════════════════════════════
447
448
449 class TestMSignIntegration:
450 """Service-layer tests: real DB, full dependency chain, no mocking.
451
452 All push requests use ``application/x-muse-wire`` + MWP binary body
453 and sign against an empty body hash (matching the MSign spec for wire
454 content types). GET requests to ``/refs`` sign against the actual
455 (empty) request body.
456 """
457
458 async def test_valid_push_returns_200(
459 self, client: AsyncClient, db_session: AsyncSession
460 ) -> None:
461 """A correctly signed push from a registered identity succeeds."""
462 priv, pub = _keypair()
463 identity = await _seed(db_session, "int-valid-user", priv, pub)
464 repo = await factory_create_repo(db_session, slug="int-valid-repo", owner=identity.handle)
465 status_code = await _push(client, priv, identity.handle, repo.owner, repo.slug)
466 assert status_code == 200
467
468 async def test_unknown_identity_returns_401(
469 self, client: AsyncClient, db_session: AsyncSession
470 ) -> None:
471 """Signing with a handle not in the DB returns 401."""
472 priv, _ = _keypair()
473 repo = await factory_create_repo(db_session, slug="int-no-identity", owner="ghost")
474 status_code = await _push(client, priv, "ghost", repo.owner, repo.slug)
475 assert status_code == 401
476
477 async def test_no_keys_registered_returns_401(
478 self, client: AsyncClient, db_session: AsyncSession
479 ) -> None:
480 """An identity with no auth keys registered is rejected."""
481 identity = db.MusehubIdentity(
482 identity_id=_uid(), handle="keyless-int-user",
483 identity_type="human", display_name="Keyless",
484 )
485 db_session.add(identity)
486 await db_session.commit()
487 repo = await factory_create_repo(
488 db_session, slug="int-keyless-repo", owner=identity.handle
489 )
490 priv, _ = _keypair()
491 status_code = await _push(client, priv, identity.handle, repo.owner, repo.slug)
492 assert status_code == 401
493
494 async def test_soft_deleted_identity_returns_401(
495 self, client: AsyncClient, db_session: AsyncSession
496 ) -> None:
497 """``deleted_at IS NOT NULL`` identities are rejected regardless of key validity."""
498 priv, pub = _keypair()
499 identity = await _seed(db_session, "deleted-int-user", priv, pub, deleted=True)
500 repo = await factory_create_repo(
501 db_session, slug="int-deleted-identity", owner="deleted-int-user"
502 )
503 status_code = await _push(client, priv, identity.handle, repo.owner, repo.slug)
504 assert status_code == 401
505
506 async def test_optional_absent_header_allows_public_repo(
507 self, client: AsyncClient, db_session: AsyncSession
508 ) -> None:
509 """``optional_token`` routes accept anonymous requests for public repos."""
510 repo = await factory_create_repo(
511 db_session, slug="int-optional-public", visibility="public"
512 )
513 resp = await client.get(f"/{repo.owner}/{repo.slug}/refs")
514 assert resp.status_code == 200
515
516 async def test_optional_invalid_header_returns_401(
517 self, client: AsyncClient, db_session: AsyncSession
518 ) -> None:
519 """A malformed MSign header on an optional route still returns 401."""
520 repo = await factory_create_repo(
521 db_session, slug="int-optional-bad", visibility="public"
522 )
523 resp = await client.get(
524 f"/{repo.owner}/{repo.slug}/refs",
525 headers={"Authorization": 'MSign handle="x" alg="ed25519" ts=1 sig="bad"'},
526 )
527 assert resp.status_code == 401
528
529
530 # ══════════════════════════════════════════════════════════════════════════════
531 # 3. End-to-End
532 # ══════════════════════════════════════════════════════════════════════════════
533
534
535 class TestMSignE2E:
536 """Full HTTP stack scenarios exercising the complete request path."""
537
538 async def test_query_string_signed_correctly(
539 self, client: AsyncClient, db_session: AsyncSession
540 ) -> None:
541 """Signing path + query string succeeds — server derives the same canonical message."""
542 priv, pub = _keypair()
543 identity = await _seed(db_session, "e2e-query-user", priv, pub)
544 repo = await factory_create_repo(
545 db_session, slug="e2e-query-repo", owner=identity.handle, visibility="public"
546 )
547 path = f"/{repo.owner}/{repo.slug}/refs"
548 auth = _msign_header(priv, identity.handle, "GET", path, b"")
549 resp = await client.get(path, headers={"Authorization": auth})
550 assert resp.status_code == 200
551
552 async def test_agent_identity_push_succeeds(
553 self, client: AsyncClient, db_session: AsyncSession
554 ) -> None:
555 """``identity_type='agent'`` is not blocked — agents can push."""
556 priv, pub = _keypair()
557 identity = await _seed(db_session, "e2e-agent-user", priv, pub, identity_type="agent")
558 repo = await factory_create_repo(
559 db_session, slug="e2e-agent-repo", owner=identity.handle
560 )
561 status_code = await _push(client, priv, identity.handle, repo.owner, repo.slug)
562 assert status_code == 200
563
564 async def test_401_response_has_detail(
565 self, client: AsyncClient, db_session: AsyncSession
566 ) -> None:
567 """401 responses must include a non-empty ``detail`` field."""
568 repo = await factory_create_repo(db_session, slug="e2e-detail-check", owner="no-such-user")
569 priv, _ = _keypair()
570 status_code = await _push(client, priv, "no-such-user", repo.owner, repo.slug)
571 assert status_code == 401
572
573 async def test_method_mismatch_in_signature_returns_401(
574 self, client: AsyncClient, db_session: AsyncSession
575 ) -> None:
576 """Signing with the wrong HTTP method produces a different canonical message → 401."""
577 priv, pub = _keypair()
578 identity = await _seed(db_session, "e2e-method-user", priv, pub)
579 repo = await factory_create_repo(
580 db_session, slug="e2e-method-repo", owner=identity.handle
581 )
582 path = f"/{repo.owner}/{repo.slug}/push/stream"
583 body = _minimal_push_body()
584 # Sign for GET but send as POST
585 auth = _msign_header(priv, identity.handle, "GET", path, b"")
586 resp = await client.post(
587 path, content=body,
588 headers={"Content-Type": "application/x-muse-wire", "Authorization": auth},
589 )
590 assert resp.status_code == 401
591
592 async def test_path_mismatch_in_signature_returns_401(
593 self, client: AsyncClient, db_session: AsyncSession
594 ) -> None:
595 """Signature computed over a different path is rejected — canonical message mismatch."""
596 priv, pub = _keypair()
597 identity = await _seed(db_session, "e2e-path-user", priv, pub)
598 repo = await factory_create_repo(
599 db_session, slug="e2e-path-repo", owner=identity.handle
600 )
601 body = _minimal_push_body()
602 real_path = f"/{repo.owner}/{repo.slug}/push/stream"
603 wrong_path = f"/{repo.owner}/{repo.slug}/other-endpoint"
604 auth = _msign_header(priv, identity.handle, "POST", wrong_path, b"")
605 resp = await client.post(
606 real_path, content=body,
607 headers={"Content-Type": "application/x-muse-wire", "Authorization": auth},
608 )
609 assert resp.status_code == 401
610
611 async def test_refs_endpoint_requires_no_auth_for_public_repo(
612 self, client: AsyncClient, db_session: AsyncSession
613 ) -> None:
614 """Public repos return 200 on ``/refs`` without any Authorization header."""
615 repo = await factory_create_repo(
616 db_session, slug="e2e-public-refs", owner="public-owner", visibility="public"
617 )
618 resp = await client.get(f"/{repo.owner}/{repo.slug}/refs")
619 assert resp.status_code == 200
620
621 async def test_push_without_auth_returns_401(
622 self, client: AsyncClient, db_session: AsyncSession
623 ) -> None:
624 """Push endpoint always requires auth — no header → 401."""
625 repo = await factory_create_repo(db_session, slug="e2e-no-auth-push", owner="any-owner")
626 body = _minimal_push_body()
627 resp = await client.post(
628 f"/{repo.owner}/{repo.slug}/push/stream",
629 content=body,
630 headers={"Content-Type": "application/x-muse-wire"},
631 )
632 assert resp.status_code == 401
633
634
635 # ══════════════════════════════════════════════════════════════════════════════
636 # 4. Stress
637 # ══════════════════════════════════════════════════════════════════════════════
638
639
640 class TestMSignStress:
641 """Sustained-load and burst scenarios to catch race conditions and resource leaks."""
642
643 async def test_28_sequential_signed_get_requests_succeed(
644 self, client: AsyncClient, db_session: AsyncSession
645 ) -> None:
646 """28 consecutive GET /refs requests (within WIRE_FETCH_LIMIT) all verify correctly."""
647 priv, pub = _keypair()
648 identity = await _seed(db_session, "stress-get-user", priv, pub)
649 repo = await factory_create_repo(
650 db_session, slug="stress-get-repo", owner=identity.handle, visibility="public"
651 )
652 path = f"/{repo.owner}/{repo.slug}/refs"
653 for i in range(28):
654 auth = _msign_header(priv, identity.handle, "GET", path, b"")
655 r = await client.get(path, headers={"Authorization": auth})
656 assert r.status_code == 200, f"Request {i} failed: {r.status_code}"
657
658 async def test_repeated_requests_with_fresh_timestamps_succeed(
659 self, client: AsyncClient, db_session: AsyncSession
660 ) -> None:
661 """Each request refreshes the timestamp — no stale-ts rejections under rapid fire."""
662 priv, pub = _keypair()
663 identity = await _seed(db_session, "stress-ts-user", priv, pub)
664 repo = await factory_create_repo(
665 db_session, slug="stress-ts-repo", owner=identity.handle, visibility="public"
666 )
667 path = f"/{repo.owner}/{repo.slug}/refs"
668 for i in range(10):
669 auth = _msign_header(priv, identity.handle, "GET", path, b"")
670 r = await client.get(path, headers={"Authorization": auth})
671 assert r.status_code != 401, f"Request {i} got unexpected 401"
672
673 async def test_two_identities_alternating_push_requests(
674 self, client: AsyncClient, db_session: AsyncSession
675 ) -> None:
676 """Two distinct identities can make authenticated push requests interleaved."""
677 priv_a, pub_a = _keypair()
678 priv_b, pub_b = _keypair()
679 id_a = await _seed(db_session, "stress-alt-a", priv_a, pub_a)
680 id_b = await _seed(db_session, "stress-alt-b", priv_b, pub_b)
681 repo_a = await factory_create_repo(db_session, slug="stress-alt-repo-a", owner=id_a.handle)
682 repo_b = await factory_create_repo(db_session, slug="stress-alt-repo-b", owner=id_b.handle)
683 for i in range(5):
684 for priv, identity, repo in [
685 (priv_a, id_a, repo_a),
686 (priv_b, id_b, repo_b),
687 ]:
688 sc = await _push(client, priv, identity.handle, repo.owner, repo.slug)
689 assert sc == 200, f"iter {i}: {identity.handle} got {sc}"
690
691 def test_parse_header_1000_times_completes_under_50ms(self) -> None:
692 """Parsing 1000 MSign headers completes in under 50ms total (no DB, pure CPU)."""
693 sig = b64url_encode(b"x" * 64)
694 hdr = f'MSign handle="gabriel" alg="ed25519" ts=1700000000 sig="{sig}"'
695 start = time.perf_counter()
696 for _ in range(1000):
697 result = _parse_msign_header(hdr)
698 assert result is not None
699 elapsed_ms = (time.perf_counter() - start) * 1000
700 assert elapsed_ms < 50, f"1000 parses took {elapsed_ms:.1f}ms (budget: 50ms)"
701
702 async def test_five_pushes_same_identity_all_succeed(
703 self, client: AsyncClient, db_session: AsyncSession
704 ) -> None:
705 """Five sequential pushes from the same identity all return 200."""
706 priv, pub = _keypair()
707 identity = await _seed(db_session, "stress-5push-user", priv, pub)
708 repo = await factory_create_repo(
709 db_session, slug="stress-5push-repo", owner=identity.handle
710 )
711 for i in range(5):
712 sc = await _push(client, priv, identity.handle, repo.owner, repo.slug)
713 assert sc == 200, f"Push {i} returned {sc}"
714
715
716 # ══════════════════════════════════════════════════════════════════════════════
717 # 5. Data Integrity
718 # ══════════════════════════════════════════════════════════════════════════════
719
720
721 class TestMSignDataIntegrity:
722 """MSignContext field accuracy, canonical key storage, and multi-key handling."""
723
724 async def test_is_agent_true_for_agent_identity_type(
725 self, client: AsyncClient, db_session: AsyncSession
726 ) -> None:
727 """``identity_type='agent'`` must yield a valid MSignContext (proved by 200)."""
728 priv, pub = _keypair()
729 identity = await _seed(db_session, "di-agent-identity", priv, pub, identity_type="agent")
730 repo = await factory_create_repo(db_session, slug="di-agent-repo", owner=identity.handle)
731 sc = await _push(client, priv, identity.handle, repo.owner, repo.slug)
732 assert sc == 200
733
734 async def test_is_agent_false_for_human_identity_type(
735 self, client: AsyncClient, db_session: AsyncSession
736 ) -> None:
737 """``identity_type='human'`` must also produce a valid context and succeed."""
738 priv, pub = _keypair()
739 identity = await _seed(db_session, "di-human-identity", priv, pub, identity_type="human")
740 repo = await factory_create_repo(db_session, slug="di-human-repo", owner=identity.handle)
741 sc = await _push(client, priv, identity.handle, repo.owner, repo.slug)
742 assert sc == 200
743
744 async def test_second_of_two_keys_verified_when_first_fails(
745 self, client: AsyncClient, db_session: AsyncSession
746 ) -> None:
747 """_verify_msign iterates all keys for an identity — second key must be tried."""
748 priv_a, pub_a = _keypair()
749 priv_b, pub_b = _keypair()
750 identity = await _seed(db_session, "di-multi-key", priv_a, pub_a)
751 # Add second key with canonical prefix
752 db_session.add(MusehubAuthKey(
753 key_id=_uid(), identity_id=identity.identity_id,
754 algorithm="ed25519",
755 public_key_b64=encode_pubkey("ed25519", pub_b),
756 fingerprint=key_fingerprint(pub_b),
757 label="key-b",
758 ))
759 await db_session.commit()
760 repo = await factory_create_repo(db_session, slug="di-multi-key-repo", owner=identity.handle)
761 # Sign with key B (second key — first key will fail, second must succeed)
762 sc = await _push(client, priv_b, identity.handle, repo.owner, repo.slug)
763 assert sc == 200, "Second registered key must be tried and accepted"
764
765 async def test_three_decoy_keys_then_correct_key(
766 self, client: AsyncClient, db_session: AsyncSession
767 ) -> None:
768 """Correct key at index 3 is still found after three non-matching decoys."""
769 priv_correct, pub_correct = _keypair()
770 identity = await _seed(db_session, "di-decoy-keys", priv_correct, pub_correct)
771 # Add 3 decoy keys (not the one we'll sign with)
772 for i in range(3):
773 _, pub_decoy = _keypair()
774 db_session.add(MusehubAuthKey(
775 key_id=_uid(), identity_id=identity.identity_id,
776 algorithm="ed25519",
777 public_key_b64=encode_pubkey("ed25519", pub_decoy),
778 fingerprint=key_fingerprint(pub_decoy),
779 label=f"decoy-{i}",
780 ))
781 await db_session.commit()
782 repo = await factory_create_repo(db_session, slug="di-decoy-repo", owner=identity.handle)
783 sc = await _push(client, priv_correct, identity.handle, repo.owner, repo.slug)
784 assert sc == 200
785
786 async def test_context_handle_matches_identity_handle(
787 self, client: AsyncClient, db_session: AsyncSession
788 ) -> None:
789 """MSignContext.handle comes from the DB identity row, not just the header.
790
791 A successful push proves ``context.handle == repo.owner == identity.handle``.
792 """
793 priv, pub = _keypair()
794 handle = "di-handle-check"
795 identity = await _seed(db_session, handle, priv, pub)
796 repo = await factory_create_repo(db_session, slug="di-handle-repo", owner=handle)
797 sc = await _push(client, priv, handle, repo.owner, repo.slug)
798 assert sc == 200
799
800 async def test_canonical_prefix_stored_and_verified(
801 self, client: AsyncClient, db_session: AsyncSession
802 ) -> None:
803 """Key stored with ``ed25519:`` prefix verifies correctly via b64url_decode."""
804 priv, pub = _keypair()
805 identity = db.MusehubIdentity(
806 identity_id=_uid(), handle="di-prefix-user",
807 identity_type="human", display_name="Prefix Test",
808 )
809 db_session.add(identity)
810 await db_session.flush()
811 # Store with canonical prefix explicitly
812 key_row = MusehubAuthKey(
813 key_id=_uid(), identity_id=identity.identity_id,
814 algorithm="ed25519",
815 public_key_b64=encode_pubkey("ed25519", pub),
816 fingerprint=key_fingerprint(pub),
817 label="prefixed-key",
818 )
819 db_session.add(key_row)
820 await db_session.commit()
821 repo = await factory_create_repo(db_session, slug="di-prefix-repo", owner=identity.handle)
822 sc = await _push(client, priv, identity.handle, repo.owner, repo.slug)
823 assert sc == 200, "Prefixed public_key_b64 must be decoded and verified correctly"
824
825 async def test_revocation_takes_immediate_effect(
826 self, client: AsyncClient, db_session: AsyncSession
827 ) -> None:
828 """Deleting the key row immediately prevents future authentications."""
829 from sqlalchemy import select as sa_select
830
831 priv, pub = _keypair()
832 identity = await _seed(db_session, "di-revoke-user", priv, pub)
833 repo = await factory_create_repo(db_session, slug="di-revoke-repo", owner=identity.handle)
834
835 # Before revocation: success
836 assert await _push(client, priv, identity.handle, repo.owner, repo.slug) == 200
837
838 # Revoke via ORM delete — bulk DELETE bypasses the identity map and
839 # can leave the key cached; ORM delete ensures the session invalidates it.
840 key_to_delete = (
841 await db_session.execute(
842 sa_select(MusehubAuthKey).where(
843 MusehubAuthKey.fingerprint == key_fingerprint(pub)
844 )
845 )
846 ).scalar_one_or_none()
847 assert key_to_delete is not None
848 await db_session.delete(key_to_delete)
849 await db_session.commit()
850
851 # After revocation: 401
852 assert await _push(client, priv, identity.handle, repo.owner, repo.slug) == 401
853
854
855 # ══════════════════════════════════════════════════════════════════════════════
856 # 6. Security
857 # ══════════════════════════════════════════════════════════════════════════════
858
859
860 class TestMSignSecurity:
861 """Auth bypass attempts, header abuse, and information leakage checks."""
862
863 async def test_missing_header_response_has_www_authenticate(
864 self, client: AsyncClient, db_session: AsyncSession
865 ) -> None:
866 """401 with missing Authorization header must include ``WWW-Authenticate: MSign``."""
867 repo = await factory_create_repo(db_session, slug="sec-www-auth", owner="sec-user")
868 body = _minimal_push_body()
869 resp = await client.post(
870 f"/{repo.owner}/{repo.slug}/push/stream",
871 content=body,
872 headers={"Content-Type": "application/x-muse-wire"},
873 )
874 assert resp.status_code == 401
875 www_auth = resp.headers.get("www-authenticate", "")
876 assert "MSign" in www_auth
877
878 async def test_wrong_scheme_returns_401_with_www_authenticate(
879 self, client: AsyncClient, db_session: AsyncSession
880 ) -> None:
881 """Bearer tokens and other schemes must be rejected with WWW-Authenticate: MSign."""
882 repo = await factory_create_repo(db_session, slug="sec-scheme-auth", owner="sec-scheme")
883 body = _minimal_push_body()
884 resp = await client.post(
885 f"/{repo.owner}/{repo.slug}/push/stream",
886 content=body,
887 headers={
888 "Content-Type": "application/x-muse-wire",
889 "Authorization": "Bearer fake-token",
890 },
891 )
892 assert resp.status_code == 401
893 assert "MSign" in resp.headers.get("www-authenticate", "")
894
895 async def test_invalid_base64_sig_returns_401(
896 self, client: AsyncClient, db_session: AsyncSession
897 ) -> None:
898 """A signature field with non-base64url characters must fail cleanly with 401."""
899 priv, pub = _keypair()
900 identity = await _seed(db_session, "sec-bad-b64", priv, pub)
901 repo = await factory_create_repo(db_session, slug="sec-bad-b64-repo", owner=identity.handle)
902 ts = int(time.time())
903 bad_hdr = f'MSign handle="{identity.handle}" alg="ed25519" ts={ts} sig="!!!invalid!!!"'
904 body = _minimal_push_body()
905 resp = await client.post(
906 f"/{repo.owner}/{repo.slug}/push/stream",
907 content=body,
908 headers={"Content-Type": "application/x-muse-wire", "Authorization": bad_hdr},
909 )
910 assert resp.status_code == 401
911
912 async def test_timestamp_exactly_at_boundary_does_not_crash(
913 self, client: AsyncClient, db_session: AsyncSession
914 ) -> None:
915 """Timestamp exactly REPLAY_WINDOW_SECONDS ago is at the boundary — must not 500."""
916 priv, pub = _keypair()
917 identity = await _seed(db_session, "sec-boundary-ts", priv, pub)
918 repo = await factory_create_repo(
919 db_session, slug="sec-boundary-repo", owner=identity.handle, visibility="public"
920 )
921 path = f"/{repo.owner}/{repo.slug}/refs"
922 boundary_ts = int(time.time()) - REPLAY_WINDOW_SECONDS
923 auth = _msign_header(priv, identity.handle, "GET", path, b"", ts=boundary_ts)
924 resp = await client.get(path, headers={"Authorization": auth})
925 assert resp.status_code in (200, 401), f"Expected 200 or 401, got {resp.status_code}"
926
927 async def test_stale_timestamp_returns_401(
928 self, client: AsyncClient, db_session: AsyncSession
929 ) -> None:
930 """Timestamp more than REPLAY_WINDOW_SECONDS old must be rejected."""
931 priv, pub = _keypair()
932 identity = await _seed(db_session, "sec-over-boundary", priv, pub)
933 repo = await factory_create_repo(
934 db_session, slug="sec-over-boundary-repo", owner=identity.handle
935 )
936 stale_ts = int(time.time()) - REPLAY_WINDOW_SECONDS - 1
937 body = _minimal_push_body()
938 auth = _msign_header(priv, identity.handle, "POST",
939 f"/{repo.owner}/{repo.slug}/push/stream", b"", ts=stale_ts)
940 resp = await client.post(
941 f"/{repo.owner}/{repo.slug}/push/stream",
942 content=body,
943 headers={"Content-Type": "application/x-muse-wire", "Authorization": auth},
944 )
945 assert resp.status_code == 401
946
947 async def test_wrong_key_returns_401_without_leaking_key_material(
948 self, client: AsyncClient, db_session: AsyncSession
949 ) -> None:
950 """Signing with the wrong key returns 401 — error detail must not contain key bytes."""
951 priv, pub = _keypair()
952 identity = await _seed(db_session, "sec-no-leak", priv, pub)
953 repo = await factory_create_repo(db_session, slug="sec-no-leak-repo", owner=identity.handle)
954 other_priv, _ = _keypair() # Wrong key
955 sc = await _push(client, other_priv, identity.handle, repo.owner, repo.slug)
956 assert sc == 401
957 # Re-send to capture JSON detail
958 path = f"/{repo.owner}/{repo.slug}/push/stream"
959 body = _minimal_push_body()
960 auth = _msign_header(other_priv, identity.handle, "POST", path, b"")
961 resp = await client.post(
962 path, content=body,
963 headers={"Content-Type": "application/x-muse-wire", "Authorization": auth},
964 )
965 detail = resp.json().get("detail", "")
966 pub_b64_bare = b64url_encode(pub)
967 assert pub_b64_bare not in detail, "Bare public key must not appear in error response"
968 assert encode_pubkey("ed25519", pub) not in detail, "Prefixed public key must not appear in error response"
969 assert "BEGIN" not in detail # no PEM blocks
970
971 async def test_handle_quote_injection_rejected_or_truncated(
972 self, client: AsyncClient, db_session: AsyncSession
973 ) -> None:
974 """A handle containing a double-quote cannot parse — regex stops at first ``"``."""
975 priv, _ = _keypair()
976 ts = int(time.time())
977 sig = b64url_encode(priv.sign(b"x"))
978 bad_hdr = f'MSign handle="injected\\"quote" alg="ed25519" ts={ts} sig="{sig}"'
979 result = _parse_msign_header(bad_hdr)
980 if result is not None:
981 handle, _, _, _ = result
982 assert '"' not in handle, "Parsed handle must not contain a quote character"
983
984 async def test_future_timestamp_returns_401(
985 self, client: AsyncClient, db_session: AsyncSession
986 ) -> None:
987 """Timestamps far in the future exceed the replay window and must be rejected."""
988 priv, pub = _keypair()
989 identity = await _seed(db_session, "sec-future-ts", priv, pub)
990 repo = await factory_create_repo(
991 db_session, slug="sec-future-ts-repo", owner=identity.handle
992 )
993 future_ts = int(time.time()) + REPLAY_WINDOW_SECONDS + 60
994 body = _minimal_push_body()
995 path = f"/{repo.owner}/{repo.slug}/push/stream"
996 auth = _msign_header(priv, identity.handle, "POST", path, b"", ts=future_ts)
997 resp = await client.post(
998 path, content=body,
999 headers={"Content-Type": "application/x-muse-wire", "Authorization": auth},
1000 )
1001 assert resp.status_code == 401
1002
1003
1004 # ══════════════════════════════════════════════════════════════════════════════
1005 # 7. Performance
1006 # ══════════════════════════════════════════════════════════════════════════════
1007
1008
1009 class TestMSignPerformance:
1010 """Latency budgets for signing primitives and the full verification path."""
1011
1012 def test_build_canonical_message_with_query_under_1ms(self) -> None:
1013 """``build_canonical_message`` with a query string must complete in under 1ms (median)."""
1014 body = b"some-request-body"
1015 samples = 500
1016 times = []
1017 for _ in range(samples):
1018 t0 = time.perf_counter_ns()
1019 build_canonical_message("POST", "/owner/repo/push?ref=main&format=json", 1700000000, body)
1020 times.append(time.perf_counter_ns() - t0)
1021 median_us = sorted(times)[samples // 2] / 1000
1022 assert median_us < 1000, f"Canonical with query string: {median_us:.1f}µs (budget: 1ms)"
1023
1024 def test_parse_header_under_50_microseconds_median(self) -> None:
1025 """``_parse_msign_header`` must complete in under 50µs (median over 500 calls)."""
1026 sig = b64url_encode(b"x" * 64)
1027 hdr = f'MSign handle="gabriel" alg="ed25519" ts=1700000042 sig="{sig}"'
1028 samples = 500
1029 times = []
1030 for _ in range(samples):
1031 t0 = time.perf_counter_ns()
1032 _parse_msign_header(hdr)
1033 times.append(time.perf_counter_ns() - t0)
1034 median_us = sorted(times)[samples // 2] / 1000
1035 assert median_us < 50, f"parse_msign_header median: {median_us:.1f}µs (budget: 50µs)"
1036
1037 def test_ed25519_sign_under_1ms_median(self) -> None:
1038 """Ed25519 signing must complete in under 1ms median on modern hardware."""
1039 priv, _ = _keypair()
1040 message = build_canonical_message("POST", "/path", 1700000000, b"body")
1041 samples = 100
1042 times = []
1043 for _ in range(samples):
1044 t0 = time.perf_counter_ns()
1045 priv.sign(message)
1046 times.append(time.perf_counter_ns() - t0)
1047 median_us = sorted(times)[samples // 2] / 1000
1048 assert median_us < 1000, f"Ed25519 sign median: {median_us:.1f}µs (budget: 1ms)"
1049
1050 async def test_10_full_stack_verifications_under_2_seconds(
1051 self, client: AsyncClient, db_session: AsyncSession
1052 ) -> None:
1053 """10 full-stack GET /refs verifications (including DB query) complete in under 2s."""
1054 priv, pub = _keypair()
1055 identity = await _seed(db_session, "perf-10req-user", priv, pub)
1056 repo = await factory_create_repo(
1057 db_session, slug="perf-10req-repo", owner=identity.handle, visibility="public"
1058 )
1059 path = f"/{repo.owner}/{repo.slug}/refs"
1060 start = time.perf_counter()
1061 for _ in range(10):
1062 auth = _msign_header(priv, identity.handle, "GET", path, b"")
1063 r = await client.get(path, headers={"Authorization": auth})
1064 assert r.status_code == 200
1065 elapsed = time.perf_counter() - start
1066 assert elapsed < 2.0, f"10 GET verifications took {elapsed:.2f}s (budget: 2s)"
1067
1068 async def test_multi_key_overhead_proportional(
1069 self, client: AsyncClient, db_session: AsyncSession
1070 ) -> None:
1071 """Identity with 3 keys must not be more than 3× slower than identity with 1 key."""
1072 priv_1, pub_1 = _keypair()
1073 id_1 = await _seed(db_session, "perf-1k-user", priv_1, pub_1)
1074 repo_1 = await factory_create_repo(
1075 db_session, slug="perf-1k-repo", owner=id_1.handle, visibility="public"
1076 )
1077
1078 priv_3, pub_3 = _keypair()
1079 id_3 = await _seed(db_session, "perf-3k-user", priv_3, pub_3)
1080 for _ in range(2):
1081 _, pub_d = _keypair()
1082 db_session.add(MusehubAuthKey(
1083 key_id=_uid(), identity_id=id_3.identity_id, algorithm="ed25519",
1084 public_key_b64=encode_pubkey("ed25519", pub_d),
1085 fingerprint=key_fingerprint(pub_d), label="decoy",
1086 ))
1087 await db_session.commit()
1088 repo_3 = await factory_create_repo(
1089 db_session, slug="perf-3k-repo", owner=id_3.handle, visibility="public"
1090 )
1091
1092 path_1 = f"/{repo_1.owner}/{repo_1.slug}/refs"
1093 path_3 = f"/{repo_3.owner}/{repo_3.slug}/refs"
1094
1095 # Warm up
1096 for priv, identity, path in [(priv_1, id_1, path_1), (priv_3, id_3, path_3)]:
1097 auth = _msign_header(priv, identity.handle, "GET", path, b"")
1098 await client.get(path, headers={"Authorization": auth})
1099
1100 n = 5
1101 t0 = time.perf_counter()
1102 for _ in range(n):
1103 auth = _msign_header(priv_1, id_1.handle, "GET", path_1, b"")
1104 await client.get(path_1, headers={"Authorization": auth})
1105 ms_1key = (time.perf_counter() - t0) * 1000 / n
1106
1107 t0 = time.perf_counter()
1108 for _ in range(n):
1109 auth = _msign_header(priv_3, id_3.handle, "GET", path_3, b"")
1110 await client.get(path_3, headers={"Authorization": auth})
1111 ms_3key = (time.perf_counter() - t0) * 1000 / n
1112
1113 ratio = ms_3key / max(ms_1key, 1)
1114 assert ratio < 3, (
1115 f"3-key is {ratio:.1f}× slower than 1-key ({ms_3key:.0f}ms vs {ms_1key:.0f}ms)"
1116 )
File History 1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef debug(push/stream): instrument O-frame decode path with INF… Sonnet 4.6 patch 121 days ago