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
13
python/secure_messaging/__init__.py
Normal file
13
python/secure_messaging/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Public package surface for the readable Python spec.
|
||||
from .signed_config import SignedConfig, UnsignedConfig # signed wrapper load/save
|
||||
from .envelope import PassthroughEnvelope # dest-in-clear, body ciphertext
|
||||
from .error_bundle import NetworkErrorBundle # system-key + sender-only
|
||||
from .admin_history import AdminHistory # DataCube-shaped append-only config log
|
||||
|
||||
__all__ = [
|
||||
"SignedConfig",
|
||||
"UnsignedConfig",
|
||||
"PassthroughEnvelope",
|
||||
"NetworkErrorBundle",
|
||||
"AdminHistory",
|
||||
]
|
||||
89
python/secure_messaging/admin_history.py
Normal file
89
python/secure_messaging/admin_history.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Append-only admin configuration history (Verae DataCube-shaped JSONL).
|
||||
|
||||
Each row stores previous file, new file, unified diff, actor, and dual hashes.
|
||||
Compatible with pfc.chain field names so a real cube can ingest the log.
|
||||
"""
|
||||
|
||||
from __future__ import annotations # annotations
|
||||
|
||||
import difflib # unified diff
|
||||
import hashlib # sha256
|
||||
import json # rows
|
||||
import time # created_ts
|
||||
from pathlib import Path # file
|
||||
from typing import Any, Dict, List, Optional # types
|
||||
|
||||
|
||||
def sha256_hex(data: bytes) -> str:
|
||||
"""Document hash for the history row."""
|
||||
return hashlib.sha256(data).hexdigest() # hex
|
||||
|
||||
|
||||
def unified_diff(prev: str, new: str, name: str = "config") -> str:
|
||||
"""Unified diff of previous and new config text."""
|
||||
return "".join( # join generator
|
||||
difflib.unified_diff(
|
||||
prev.splitlines(True), # old lines keepends
|
||||
new.splitlines(True), # new lines
|
||||
fromfile=name + ".prev", # label
|
||||
tofile=name + ".new", # label
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class AdminHistory:
|
||||
"""JSONL chain under a cube directory: blockchain/chain.jsonl."""
|
||||
|
||||
def __init__(self, cube_dir: Path):
|
||||
self.path = Path(cube_dir) / "blockchain" / "chain.jsonl" # pfc layout
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True) # ensure dir
|
||||
if not self.path.exists(): # empty chain
|
||||
self.path.touch() # create
|
||||
|
||||
def _rows(self) -> List[Dict[str, Any]]:
|
||||
"""Read all rows."""
|
||||
rows = [] # accumulator
|
||||
for line in self.path.read_text().splitlines(): # each line
|
||||
if line.strip(): # skip blanks
|
||||
rows.append(json.loads(line)) # parse
|
||||
return rows # list
|
||||
|
||||
def tip(self) -> Optional[Dict[str, Any]]:
|
||||
"""Last row or None."""
|
||||
rows = self._rows() # load
|
||||
return rows[-1] if rows else None # tip
|
||||
|
||||
def append_change(
|
||||
self,
|
||||
*,
|
||||
actor: str,
|
||||
prev_text: str,
|
||||
new_text: str,
|
||||
name: str = "secure-messaging.json",
|
||||
) -> Dict[str, Any]:
|
||||
"""Append prev, new, diff. Empty actor is rejected."""
|
||||
if not (actor or "").strip(): # required
|
||||
raise ValueError("actor required")
|
||||
prev_h = sha256_hex(prev_text.encode()) # hash old
|
||||
new_h = sha256_hex(new_text.encode()) # hash new
|
||||
payload = { # chain payload
|
||||
"type": "admin-config",
|
||||
"actor": actor,
|
||||
"prev": prev_text,
|
||||
"new": new_text,
|
||||
"diff": unified_diff(prev_text, new_text, name),
|
||||
"prev_sha256": prev_h,
|
||||
"new_sha256": new_h,
|
||||
"created_ts": time.time(),
|
||||
}
|
||||
body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() # canonical
|
||||
prev = self.tip() # previous chain row
|
||||
row = { # pfc-like envelope
|
||||
"seq": (prev["seq"] + 1) if prev else 0,
|
||||
"prev_sha256": prev["sha256"] if prev else "0" * 64,
|
||||
"payload": payload,
|
||||
"sha256": sha256_hex(body),
|
||||
}
|
||||
with self.path.open("a") as f: # append-only
|
||||
f.write(json.dumps(row, sort_keys=True) + "\n") # one line
|
||||
return row # for tests
|
||||
102
python/secure_messaging/envelope.py
Normal file
102
python/secure_messaging/envelope.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Passthrough envelope: destination in the clear, body encrypted.
|
||||
|
||||
After send, the sender cannot open the ciphertext; they keep lookup_id only.
|
||||
Production alg is npe (HPKE). Lab algs are lab-xor and plain-lab.
|
||||
"""
|
||||
|
||||
from __future__ import annotations # annotations as strings
|
||||
|
||||
import hashlib # lookup_id and lab key
|
||||
import json # body encode
|
||||
import os # urandom nonce
|
||||
from dataclasses import dataclass # envelope fields
|
||||
from typing import Any, Dict, Mapping, Optional # maps
|
||||
|
||||
|
||||
def lookup_id(sender: str, nonce: bytes) -> str:
|
||||
"""Opaque id: HMAC-SHA256(sender, nonce) hex. Not reversible at the broker."""
|
||||
return hashlib.sha256(nonce + sender.encode("utf-8")).hexdigest() # bind sender+nonce
|
||||
|
||||
|
||||
def _xor(key: bytes, data: bytes) -> bytes:
|
||||
"""Lab-only repeating XOR (same construction as pfc-lab-xor)."""
|
||||
out = bytearray(len(data)) # output buffer
|
||||
for i, b in enumerate(data): # each plaintext byte
|
||||
out[i] = b ^ key[i % len(key)] # xor with cycling key
|
||||
return bytes(out) # immutable
|
||||
|
||||
|
||||
@dataclass
|
||||
class PassthroughEnvelope:
|
||||
"""On-wire object. Header-like fields stay JSON; body is ct."""
|
||||
|
||||
to: str # mailbox dest, in the clear for routing
|
||||
from_lookup_id: str # sender cannot be recovered by broker
|
||||
alg: str # npe | lab-xor | plain-lab
|
||||
ct: str # hex ciphertext or empty if plain-lab
|
||||
nonce: str # hex nonce used in lookup_id
|
||||
error_token: str # public-key token placeholder for return path
|
||||
|
||||
def header(self) -> Dict[str, str]:
|
||||
"""Routing/logging header (no payload)."""
|
||||
return { # allowed on the untrusted broker
|
||||
"to": self.to,
|
||||
"from_lookup_id": self.from_lookup_id,
|
||||
"alg": self.alg,
|
||||
"nonce": self.nonce,
|
||||
"error_token": self.error_token,
|
||||
}
|
||||
|
||||
def wire(self) -> Dict[str, Any]:
|
||||
"""Full JSON for NATS."""
|
||||
w = self.header() # start with header
|
||||
w["ct"] = self.ct # ciphertext only
|
||||
w["v"] = 1 # version
|
||||
w["mode"] = "passthrough" # dest in clear
|
||||
return w # ready to publish
|
||||
|
||||
|
||||
def seal(
|
||||
*,
|
||||
to: str,
|
||||
sender: str,
|
||||
body: Mapping[str, Any],
|
||||
mode: str,
|
||||
lab_key: bytes = b"",
|
||||
) -> PassthroughEnvelope:
|
||||
"""Encrypt body for recipient; return passthrough envelope."""
|
||||
nonce = os.urandom(16) # fresh nonce
|
||||
lid = lookup_id(sender, nonce) # opaque sender id
|
||||
raw = json.dumps(body, sort_keys=True, separators=(",", ":")).encode() # body bytes
|
||||
if mode == "plain-lab": # tests only
|
||||
ct = raw.hex() # not secret
|
||||
elif mode == "lab-xor": # lab PSK
|
||||
if not lab_key: # require key
|
||||
raise ValueError("lab_key required for lab-xor")
|
||||
key = hashlib.sha256(lab_key).digest() # 32-byte key
|
||||
ct = _xor(key, raw).hex() # hex ct
|
||||
elif mode == "npe": # production — sidecar not in this module
|
||||
raise NotImplementedError("npe: attach NPE sidecar; do not HPKE here")
|
||||
else: # unknown
|
||||
raise ValueError("unknown crypto.mode " + mode)
|
||||
token = hashlib.sha256(nonce + b"error-token").hexdigest()[:32] # return-path token stub
|
||||
return PassthroughEnvelope( # dest in clear
|
||||
to=to,
|
||||
from_lookup_id=lid,
|
||||
alg=mode,
|
||||
ct=ct,
|
||||
nonce=nonce.hex(),
|
||||
error_token=token,
|
||||
)
|
||||
|
||||
|
||||
def open_lab(env: PassthroughEnvelope, lab_key: bytes = b"") -> Dict[str, Any]:
|
||||
"""Decrypt lab envelopes only. Production uses NPE sidecar."""
|
||||
raw = bytes.fromhex(env.ct) # ct bytes
|
||||
if env.alg == "plain-lab": # tests
|
||||
return json.loads(raw.decode()) # json body
|
||||
if env.alg == "lab-xor": # lab
|
||||
key = hashlib.sha256(lab_key).digest() # same kdf
|
||||
pt = _xor(key, raw) # decrypt
|
||||
return json.loads(pt.decode()) # json
|
||||
raise ValueError("open_lab cannot open alg " + env.alg) # npe not here
|
||||
117
python/secure_messaging/error_bundle.py
Normal file
117
python/secure_messaging/error_bundle.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Network Error Bundle + system bounce report.
|
||||
|
||||
ct_sender: only the sender can read the failure status.
|
||||
ct_system: system public key material for logging/recovery.
|
||||
Never include the intended recipient's message plaintext.
|
||||
Sender identifies the original with lookup_id only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations # annotations
|
||||
|
||||
import hashlib # lab box
|
||||
import json # encode reports
|
||||
import os # nonce
|
||||
from dataclasses import dataclass # bundle
|
||||
from typing import Any, Dict # maps
|
||||
|
||||
|
||||
def _xor(key: bytes, data: bytes) -> bytes:
|
||||
"""Lab box: XOR with SHA-256(key||nonce) — replaced by X25519 in production."""
|
||||
out = bytearray(len(data)) # buffer
|
||||
for i, b in enumerate(data): # each byte
|
||||
out[i] = b ^ key[i % len(key)] # xor
|
||||
return bytes(out) # done
|
||||
|
||||
|
||||
def _lab_box(pubkey_material: bytes, nonce: bytes, obj: Dict[str, Any]) -> str:
|
||||
"""Encrypt obj to a lab 'public' blob (hash of material+nonce as key)."""
|
||||
raw = json.dumps(obj, sort_keys=True, separators=(",", ":")).encode() # bytes
|
||||
key = hashlib.sha256(pubkey_material + nonce).digest() # 32 bytes
|
||||
return _xor(key, raw).hex() # hex ct
|
||||
|
||||
|
||||
def _lab_unbox(pubkey_material: bytes, nonce: bytes, ct_hex: str) -> Dict[str, Any]:
|
||||
"""Decrypt lab box (same keying as _lab_box)."""
|
||||
key = hashlib.sha256(pubkey_material + nonce).digest() # same kdf
|
||||
pt = _xor(key, bytes.fromhex(ct_hex)) # decrypt
|
||||
return json.loads(pt.decode()) # json
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkErrorBundle:
|
||||
"""Two ciphertexts: sender-only status + system bounce report."""
|
||||
|
||||
lookup_id: str # how sender finds the failed send
|
||||
error_code: str # machine code (e.g. SM-DEAD-1)
|
||||
dest_class: str # mailbox | service (not a mailbox id)
|
||||
nonce: str # hex
|
||||
ct_sender: str # hex, sender-only
|
||||
ct_system: str # hex, system-key bounce report
|
||||
|
||||
def header(self) -> Dict[str, str]:
|
||||
"""Logging header: codes only."""
|
||||
return { # never put ct in logs
|
||||
"lookup_id": self.lookup_id,
|
||||
"error_code": self.error_code,
|
||||
"dest_class": self.dest_class,
|
||||
}
|
||||
|
||||
def wire(self) -> Dict[str, Any]:
|
||||
"""NATS body for verae.sm.dead / verae.sm.error."""
|
||||
return { # both ciphertexts
|
||||
"v": 1,
|
||||
"type": "network-error-bundle",
|
||||
"lookup_id": self.lookup_id,
|
||||
"error_code": self.error_code,
|
||||
"dest_class": self.dest_class,
|
||||
"nonce": self.nonce,
|
||||
"ct_sender": self.ct_sender,
|
||||
"ct_system": self.ct_system,
|
||||
}
|
||||
|
||||
|
||||
def build_bundle(
|
||||
*,
|
||||
lookup_id: str,
|
||||
error_code: str,
|
||||
dest_class: str,
|
||||
sender_pub: bytes,
|
||||
system_pub: bytes,
|
||||
detail: str,
|
||||
) -> NetworkErrorBundle:
|
||||
"""Build bundle. detail is a status phrase, never recipient plaintext."""
|
||||
nonce = os.urandom(16) # fresh
|
||||
sender_obj = { # only sender opens this
|
||||
"lookup_id": lookup_id,
|
||||
"error_code": error_code,
|
||||
"detail": detail,
|
||||
}
|
||||
system_obj = { # bounce report — no mail body
|
||||
"lookup_id": lookup_id,
|
||||
"error_code": error_code,
|
||||
"dest_class": dest_class,
|
||||
"respond": True,
|
||||
}
|
||||
return NetworkErrorBundle( # pack
|
||||
lookup_id=lookup_id,
|
||||
error_code=error_code,
|
||||
dest_class=dest_class,
|
||||
nonce=nonce.hex(),
|
||||
ct_sender=_lab_box(sender_pub, nonce, sender_obj),
|
||||
ct_system=_lab_box(system_pub, nonce, system_obj),
|
||||
)
|
||||
|
||||
|
||||
def open_sender(bundle: NetworkErrorBundle, sender_pub: bytes) -> Dict[str, Any]:
|
||||
"""Sender reads their status ciphertext."""
|
||||
return _lab_unbox(sender_pub, bytes.fromhex(bundle.nonce), bundle.ct_sender) # sender only
|
||||
|
||||
|
||||
def open_system(bundle: NetworkErrorBundle, system_pub: bytes) -> Dict[str, Any]:
|
||||
"""Ops logger reads bounce metadata (still no mail body)."""
|
||||
return _lab_unbox(system_pub, bytes.fromhex(bundle.nonce), bundle.ct_system) # system only
|
||||
|
||||
|
||||
def log_summary(bundle: NetworkErrorBundle) -> Dict[str, str]:
|
||||
"""Central log line: codes only."""
|
||||
return bundle.header() # no ciphertext, no body
|
||||
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