Files
esign/pkg/envelope/state_machine_test.go
zeekay e51899bdc8 luxfi/esign v0.1: envelope state machine + hash-chained audit
First scaffold of the Apache 2.0 eSign crypto core. This commit
lands the foundation:

  * pkg/envelope — Envelope, SignerSlot, Signature, AuditEvent,
    State enum (Draft/Sent/Viewed/Signing/Completed/Voided/Expired/
    Declined) + transition matrix.
  * pkg/envelope/state_machine.go — Transition + AddSignature, with
    order enforcement, dochash binding, duplicate-signature rejection,
    sticky terminal states, SHA-256 hash-chained audit events.
  * pkg/envelope/state_machine_test.go — 12 unit tests covering legal
    transitions, illegal transitions, terminal stickiness, audit-chain
    consistency, tamper detection, single + multi + ordered signing,
    document-hash binding, unknown-signer rejection.
  * README.md — composition model: luxfi/esign (Apache 2.0) sits
    beneath hanzoai/esign (AGPL, Hanzo-hosted DocuSign clone) via
    native ZAP RPC. White-label tenants can call hanzoai/esign as a
    SaaS or compose luxfi/esign directly with no SaaS hop.
  * Module skeleton: pkg/{envelope,pdf,sign,verify,audit,api} +
    cmd/esignd. PDF, sign, verify, audit, api and esignd cmd are
    empty directories — populated in follow-on commits.

Next: pdfcpu-backed PAdES embed (pkg/pdf), stdlib Ed25519 single-signer
path (pkg/sign), then ML-DSA-65 PQ path, then FROST threshold.
2026-06-07 11:20:31 -07:00

251 lines
8.0 KiB
Go

// Copyright 2026 Lux Industries Inc. and the luxfi/esign authors.
// Licensed under the Apache License, Version 2.0 (see LICENSE).
package envelope
import (
"crypto/sha256"
"testing"
"time"
)
// newDraft returns a minimal Draft envelope with one required signer.
func newDraft(t *testing.T) *Envelope {
t.Helper()
doc := []byte("Hello world.\n")
docHash := sha256.Sum256(doc)
signer := [32]byte{1}
return &Envelope{
ID: "env-1",
Version: 0,
Document: doc,
DocHash: docHash,
Signers: []SignerSlot{{SignerHash: signer, Method: MethodEd25519, Required: true}},
State: StateDraft,
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
}
}
// State transitions that ARE legal.
func TestTransition_LegalPaths(t *testing.T) {
cases := []struct {
name string
from State
to State
}{
{"draft → sent", StateDraft, StateSent},
{"draft → voided", StateDraft, StateVoided},
{"sent → viewed", StateSent, StateViewed},
{"sent → signing", StateSent, StateSigning},
{"sent → declined", StateSent, StateDeclined},
{"viewed → signing", StateViewed, StateSigning},
{"signing → completed", StateSigning, StateCompleted},
{"signing → voided", StateSigning, StateVoided},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newDraft(t)
e.State = c.from
_, err := e.Transition(c.to, [32]byte{0xff}, "", time.Now().UTC())
if err != nil {
t.Fatalf("Transition(%s→%s) returned %v, want nil",
c.from, c.to, err)
}
if e.State != c.to {
t.Errorf("state = %s, want %s", e.State, c.to)
}
})
}
}
// State transitions that should be REJECTED.
func TestTransition_IllegalPaths(t *testing.T) {
cases := []struct {
name string
from State
to State
}{
{"draft → completed (skip signing)", StateDraft, StateCompleted},
{"draft → viewed (skip sent)", StateDraft, StateViewed},
{"sent → draft (no rewind)", StateSent, StateDraft},
{"completed → voided (terminal)", StateCompleted, StateVoided},
{"voided → draft (terminal)", StateVoided, StateDraft},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newDraft(t)
e.State = c.from
_, err := e.Transition(c.to, [32]byte{0xff}, "", time.Now().UTC())
if err == nil {
t.Fatalf("Transition(%s→%s) returned nil, want error", c.from, c.to)
}
})
}
}
// Terminal states are sticky: every further Transition fails fast.
func TestTransition_TerminalSticky(t *testing.T) {
for _, term := range []State{StateCompleted, StateVoided, StateExpired, StateDeclined} {
t.Run(term.String(), func(t *testing.T) {
e := newDraft(t)
e.State = term
_, err := e.Transition(StateSent, [32]byte{0xff}, "", time.Now().UTC())
if err != ErrAlreadyTerminal {
t.Fatalf("got %v, want ErrAlreadyTerminal", err)
}
})
}
}
// Audit chain stays internally consistent across many transitions.
func TestAuditChain_ConsistentAcrossSequence(t *testing.T) {
e := newDraft(t)
now := time.Unix(1_700_000_000, 0).UTC()
for _, step := range []State{StateSent, StateViewed, StateSigning} {
if _, err := e.Transition(step, [32]byte{0xaa}, "", now); err != nil {
t.Fatalf("step %s: %v", step, err)
}
now = now.Add(time.Second)
}
if got := e.VerifyChain(); got != -1 {
t.Fatalf("VerifyChain = %d, want -1 (intact)", got)
}
if len(e.AuditChain) != 3 {
t.Fatalf("len(chain) = %d, want 3", len(e.AuditChain))
}
// Seq monotonic.
for i, ev := range e.AuditChain {
if int(ev.Seq) != i {
t.Errorf("AuditChain[%d].Seq = %d, want %d", i, ev.Seq, i)
}
}
}
// Tampering with any event in the chain is detected by VerifyChain.
func TestAuditChain_DetectsTampering(t *testing.T) {
e := newDraft(t)
now := time.Unix(1_700_000_000, 0).UTC()
for _, step := range []State{StateSent, StateViewed, StateSigning} {
_, _ = e.Transition(step, [32]byte{0xaa}, "", now)
now = now.Add(time.Second)
}
// Tamper with event 1's Note. EventHash is stored as-is, so re-walking
// the chain finds Seq=1's recomputed hash no longer matches.
e.AuditChain[1].Note = "tampered"
if got := e.VerifyChain(); got != 1 {
t.Fatalf("VerifyChain = %d, want 1 (tampered event)", got)
}
}
// AddSignature flips state to Completed when every required slot is filled
// by a single signer.
func TestAddSignature_SingleRequired_Completes(t *testing.T) {
e := newDraft(t)
now := time.Unix(1_700_000_000, 0).UTC()
_, _ = e.Transition(StateSent, [32]byte{0xaa}, "", now)
now = now.Add(time.Second)
sig := Signature{
SignerHash: e.Signers[0].SignerHash,
Method: MethodEd25519,
Sig: []byte("fake-sig"),
PubKey: []byte("fake-pub"),
DocHash: e.DocHash,
}
if _, err := e.AddSignature(sig, now); err != nil {
t.Fatalf("AddSignature: %v", err)
}
if e.State != StateCompleted {
t.Errorf("state = %s, want completed", e.State)
}
}
// Signing out of declared order is rejected when Order > 0.
func TestAddSignature_OrderEnforced(t *testing.T) {
doc := []byte("doc")
docHash := sha256.Sum256(doc)
a, b := [32]byte{1}, [32]byte{2}
e := &Envelope{
ID: "env-2",
Document: doc, DocHash: docHash,
Signers: []SignerSlot{
{SignerHash: a, Method: MethodEd25519, Required: true, Order: 1},
{SignerHash: b, Method: MethodEd25519, Required: true, Order: 2},
},
State: StateSent,
}
// B tries to sign first — must fail.
sigB := Signature{SignerHash: b, Method: MethodEd25519, DocHash: docHash}
if _, err := e.AddSignature(sigB, time.Now().UTC()); err != ErrSignerOutOfOrder {
t.Fatalf("AddSignature(B-first): got %v, want ErrSignerOutOfOrder", err)
}
// A signs first → OK.
sigA := Signature{SignerHash: a, Method: MethodEd25519, DocHash: docHash}
if _, err := e.AddSignature(sigA, time.Now().UTC()); err != nil {
t.Fatalf("AddSignature(A): %v", err)
}
// Now B can sign → completes.
if _, err := e.AddSignature(sigB, time.Now().UTC()); err != nil {
t.Fatalf("AddSignature(B-after-A): %v", err)
}
if e.State != StateCompleted {
t.Errorf("state = %s, want completed", e.State)
}
}
// Duplicate signature from the same signer is rejected. Two-signer envelope
// so the first signature leaves the envelope mid-Signing (not Completed),
// which is the only state where a "duplicate" check is even reachable —
// once an envelope completes, all further signatures hit ErrAlreadyTerminal
// first, which is also correct behaviour.
func TestAddSignature_NoDuplicate(t *testing.T) {
doc := []byte("doc")
docHash := sha256.Sum256(doc)
a, b := [32]byte{1}, [32]byte{2}
e := &Envelope{
ID: "env-dup",
Document: doc, DocHash: docHash,
Signers: []SignerSlot{
{SignerHash: a, Method: MethodEd25519, Required: true},
{SignerHash: b, Method: MethodEd25519, Required: true},
},
State: StateSent,
}
sig := Signature{SignerHash: a, Method: MethodEd25519, DocHash: docHash}
if _, err := e.AddSignature(sig, time.Now().UTC()); err != nil {
t.Fatalf("first sig: %v", err)
}
if _, err := e.AddSignature(sig, time.Now().UTC()); err != ErrDuplicateSignature {
t.Fatalf("got %v, want ErrDuplicateSignature", err)
}
}
// Substituting a different document body must be rejected — the signer
// signed dochash X, the envelope dochash is Y, that's an attack.
func TestAddSignature_DocHashMismatchRejected(t *testing.T) {
e := newDraft(t)
_, _ = e.Transition(StateSent, [32]byte{0xaa}, "", time.Now().UTC())
sig := Signature{
SignerHash: e.Signers[0].SignerHash,
Method: MethodEd25519,
DocHash: [32]byte{0xde, 0xad, 0xbe, 0xef}, // wrong hash
}
if _, err := e.AddSignature(sig, time.Now().UTC()); err != ErrDocHashMismatch {
t.Fatalf("got %v, want ErrDocHashMismatch", err)
}
}
// Unknown signer (not in slot list) is rejected.
func TestAddSignature_UnknownSignerRejected(t *testing.T) {
e := newDraft(t)
_, _ = e.Transition(StateSent, [32]byte{0xaa}, "", time.Now().UTC())
sig := Signature{
SignerHash: [32]byte{0x99}, // not in slot list
Method: MethodEd25519,
DocHash: e.DocHash,
}
if _, err := e.AddSignature(sig, time.Now().UTC()); err != ErrUnknownSigner {
t.Fatalf("got %v, want ErrUnknownSigner", err)
}
}