Files
zeekay 3ccbe432ae luxfi/esign v0.3: Hanzo Base as the default backend store
Make Hanzo Base — per-tenant SQLite + extension runtime (HIP-0105,
HIP-0107, HIP-0302) — the canonical production backend. MemStore stays
for tests and ephemeral local dev.

  * pkg/basestore — BaseStore implements api.Store on a core.App.
    Single esign_envelopes collection holds the canonical JSON
    envelope as the body field; state + doc_hash + version +
    updated_at are mirrored as indexed columns for cheap dashboard
    queries, but the JSON blob is the source of truth so envelope
    schema changes never require a SQLite migration.
    ensureCollection is idempotent.
    Indexes: (state, updated_at DESC), (doc_hash).

  * pkg/basestore/store_test.go — round-trip, upsert, missing-id,
    empty-id rejection. Uses t.TempDir() so each test isolates its
    own SQLite file.

  * pkg/api/handlers.go — bump default envelope ID generator from
    "env_<8 hex>" to "esign<16 hex>" (21 chars, ^[a-z0-9]+$) to
    match Base's default record-id format and clear the 15-char
    minimum. ID stays deterministic from the document so identical
    bytes get the same envelope ID.

  * cmd/esignd — default to BaseStore against `--data-dir esign_data`.
    Pass `--memory` for the in-process MemStore (ephemeral, tests).
    buildStore handles bootstrap + cleanup so the rest of main stays
    a clean wire-up.

Test summary: 4 packages, 26 tests, all green.
2026-06-07 11:43:52 -07:00

267 lines
8.1 KiB
Go

// Copyright 2026 Lux Industries Inc. and the luxfi/esign authors.
// Licensed under the Apache License, Version 2.0 (see LICENSE).
package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"sync"
"time"
"github.com/luxfi/esign/pkg/envelope"
"github.com/luxfi/esign/pkg/sign"
)
// Store is the abstract persistence surface the API talks to. In-process
// services can pass an in-memory implementation (MemStore); production
// deployments back this with a SQLite/etc. store kept in cmd/esignd or
// in a downstream service that imports this package.
type Store interface {
Put(ctx context.Context, e *envelope.Envelope) error
Get(ctx context.Context, id string) (*envelope.Envelope, error)
}
// CreateRequest is the body of OpEnvelopeCreate.
type CreateRequest struct {
ID string `json:"id,omitempty"`
Document []byte `json:"document"` // raw PDF bytes; SHA-256 derived server-side
Signers []envelope.SignerSlot `json:"signers"`
ExpiresAt time.Time `json:"expiresAt,omitempty"`
}
// CreateResponse is the body of OpEnvelopeCreate's reply.
type CreateResponse struct {
ID string `json:"id"`
DocHash string `json:"docHash"` // hex
}
// SignRequest is the body of OpEnvelopeSign.
type SignRequest struct {
EnvelopeID string `json:"envelopeId"`
SignerHash [32]byte `json:"signerHash"`
Method envelope.SignMethod `json:"method"`
Sig []byte `json:"sig"`
PubKey []byte `json:"pubKey"`
}
// SignResponse is the body of OpEnvelopeSign's reply.
type SignResponse struct {
State string `json:"state"`
Version uint32 `json:"version"`
Completed bool `json:"completed"`
}
// VerifyResponse is the body of OpEnvelopeVerify's reply. BrokenAt = -1
// signals an intact envelope.
type VerifyResponse struct {
Verified bool `json:"verified"`
BrokenAuditEvent int `json:"brokenAuditEvent"` // -1 if chain ok
FailedSignature int `json:"failedSignature"` // -1 if all sigs verify
Detail string `json:"detail,omitempty"`
}
// TransitionRequest is the body of OpEnvelopeTransition.
type TransitionRequest struct {
EnvelopeID string `json:"envelopeId"`
Target envelope.State `json:"target"`
Actor [32]byte `json:"actor"`
Note string `json:"note,omitempty"`
}
// MemStore is a thread-safe in-memory Store. Lives in this package so the
// API surface ships with a working default for tests and small deployments.
type MemStore struct {
mu sync.RWMutex
by map[string]*envelope.Envelope
}
// NewMemStore returns an empty MemStore ready to use.
func NewMemStore() *MemStore {
return &MemStore{by: map[string]*envelope.Envelope{}}
}
// Put inserts or replaces an envelope by its ID. Callers MUST set e.ID.
func (m *MemStore) Put(_ context.Context, e *envelope.Envelope) error {
if e == nil || e.ID == "" {
return errors.New("api: store: envelope id required")
}
m.mu.Lock()
defer m.mu.Unlock()
// Defensive copy — callers can mutate their pointer after Put.
cp := *e
m.by[e.ID] = &cp
return nil
}
// Get returns a copy of the envelope so callers can't mutate stored state.
func (m *MemStore) Get(_ context.Context, id string) (*envelope.Envelope, error) {
m.mu.RLock()
defer m.mu.RUnlock()
e, ok := m.by[id]
if !ok {
return nil, errors.New("api: store: envelope not found: " + id)
}
cp := *e
return &cp, nil
}
// Handler bundles the persistence and clock dependencies the RPC surface
// needs. One Handler is shared across all opcode dispatches.
type Handler struct {
Store Store
Now func() time.Time // optional; defaults to time.Now
}
// New returns a Handler with sensible defaults. Pass a custom Now in tests.
func New(store Store) *Handler {
return &Handler{Store: store, Now: time.Now}
}
func (h *Handler) now() time.Time {
if h.Now == nil {
return time.Now().UTC()
}
return h.Now().UTC()
}
// Create handles OpEnvelopeCreate. Generates an ID if the client didn't
// provide one (sha256(doc)[:16] hex, prefixed "env_") so callers can rely
// on a deterministic ID from a deterministic document.
func (h *Handler) Create(ctx context.Context, req CreateRequest) (CreateResponse, error) {
if len(req.Document) == 0 {
return CreateResponse{}, errors.New("api: create: document required")
}
docHash := sha256.Sum256(req.Document)
id := req.ID
if id == "" {
// `esign` prefix + 16 hex chars (8 bytes of dochash) = 21 chars,
// all lowercase alphanumeric — matches Base's default
// ^[a-z0-9]+$ ID format and clears the 15-char minimum.
// Deterministic from the document so identical bytes get the
// same envelope ID — useful for client-side de-duplication.
id = "esign" + hex.EncodeToString(docHash[:8])
}
now := h.now()
e := &envelope.Envelope{
ID: id,
Version: 1,
Document: req.Document,
DocHash: docHash,
Signers: req.Signers,
State: envelope.StateDraft,
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: req.ExpiresAt,
}
if err := h.Store.Put(ctx, e); err != nil {
return CreateResponse{}, err
}
return CreateResponse{
ID: id,
DocHash: hex.EncodeToString(docHash[:]),
}, nil
}
// Get handles OpEnvelopeGet — returns the full envelope.
func (h *Handler) Get(ctx context.Context, id string) (*envelope.Envelope, error) {
return h.Store.Get(ctx, id)
}
// Sign handles OpEnvelopeSign. Verifies the signature cryptographically
// BEFORE handing off to envelope.AddSignature so a malformed sig never
// lands in the envelope's signature list.
func (h *Handler) Sign(ctx context.Context, req SignRequest) (SignResponse, error) {
e, err := h.Store.Get(ctx, req.EnvelopeID)
if err != nil {
return SignResponse{}, err
}
// Cryptographic verification first.
if err := sign.Verify(req.Method, req.PubKey, e.DocHash[:], req.Sig); err != nil {
return SignResponse{}, err
}
// Append to the envelope.
_, err = e.AddSignature(envelope.Signature{
SignerHash: req.SignerHash,
Method: req.Method,
Sig: req.Sig,
PubKey: req.PubKey,
DocHash: e.DocHash,
}, h.now())
if err != nil {
return SignResponse{}, err
}
if err := h.Store.Put(ctx, e); err != nil {
return SignResponse{}, err
}
return SignResponse{
State: e.State.String(),
Version: e.Version,
Completed: e.State == envelope.StateCompleted,
}, nil
}
// Transition handles OpEnvelopeTransition for non-signature state changes
// (Sent, Viewed, Voided, Expired, Declined).
func (h *Handler) Transition(ctx context.Context, req TransitionRequest) (SignResponse, error) {
e, err := h.Store.Get(ctx, req.EnvelopeID)
if err != nil {
return SignResponse{}, err
}
if _, err := e.Transition(req.Target, req.Actor, req.Note, h.now()); err != nil {
return SignResponse{}, err
}
if err := h.Store.Put(ctx, e); err != nil {
return SignResponse{}, err
}
return SignResponse{
State: e.State.String(),
Version: e.Version,
Completed: e.State == envelope.StateCompleted,
}, nil
}
// Verify handles OpEnvelopeVerify — re-checks every signature in the
// envelope against the stored dochash, then re-walks the audit chain.
// Returns a VerifyResponse with the first index of any break, or -1/-1
// for an intact envelope.
func (h *Handler) Verify(ctx context.Context, id string) (VerifyResponse, error) {
e, err := h.Store.Get(ctx, id)
if err != nil {
return VerifyResponse{}, err
}
for i, s := range e.Signatures {
if s.DocHash != e.DocHash {
return VerifyResponse{
Verified: false,
FailedSignature: i,
BrokenAuditEvent: -1,
Detail: "signature " + s.SignerHashHex() + " has stale dochash",
}, nil
}
if err := sign.Verify(s.Method, s.PubKey, e.DocHash[:], s.Sig); err != nil {
return VerifyResponse{
Verified: false,
FailedSignature: i,
BrokenAuditEvent: -1,
Detail: "signature " + s.SignerHashHex() + ": " + err.Error(),
}, nil
}
}
if broken := e.VerifyChain(); broken != -1 {
return VerifyResponse{
Verified: false,
FailedSignature: -1,
BrokenAuditEvent: broken,
Detail: "audit chain broken at event " + envelope.SeqString(broken),
}, nil
}
return VerifyResponse{
Verified: true,
FailedSignature: -1,
BrokenAuditEvent: -1,
}, nil
}