mirror of
https://github.com/luxfi/esign.git
synced 2026-10-30 16:58:18 +00:00
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.
251 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|