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
22
.forgejo/workflows/ci.yml
Normal file
22
.forgejo/workflows/ci.yml
Normal 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
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
keys/
|
||||||
|
*.pem
|
||||||
|
go/bin/
|
||||||
7
LICENSE
Normal file
7
LICENSE
Normal 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
16
README.md
Normal 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
26
go/cmd/sm-leaf/main.go
Normal 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
19
go/go.mod
Normal 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
21
go/go.sum
Normal 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
66
go/internal/leaf/leaf.go
Normal 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
|
||||||
|
}
|
||||||
14
go/internal/leaf/leaf_test.go
Normal file
14
go/internal/leaf/leaf_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
cryptography>=42.0.0
|
||||||
83
tests/test_sm.py
Normal file
83
tests/test_sm.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue