Files
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

209 lines
5.5 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"
"encoding/binary"
"time"
)
// legalTransitions encodes the state graph. Any From→To pair not present
// here yields ErrInvalidTransition. Keep this map narrow — every entry is
// a public state-machine claim and must be matched by a unit test.
//
// Sent→Completed and Viewed→Completed are legal for the single-signer case:
// a sole required signer can sign immediately, collapsing the Signing state.
var legalTransitions = map[State]map[State]bool{
StateDraft: {
StateSent: true,
StateVoided: true, // void from draft = throw away before sending
StateExpired: true,
},
StateSent: {
StateViewed: true,
StateSigning: true, // signer signs without us recording a Viewed first
StateCompleted: true, // sole signer signs in one shot
StateVoided: true,
StateExpired: true,
StateDeclined: true,
},
StateViewed: {
StateSigning: true,
StateCompleted: true, // sole signer: viewed then signed
StateVoided: true,
StateExpired: true,
StateDeclined: true,
},
StateSigning: {
StateSigning: true, // additional signer signs, still incomplete
StateCompleted: true,
StateVoided: true,
StateExpired: true,
StateDeclined: true,
},
}
// Transition advances the envelope to `to`, appending an audit event keyed
// off `actor` (hash of the principal causing the change) and an optional
// note. Returns the audit event written, or an error if the transition is
// not legal from the current state.
//
// The envelope's UpdatedAt is set to now; the version is bumped. Terminal
// states are sticky — any further transition fails with ErrAlreadyTerminal.
func (e *Envelope) Transition(to State, actor [32]byte, note string, now time.Time) (AuditEvent, error) {
if e.State.IsTerminal() {
return AuditEvent{}, ErrAlreadyTerminal
}
allowed, ok := legalTransitions[e.State]
if !ok || !allowed[to] {
return AuditEvent{}, ErrInvalidTransition
}
from := e.State
e.State = to
e.UpdatedAt = now
e.Version++
ev := AuditEvent{
Seq: uint32(len(e.AuditChain)),
At: now,
Actor: actor,
From: from,
To: to,
Note: note,
}
if len(e.AuditChain) > 0 {
ev.PrevHash = e.AuditChain[len(e.AuditChain)-1].EventHash
}
ev.EventHash = hashAuditEvent(ev)
e.AuditChain = append(e.AuditChain, ev)
return ev, nil
}
// AddSignature records signer `sig`'s contribution. Validates:
// - signer is declared in the slot list
// - signer hasn't already signed
// - signature DocHash matches envelope DocHash (no document substitution)
// - if slot Order is non-zero, all prior-ordered required signers have signed
//
// On success, drives the state forward to Signing (or to Completed if every
// required slot is now filled). Returns the audit event written.
func (e *Envelope) AddSignature(sig Signature, now time.Time) (AuditEvent, error) {
if e.State.IsTerminal() {
return AuditEvent{}, ErrAlreadyTerminal
}
if sig.DocHash != e.DocHash {
return AuditEvent{}, ErrDocHashMismatch
}
// Slot lookup.
var (
slot SignerSlot
slotIdx = -1
)
for i, s := range e.Signers {
if s.SignerHash == sig.SignerHash {
slot = s
slotIdx = i
break
}
}
if slotIdx < 0 {
return AuditEvent{}, ErrUnknownSigner
}
// Duplicate-signature check.
for _, existing := range e.Signatures {
if existing.SignerHash == sig.SignerHash {
return AuditEvent{}, ErrDuplicateSignature
}
}
// Order enforcement: every required signer with Order < slot.Order
// must have already signed.
if slot.Order > 0 {
for _, prior := range e.Signers {
if prior.Required && prior.Order < slot.Order {
signed := false
for _, s := range e.Signatures {
if s.SignerHash == prior.SignerHash {
signed = true
break
}
}
if !signed {
return AuditEvent{}, ErrSignerOutOfOrder
}
}
}
}
sig.SignedAt = now
e.Signatures = append(e.Signatures, sig)
// Are all required slots now filled?
complete := true
for _, slot := range e.Signers {
if !slot.Required {
continue
}
filled := false
for _, s := range e.Signatures {
if s.SignerHash == slot.SignerHash {
filled = true
break
}
}
if !filled {
complete = false
break
}
}
target := StateSigning
if complete {
target = StateCompleted
}
return e.Transition(target, sig.SignerHash, "", now)
}
// hashAuditEvent computes a deterministic SHA-256 of an AuditEvent's canonical
// encoding. PrevHash is folded in so EventHash is the chain tip after this
// event; tampering with any earlier event breaks every forward hash.
func hashAuditEvent(ev AuditEvent) [32]byte {
h := sha256.New()
var u32 [4]byte
binary.LittleEndian.PutUint32(u32[:], ev.Seq)
h.Write(u32[:])
var u64 [8]byte
binary.LittleEndian.PutUint64(u64[:], uint64(ev.At.UnixNano()))
h.Write(u64[:])
h.Write(ev.Actor[:])
h.Write([]byte{byte(ev.From), byte(ev.To)})
h.Write([]byte(ev.Note))
h.Write(ev.PrevHash[:])
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// VerifyChain walks the audit chain forward and recomputes each EventHash
// from its inputs (Seq, At, Actor, From, To, Note, PrevHash). Any mismatch
// means at least one event was tampered with after it was recorded.
//
// Returns the index of the first bad event, or -1 if the chain is intact.
func (e *Envelope) VerifyChain() int {
var prev [32]byte
for i, ev := range e.AuditChain {
ev.PrevHash = prev
want := hashAuditEvent(ev)
if want != e.AuditChain[i].EventHash {
return i
}
prev = want
}
return -1
}