11from __future__ import annotations
22
33import json
4- from base64 import b64decode , b64encode
4+ import logging
5+ import uuid as uuid_mod
6+ from base64 import b64decode , b64encode , urlsafe_b64encode
57from datetime import UTC , datetime
8+ from enum import Enum , IntEnum , StrEnum
69from typing import TYPE_CHECKING , Any
710
811from nacl .exceptions import BadSignatureError
1215if TYPE_CHECKING :
1316 from airlock .schemas .handshake import SignatureEnvelope
1417
18+ logger = logging .getLogger (__name__ )
19+
20+ _SIGNATURE_FIELDS = frozenset ({"signature" , "airlock_signature" , "trust_token" })
21+
22+
23+ def _prepare_for_json (obj : Any ) -> Any :
24+ """Recursively convert Python objects to JSON-safe, cross-language types.
25+
26+ Ensures deterministic serialization that produces identical output in
27+ Python, Go, Rust, and JavaScript implementations (C-09 interop fix).
28+
29+ Conversion rules:
30+ - datetime -> ISO 8601 with timezone (naive datetimes treated as UTC)
31+ - IntEnum -> int value
32+ - StrEnum -> str value
33+ - Enum -> raw .value
34+ - UUID -> lowercase hyphenated string
35+ - bytes -> base64url encoding (no padding)
36+ - BaseModel -> model.model_dump(mode="json")
37+ - set -> sorted list (recursed)
38+ - dict -> recurse into values
39+ - list / tuple -> recurse into elements
40+ - str, int, float, bool, None -> pass through
41+ - other -> TypeError
42+ """
43+ # Enums must be checked first: IntEnum is a subclass of int,
44+ # StrEnum is a subclass of str, so they'd pass the scalar check below.
45+ # Plain Enum (e.g. Enum with int/str value) is NOT a subclass of int/str.
46+ if isinstance (obj , IntEnum ):
47+ return int (obj )
48+ if isinstance (obj , StrEnum ):
49+ return str (obj .value )
50+ if isinstance (obj , Enum ):
51+ return obj .value
52+
53+ # JSON-native scalars: pass through unchanged
54+ if obj is None or isinstance (obj , (bool , int , float , str )):
55+ return obj
56+
57+ if isinstance (obj , datetime ):
58+ if obj .tzinfo is None or obj .tzinfo .utcoffset (obj ) is None :
59+ # Naive datetime: treat as UTC
60+ obj = obj .replace (tzinfo = UTC )
61+ return obj .isoformat ()
62+
63+ if isinstance (obj , uuid_mod .UUID ):
64+ return str (obj )
65+
66+ if isinstance (obj , bytes ):
67+ return urlsafe_b64encode (obj ).rstrip (b"=" ).decode ("ascii" )
68+
69+ if isinstance (obj , BaseModel ):
70+ return obj .model_dump (mode = "json" )
71+
72+ if isinstance (obj , set ):
73+ return [_prepare_for_json (item ) for item in sorted (obj , key = str )]
74+
75+ if isinstance (obj , dict ):
76+ return {k : _prepare_for_json (v ) for k , v in obj .items ()}
77+
78+ if isinstance (obj , (list , tuple )):
79+ return [_prepare_for_json (item ) for item in obj ]
80+
81+ raise TypeError (f"Cannot canonicalize type: { type (obj )} " )
82+
1583
1684def canonicalize (data : dict [str , Any ]) -> bytes :
1785 """Produce deterministic canonical JSON bytes.
@@ -20,10 +88,20 @@ def canonicalize(data: dict[str, Any]) -> bytes:
2088 - Sort keys
2189 - No whitespace
2290 - UTF-8 encoding
23- Strips 'signature' key if present (we sign the unsigned form).
91+ - ensure_ascii=False for cross-language consistency
92+
93+ All values are first normalized via ``_prepare_for_json`` so that
94+ datetimes, enums, UUIDs, bytes, etc. are converted to language-agnostic
95+ representations *before* JSON encoding. This guarantees identical
96+ canonical bytes across Python, Go, Rust, and JavaScript (C-09 fix).
97+
98+ Strips known signature/token fields so we sign the unsigned form.
2499 """
25- cleaned = {k : v for k , v in data .items () if k != "signature" }
26- return json .dumps (cleaned , sort_keys = True , separators = ("," , ":" ), default = str ).encode ("utf-8" )
100+ cleaned = {k : v for k , v in data .items () if k not in _SIGNATURE_FIELDS }
101+ prepared = _prepare_for_json (cleaned )
102+ return json .dumps (prepared , sort_keys = True , separators = ("," , ":" ), ensure_ascii = False ).encode (
103+ "utf-8"
104+ )
27105
28106
29107def sign_message (message_dict : dict [str , Any ], signing_key : SigningKey ) -> str :
@@ -59,6 +137,13 @@ def sign_model(model: BaseModel, signing_key: SigningKey) -> SignatureEnvelope:
59137 """Sign a Pydantic model and return a SignatureEnvelope.
60138
61139 Canonical form excludes the 'signature' field.
140+
141+ NOTE: ``model.model_dump(mode="json")`` already converts datetimes,
142+ enums, UUIDs etc. to JSON-safe primitives via Pydantic's serializer,
143+ so the dict passed to ``sign_message`` → ``canonicalize`` contains only
144+ str/int/float/bool/None/list/dict. ``_prepare_for_json`` will simply
145+ pass these through. This path is therefore safe for cross-language
146+ signature verification without additional pre-conversion.
62147 """
63148 from airlock .schemas .handshake import SignatureEnvelope
64149
@@ -83,3 +168,40 @@ def verify_model(model: BaseModel, verify_key: VerifyKey) -> bool:
83168 data = model .model_dump (mode = "json" )
84169 data .pop ("signature" , None )
85170 return verify_signature (data , sig .value , verify_key )
171+
172+
173+ def sign_attestation (attestation : BaseModel , signing_key : SigningKey ) -> str :
174+ """Sign an AirlockAttestation and return a base64-encoded Ed25519 signature.
175+
176+ Canonical form excludes ``airlock_signature`` and ``trust_token`` fields
177+ (handled by :func:`canonicalize`). The returned string is suitable for
178+ setting on ``AirlockAttestation.airlock_signature``.
179+ """
180+ data = attestation .model_dump (mode = "json" )
181+ return sign_message (data , signing_key )
182+
183+
184+ def verify_attestation (attestation : BaseModel , public_key : VerifyKey | bytes ) -> bool :
185+ """Verify the ``airlock_signature`` on an :class:`AirlockAttestation`.
186+
187+ Parameters
188+ ----------
189+ attestation:
190+ The attestation model instance. Must have an ``airlock_signature``
191+ field containing a base64-encoded Ed25519 signature string.
192+ public_key:
193+ Either a :class:`~nacl.signing.VerifyKey` or raw 32-byte public key.
194+
195+ Returns
196+ -------
197+ bool
198+ ``True`` if the signature is valid, ``False`` otherwise (including
199+ when ``airlock_signature`` is ``None``).
200+ """
201+ sig_b64 = getattr (attestation , "airlock_signature" , None )
202+ if sig_b64 is None :
203+ return False
204+ if isinstance (public_key , bytes ):
205+ public_key = VerifyKey (public_key )
206+ data = attestation .model_dump (mode = "json" )
207+ return verify_signature (data , sig_b64 , public_key )
0 commit comments