commit d67509e4709b77d0538e979dba73cbf258f6fe87 Author: George Lambert Date: Tue Sep 15 22:17:19 2026 -0400 S06-S08: Python spec, Go in-process leaf, tests and Forgejo CI diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..a12a8ad --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,22 @@ +name: ci +on: + push: + pull_request: +jobs: + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -r requirements.txt + - run: PYTHONPATH=python python3 -m unittest discover -s tests -v + go: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - run: cd go && go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d5b1d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +keys/ +*.pem +go/bin/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d1b6843 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2026 Verae / George Lambert + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files to use, copy, modify, +merge, publish, and distribute, subject to including this notice. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. diff --git a/README.md b/README.md new file mode 100644 index 0000000..118206b --- /dev/null +++ b/README.md @@ -0,0 +1,16 @@ +# secure-messaging + +Passthrough NATS envelopes, Ed25519 signed configuration, DataCube admin +history, and Network Error Bundles. + +* Python spec: `python/secure_messaging/` (line comments) +* Go leaf: `go/cmd/sm-leaf` (in-process NATS + optional `SM_LEAF_HUB`) +* Catalog: https://git.georgelambert.org/marchon/nats-service-endpoints +* Hub: https://git.georgelambert.org/marchon/system-git-sync + +Config must be a signed wrapper. Unsigned files are rejected. Admin changes +append prev + new + unified diff to a `kind=admin-history` JSONL chain. + +`crypto.mode`: `npe` | `lab-xor` | `plain-lab` (see signed payload). + +Not a HIPAA/SOC 2/ISO certificate. ns1: no deploy until CI review. diff --git a/go/cmd/sm-leaf/main.go b/go/cmd/sm-leaf/main.go new file mode 100644 index 0000000..2ace866 --- /dev/null +++ b/go/cmd/sm-leaf/main.go @@ -0,0 +1,26 @@ +// sm-leaf: in-process NATS core plus optional leaf to the Verae hub. +package main + +import ( + "flag" + "log" + "os" + "os/signal" + "syscall" + + "github.com/marchon/secure-messaging/internal/leaf" +) + +func main() { + hub := flag.String("hub", os.Getenv("SM_LEAF_HUB"), "leaf hub URL, e.g. nats-leaf://10.10.10.21:7422") + flag.Parse() + n, err := leaf.Start(*hub) + if err != nil { + log.Fatal(err) + } + log.Printf("sm-leaf in-process nats %s hub=%q", n.Addr(), *hub) + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) + <-ch + _ = n.Shutdown() +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..4931b11 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,19 @@ +module github.com/marchon/secure-messaging + +go 1.22 + +require ( + github.com/nats-io/nats-server/v2 v2.10.24 + github.com/nats-io/nats.go v1.38.0 +) + +require ( + github.com/klauspost/compress v1.17.11 // indirect + github.com/minio/highwayhash v1.0.3 // indirect + github.com/nats-io/jwt/v2 v2.7.3 // indirect + github.com/nats-io/nkeys v0.4.9 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/time v0.8.0 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..271360c --- /dev/null +++ b/go/go.sum @@ -0,0 +1,21 @@ +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q= +github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.7.3 h1:6bNPK+FXgBeAqdj4cYQ0F8ViHRbi7woQLq4W29nUAzE= +github.com/nats-io/jwt/v2 v2.7.3/go.mod h1:GvkcbHhKquj3pkioy5put1wvPxs78UlZ7D/pY+BgZk4= +github.com/nats-io/nats-server/v2 v2.10.24 h1:KcqqQAD0ZZcG4yLxtvSFJY7CYKVYlnlWoAiVZ6i/IY4= +github.com/nats-io/nats-server/v2 v2.10.24/go.mod h1:olvKt8E5ZlnjyqBGbAXtxvSQKsPodISK5Eo/euIta4s= +github.com/nats-io/nats.go v1.38.0 h1:A7P+g7Wjp4/NWqDOOP/K6hfhr54DvdDQUznt5JFg9XA= +github.com/nats-io/nats.go v1.38.0/go.mod h1:IGUM++TwokGnXPs82/wCuiHS02/aKrdYUQkU8If6yjw= +github.com/nats-io/nkeys v0.4.9 h1:qe9Faq2Gxwi6RZnZMXfmGMZkg3afLLOtrU+gDZJ35b0= +github.com/nats-io/nkeys v0.4.9/go.mod h1:jcMqs+FLG+W5YO36OX6wFIFcmpdAns+w1Wm6D3I/evE= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= +golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/go/internal/leaf/leaf.go b/go/internal/leaf/leaf.go new file mode 100644 index 0000000..d10ca28 --- /dev/null +++ b/go/internal/leaf/leaf.go @@ -0,0 +1,66 @@ +// Package leaf starts an in-process nats-server and optional hub leaf. +package leaf + +import ( + "errors" + "net/url" + "time" + + natsserver "github.com/nats-io/nats-server/v2/server" + nats "github.com/nats-io/nats.go" +) + +// Node is an in-process broker plus client. +type Node struct { + ns *natsserver.Server + nc *nats.Conn +} + +// Start binds 127.0.0.1:0 and optionally leaf-connects to hub. +func Start(hub string) (*Node, error) { + opts := &natsserver.Options{Host: "127.0.0.1", Port: -1} + if hub != "" { + u, err := url.Parse(hub) + if err != nil { + return nil, err + } + opts.LeafNode.Remotes = []*natsserver.RemoteLeafOpts{{URLs: []*url.URL{u}}} + } + ns, err := natsserver.NewServer(opts) + if err != nil { + return nil, err + } + go ns.Start() + if !ns.ReadyForConnections(5 * time.Second) { + return nil, errors.New("nats not ready") + } + nc, err := nats.Connect(ns.ClientURL()) + if err != nil { + ns.Shutdown() + return nil, err + } + n := &Node{ns: ns, nc: nc} + if _, err := n.nc.Subscribe("verae.sm.send", func(msg *nats.Msg) { + if msg.Reply != "" { + _ = n.nc.Publish(msg.Reply, []byte(`{"accepted":true}`)) + } + }); err != nil { + n.Shutdown() + return nil, err + } + return n, nil +} + +// Addr is the in-process client URL. +func (n *Node) Addr() string { return n.ns.ClientURL() } + +// Shutdown stops client and server. +func (n *Node) Shutdown() error { + if n.nc != nil { + n.nc.Close() + } + if n.ns != nil { + n.ns.Shutdown() + } + return nil +} diff --git a/go/internal/leaf/leaf_test.go b/go/internal/leaf/leaf_test.go new file mode 100644 index 0000000..e678eb6 --- /dev/null +++ b/go/internal/leaf/leaf_test.go @@ -0,0 +1,14 @@ +package leaf + +import "testing" + +func TestStartInProcess(t *testing.T) { + n, err := Start("") + if err != nil { + t.Fatal(err) + } + defer n.Shutdown() + if n.Addr() == "" { + t.Fatal("empty addr") + } +} diff --git a/python/secure_messaging/__init__.py b/python/secure_messaging/__init__.py new file mode 100644 index 0000000..7506e33 --- /dev/null +++ b/python/secure_messaging/__init__.py @@ -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", +] diff --git a/python/secure_messaging/admin_history.py b/python/secure_messaging/admin_history.py new file mode 100644 index 0000000..1c7300b --- /dev/null +++ b/python/secure_messaging/admin_history.py @@ -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 diff --git a/python/secure_messaging/envelope.py b/python/secure_messaging/envelope.py new file mode 100644 index 0000000..9adab43 --- /dev/null +++ b/python/secure_messaging/envelope.py @@ -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 diff --git a/python/secure_messaging/error_bundle.py b/python/secure_messaging/error_bundle.py new file mode 100644 index 0000000..9e3d49c --- /dev/null +++ b/python/secure_messaging/error_bundle.py @@ -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 diff --git a/python/secure_messaging/signed_config.py b/python/secure_messaging/signed_config.py new file mode 100644 index 0000000..0fb5455 --- /dev/null +++ b/python/secure_messaging/signed_config.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e30c06f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +cryptography>=42.0.0 diff --git a/tests/test_sm.py b/tests/test_sm.py new file mode 100644 index 0000000..bb741ea --- /dev/null +++ b/tests/test_sm.py @@ -0,0 +1,83 @@ +"""Signed config, passthrough, error bundle, admin history.""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from secure_messaging.admin_history import AdminHistory +from secure_messaging.envelope import open_lab, seal +from secure_messaging.error_bundle import build_bundle, log_summary, open_sender, open_system +from secure_messaging.signed_config import UnsignedConfig, generate_signing_key, load_signed, save_signed, sign, verify + + +class SignedConfigTests(unittest.TestCase): + def test_roundtrip_and_tamper(self): + priv = generate_signing_key() + pub = priv.public_key() + payload = {"crypto": {"mode": "lab-xor"}, "routing": {"mode": "passthrough"}} + signed = sign(payload, priv) + self.assertEqual(verify(signed.wrapper(), pub)["crypto"]["mode"], "lab-xor") + bad = signed.wrapper() + bad["payload"]["crypto"]["mode"] = "plain-lab" + with self.assertRaises(UnsignedConfig): + verify(bad, pub) + with tempfile.TemporaryDirectory() as td: + p = Path(td) / "c.json" + save_signed(p, signed) + self.assertEqual(load_signed(p, pub)["routing"]["mode"], "passthrough") + p.write_text(json.dumps({"payload": payload})) + with self.assertRaises(UnsignedConfig): + load_signed(p, pub) + + +class EnvelopeTests(unittest.TestCase): + def test_lab_xor_roundtrip_lookup(self): + env = seal( + to="npe.inbox.abc", + sender="alice", + body={"note": "hello"}, + mode="lab-xor", + lab_key=b"lab", + ) + self.assertEqual(env.to, "npe.inbox.abc") + self.assertNotIn("alice", env.from_lookup_id) + self.assertEqual(open_lab(env, b"lab")["note"], "hello") + + +class ErrorBundleTests(unittest.TestCase): + def test_sender_and_system_separate(self): + b = build_bundle( + lookup_id="lid1", + error_code="SM-DEAD-1", + dest_class="mailbox", + sender_pub=b"sender-pub", + system_pub=b"system-pub", + detail="undeliverable", + ) + s = open_sender(b, b"sender-pub") + sysb = open_system(b, b"system-pub") + self.assertEqual(s["detail"], "undeliverable") + self.assertNotIn("detail", sysb) + self.assertTrue(sysb["respond"]) + self.assertEqual(log_summary(b)["error_code"], "SM-DEAD-1") + with self.assertRaises(Exception): + open_sender(b, b"wrong") + + +class HistoryTests(unittest.TestCase): + def test_prev_new_diff(self): + with tempfile.TemporaryDirectory() as td: + h = AdminHistory(Path(td) / "admin-history") + row = h.append_change(actor="alice", prev_text="a=1\n", new_text="a=2\n") + self.assertEqual(row["payload"]["type"], "admin-config") + self.assertIn("-a=1", row["payload"]["diff"]) + self.assertIn("+a=2", row["payload"]["diff"]) + with self.assertRaises(ValueError): + h.append_change(actor="", prev_text="", new_text="x") + + +if __name__ == "__main__": + unittest.main()