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.
209 lines
5.5 KiB
Go
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
|
|
}
|