S06-S08: Python spec, Go in-process leaf, tests and Forgejo CI
This commit is contained in:
commit
d67509e470
16 changed files with 726 additions and 0 deletions
125
python/secure_messaging/signed_config.py
Normal file
125
python/secure_messaging/signed_config.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Ed25519 signed configuration wrapper.
|
||||
|
||||
Unsigned files are rejected. After an admin console change, sign again and
|
||||
append prev + new + diff to AdminHistory (Verae DataCube-shaped JSONL).
|
||||
|
||||
Config key ``crypto.mode`` selects npe | lab-xor | plain-lab (see System-Git-Sync).
|
||||
"""
|
||||
|
||||
from __future__ import annotations # postpone evaluation of annotations
|
||||
|
||||
import json # canonical JSON for signatures
|
||||
from dataclasses import dataclass # SignedConfig container
|
||||
from pathlib import Path # filesystem paths
|
||||
from typing import Any, Dict, Mapping, Optional # types for payload maps
|
||||
|
||||
from cryptography.exceptions import InvalidSignature # bad sig → reject
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import ( # Ed25519
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
from cryptography.hazmat.primitives.serialization import ( # PEM/raw helpers
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
PublicFormat,
|
||||
)
|
||||
|
||||
|
||||
class UnsignedConfig(ValueError):
|
||||
"""Raised when a config file has no wrapper or a bad signature."""
|
||||
|
||||
|
||||
def canonical(payload: Mapping[str, Any]) -> bytes:
|
||||
"""Stable bytes for signing: sorted keys, no extra spaces."""
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") # one canonical form
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignedConfig:
|
||||
"""A payload plus Ed25519 signature metadata."""
|
||||
|
||||
payload: Dict[str, Any] # the configuration object
|
||||
key_id: str # fingerprint of the public key
|
||||
signature: bytes # 64-byte Ed25519 signature
|
||||
|
||||
def wrapper(self) -> Dict[str, Any]:
|
||||
"""On-disk JSON object (hex signature)."""
|
||||
return { # never put the private key here
|
||||
"payload": json.loads(json.dumps(self.payload)),
|
||||
"sig": {
|
||||
"alg": "ed25519",
|
||||
"key_id": self.key_id,
|
||||
"signature": self.signature.hex(),
|
||||
},
|
||||
}
|
||||
|
||||
def dumps(self) -> str:
|
||||
"""Pretty JSON for files (payload still signed as canonical)."""
|
||||
return json.dumps(self.wrapper(), indent=2) + "\n" # human-readable file
|
||||
|
||||
|
||||
def key_id_from_public(pub: Ed25519PublicKey) -> str:
|
||||
"""First 16 hex chars of SHA-256(raw 32-byte public key)."""
|
||||
import hashlib # local import keeps module top smaller
|
||||
|
||||
raw = pub.public_bytes(Encoding.Raw, PublicFormat.Raw) # 32 bytes
|
||||
return hashlib.sha256(raw).hexdigest()[:16] # short id for configs
|
||||
|
||||
|
||||
def generate_signing_key() -> Ed25519PrivateKey:
|
||||
"""Create a new Ed25519 key (lab). Production: load from HSM."""
|
||||
return Ed25519PrivateKey.generate() # OS CSPRNG
|
||||
|
||||
|
||||
def sign(payload: Mapping[str, Any], priv: Ed25519PrivateKey) -> SignedConfig:
|
||||
"""Sign canonical JSON of payload."""
|
||||
pub = priv.public_key() # derive public
|
||||
kid = key_id_from_public(pub) # id printed in wrapper
|
||||
sig = priv.sign(canonical(payload)) # 64 bytes
|
||||
return SignedConfig(payload=dict(payload), key_id=kid, signature=sig) # copy payload
|
||||
|
||||
|
||||
def verify(wrapper: Mapping[str, Any], pub: Ed25519PublicKey) -> Dict[str, Any]:
|
||||
"""Return payload if signature matches; else UnsignedConfig."""
|
||||
sig = wrapper.get("sig") or {} # missing sig → fail
|
||||
if sig.get("alg") != "ed25519": # only this alg this round
|
||||
raise UnsignedConfig("unsupported sig.alg")
|
||||
payload = wrapper.get("payload") # unsigned body
|
||||
if not isinstance(payload, dict): # must be object
|
||||
raise UnsignedConfig("payload must be an object")
|
||||
try: # decode hex
|
||||
signature = bytes.fromhex(str(sig.get("signature") or ""))
|
||||
except ValueError as exc: # not hex
|
||||
raise UnsignedConfig("signature not hex") from exc
|
||||
if key_id_from_public(pub) != str(sig.get("key_id") or ""): # wrong key
|
||||
raise UnsignedConfig("key_id mismatch")
|
||||
try: # cryptography raises InvalidSignature
|
||||
pub.verify(signature, canonical(payload))
|
||||
except InvalidSignature as exc:
|
||||
raise UnsignedConfig("bad signature") from exc
|
||||
return dict(payload) # verified copy
|
||||
|
||||
|
||||
def load_signed(path: Path, pub: Ed25519PublicKey) -> Dict[str, Any]:
|
||||
"""Read a file and reject if unsigned or tampered."""
|
||||
raw = Path(path).read_text() # entire file
|
||||
wrapper = json.loads(raw) # must be JSON
|
||||
if not isinstance(wrapper, dict) or "sig" not in wrapper: # no wrapper
|
||||
raise UnsignedConfig("missing sig wrapper")
|
||||
return verify(wrapper, pub) # verified payload
|
||||
|
||||
|
||||
def save_signed(path: Path, signed: SignedConfig) -> None:
|
||||
"""Write wrapper JSON (does not write the private key)."""
|
||||
Path(path).write_text(signed.dumps()) # replace file atomically enough for lab
|
||||
|
||||
|
||||
def pem_private(priv: Ed25519PrivateKey) -> bytes:
|
||||
"""PKCS8 PEM for gitignored key files."""
|
||||
return priv.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()) # lab only
|
||||
|
||||
|
||||
def pem_public(pub: Ed25519PublicKey) -> bytes:
|
||||
"""SubjectPublicKeyInfo PEM for distribution in signed payload."""
|
||||
return pub.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo) # public
|
||||
Loading…
Add table
Add a link
Reference in a new issue