mirror of
https://github.com/luxfi/esign.git
synced 2026-10-30 16:58:18 +00:00
This commit lands the first end-to-end signable surface:
* pkg/sign — Signer/Verifier interfaces, Ed25519 implementation
(stdlib crypto/ed25519), method-dispatch helper. 8 unit tests:
round-trip, deterministic-from-seed, bad-seed-length, tampered
payload + sig, bad pubkey, bad sig length, zero-value verifier.
* pkg/envelope — added Signature.SignerHashHex() and SeqString
helper used by the verifier-side error reporting.
* pkg/api — Handler with pluggable Store, in-memory MemStore for
tests + small deployments. CreateRequest / SignRequest /
TransitionRequest / VerifyResponse types form the wire surface.
Handler.Sign runs cryptographic verify BEFORE calling
envelope.AddSignature so a malformed sig never lands in the
envelope. 3 e2e tests cover happy path, sig tampering detection,
and crypto-fails-before-store ordering.
* pkg/api — opcode table (Create/Get/Sign/Transition/Verify/Audit
+ Ping). ZAPServer serves [4B BE length][2B LE opcode][JSON body]
framing with per-conn pipelining + idle timeout. Tests: ping,
end-to-end create→sent→sign→verify, unknown opcode error,
bad-JSON error.
* cmd/esignd — standalone daemon. `--zap :9650` by default.
SIGINT/SIGTERM shutdown drains inflight conns before exit.
Test summary: 3 packages, 22 tests, all green.
205 lines
5.1 KiB
Go
205 lines
5.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"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// ZAPServer serves the eSign RPC surface over the ZAP wire:
|
|
//
|
|
// [4 byte BE length] [2 byte LE opcode] [JSON body]
|
|
//
|
|
// Each request/response pair is one length-prefixed frame in each
|
|
// direction. Connections are persistent — clients pipeline multiple
|
|
// requests on the same socket.
|
|
//
|
|
// ZAPServer is the same shape as luxfi/mpc and luxfi/kms expose, so the
|
|
// downstream ZAP node-discovery + ML-KEM handshake can wrap this server
|
|
// transparently; this package owns only the opcode dispatch + framing.
|
|
type ZAPServer struct {
|
|
addr string
|
|
handler *Handler
|
|
listener net.Listener
|
|
|
|
wg sync.WaitGroup
|
|
mu sync.Mutex
|
|
closed bool
|
|
}
|
|
|
|
// NewZAPServer binds to addr but does not yet Accept. Call Serve to
|
|
// start the accept loop; call Close to tear down cleanly.
|
|
func NewZAPServer(addr string, handler *Handler) (*ZAPServer, error) {
|
|
if handler == nil {
|
|
return nil, errors.New("api: zap server: handler required")
|
|
}
|
|
ln, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &ZAPServer{
|
|
addr: ln.Addr().String(),
|
|
handler: handler,
|
|
listener: ln,
|
|
}, nil
|
|
}
|
|
|
|
// Addr returns the bound listen address. Useful when the caller passed
|
|
// ":0" to get an ephemeral port.
|
|
func (s *ZAPServer) Addr() string { return s.addr }
|
|
|
|
// Serve runs the accept loop. Returns when Close is called.
|
|
func (s *ZAPServer) Serve() error {
|
|
for {
|
|
conn, err := s.listener.Accept()
|
|
if err != nil {
|
|
s.mu.Lock()
|
|
done := s.closed
|
|
s.mu.Unlock()
|
|
if done {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
defer conn.Close()
|
|
s.handleConn(conn)
|
|
}()
|
|
}
|
|
}
|
|
|
|
// Close stops accepting and waits for inflight conns to drain.
|
|
func (s *ZAPServer) Close() error {
|
|
s.mu.Lock()
|
|
s.closed = true
|
|
s.mu.Unlock()
|
|
_ = s.listener.Close()
|
|
s.wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
// handleConn pipelines requests on a single connection until the client
|
|
// closes the socket or sends a malformed frame.
|
|
func (s *ZAPServer) handleConn(conn net.Conn) {
|
|
// Per-conn read deadline kicks in only if the client goes idle mid-frame.
|
|
const idleTimeout = 60 * time.Second
|
|
for {
|
|
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
|
|
|
|
// Length prefix.
|
|
var lenBuf [4]byte
|
|
if _, err := io.ReadFull(conn, lenBuf[:]); err != nil {
|
|
return
|
|
}
|
|
frameLen := binary.BigEndian.Uint32(lenBuf[:])
|
|
if frameLen < 2 || frameLen > 64*1024*1024 {
|
|
return // malformed
|
|
}
|
|
|
|
// Body.
|
|
body := make([]byte, frameLen)
|
|
if _, err := io.ReadFull(conn, body); err != nil {
|
|
return
|
|
}
|
|
op := binary.LittleEndian.Uint16(body[:2])
|
|
payload := body[2:]
|
|
|
|
respBody, respErr := s.dispatch(context.Background(), op, payload)
|
|
|
|
out := encodeResponse(op, respBody, respErr)
|
|
_ = conn.SetWriteDeadline(time.Now().Add(idleTimeout))
|
|
if _, err := conn.Write(out); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// dispatch routes one opcode to its handler. Returns either a JSON-encoded
|
|
// body or an error message; the framer wraps both into the same wire shape.
|
|
func (s *ZAPServer) dispatch(ctx context.Context, op uint16, payload []byte) ([]byte, error) {
|
|
switch op {
|
|
case OpPing:
|
|
return []byte(`{"ok":true}`), nil
|
|
case OpEnvelopeCreate:
|
|
var req CreateRequest
|
|
if err := json.Unmarshal(payload, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := s.handler.Create(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(resp)
|
|
case OpEnvelopeGet:
|
|
var req struct{ ID string `json:"id"` }
|
|
if err := json.Unmarshal(payload, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
e, err := s.handler.Get(ctx, req.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(e)
|
|
case OpEnvelopeSign:
|
|
var req SignRequest
|
|
if err := json.Unmarshal(payload, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := s.handler.Sign(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(resp)
|
|
case OpEnvelopeTransition:
|
|
var req TransitionRequest
|
|
if err := json.Unmarshal(payload, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := s.handler.Transition(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(resp)
|
|
case OpEnvelopeVerify:
|
|
var req struct{ ID string `json:"id"` }
|
|
if err := json.Unmarshal(payload, &req); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := s.handler.Verify(ctx, req.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return json.Marshal(resp)
|
|
default:
|
|
return nil, errors.New("api: zap server: unknown opcode")
|
|
}
|
|
}
|
|
|
|
// encodeResponse builds the reply frame: same length-prefix shape as the
|
|
// request. On error, body is {"error":"..."} so clients can branch.
|
|
func encodeResponse(op uint16, body []byte, err error) []byte {
|
|
if err != nil {
|
|
body, _ = json.Marshal(struct {
|
|
Error string `json:"error"`
|
|
}{err.Error()})
|
|
}
|
|
// 2 byte opcode + body.
|
|
inner := make([]byte, 2+len(body))
|
|
binary.LittleEndian.PutUint16(inner[:2], op)
|
|
copy(inner[2:], body)
|
|
out := make([]byte, 4+len(inner))
|
|
binary.BigEndian.PutUint32(out[:4], uint32(len(inner)))
|
|
copy(out[4:], inner)
|
|
return out
|
|
}
|