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