S06-S08: Python spec, Go in-process leaf, tests and Forgejo CI
Some checks are pending
ci / python (push) Waiting to run
ci / go (push) Waiting to run

This commit is contained in:
George Lambert 2026-09-15 22:17:19 -04:00
commit d67509e470
16 changed files with 726 additions and 0 deletions

22
.forgejo/workflows/ci.yml Normal file
View file

@ -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 ./...

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
__pycache__/
*.pyc
keys/
*.pem
go/bin/

7
LICENSE Normal file
View file

@ -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.

16
README.md Normal file
View file

@ -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.

26
go/cmd/sm-leaf/main.go Normal file
View file

@ -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()
}

19
go/go.mod Normal file
View file

@ -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
)

21
go/go.sum Normal file
View file

@ -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=

66
go/internal/leaf/leaf.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

View 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",
]

View 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

View 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

View 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

View 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

1
requirements.txt Normal file
View file

@ -0,0 +1 @@
cryptography>=42.0.0

83
tests/test_sm.py Normal file
View file

@ -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()