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