Files
bf2bbdef1b feat: native /v1/sdk enveloped secrets + threshold-sign plane (red-approved) (#9)
* test(zapserver): fix e2e seed/assert mismatch (secret-value vs sk_live_real)

TestConsensusE2E_ValidatorReadsSecret seeded "secret-value" but asserted
"sk_live_real" — the E2E read path was correct, the assertion constant
disagreed with the seed. Align the seed to the asserted live-secret value
so the green test actually guards the consensus read path.

* feat(zapserver): expose enveloped secret+sign plane over HTTP /v1/sdk

Decomplect the verify->authorize->dispatch core so the ZAP wire and a new
HTTP /v1/sdk transport share ONE implementation (Server.dispatch). Add:

- POST /v1/sdk/secrets: single RPC endpoint. The op is read from the
  SIGNED env.Op field, never the URL, so no framing can escalate a read
  identity into a write. Maps status byte -> HTTP code; replay masked as
  generic 403 'forbidden' (nonce ledger unprobeable); 4 MiB body cap.
- OpSign (0x0050, write/operator) + OpVerify (0x0051, read/validator):
  deliberate, documented widening of the authorizer. Dispatch to a narrow
  SignBackend that delegates to luxfi/mpc t-of-n; KMS holds no key
  material. nil backend -> clear 'signing not configured'.

18 new httptest end-to-end tests: validator read / operator write splits,
replay/stale/tamper/oversize/malformed/unknown-op rejection, and sign
delegation gated BEFORE the backend (forbidden sign never reaches MPC).
All prior authz tests stay green.

* feat(sdksign): keys.Manager -> zapserver.SignBackend adapter

Sign delegates to the MPC t-of-n cluster (KMS holds no full key). Verify
is a local public-key check: ed25519 (corona) via stdlib crypto/ed25519
(real, tested with a live keypair); secp256k1 (bls) returns a precise
ErrVerifyBLSDelegated — a documented capability boundary (secp256k1
verification is owned by the chain/precompile layer), not a fake.

Tests (CGO=0, no MPC daemon needed): sign routes to the correct wallet
per scheme, result propagates; ed25519 valid verifies + tampered
sig/message do NOT; bls verify hits the delegated boundary; unknown
validator errors.

* fix(gomod): realign [email protected] + [email protected] go.sum to re-tagged hashes

Both tags were force-moved (vendor/docs sync lineage, per LLM.md); go.sum
held the pre-re-tag zip hashes, so any GOWORK=off / clean-cache build
(CI) failed 'checksum mismatch' before compiling. Updated the two h1
lines to the authoritative direct-fetch hashes; the GOWORK=off build
re-verifies the downloaded bits against these, which is the proof they
are correct. Never bypassed the check.

* feat(kms): mount /v1/sdk enveloped secrets+sign surface in kmsd

Construct the zapserver.Server once from the loaded REK and back BOTH
transports with ONE consensus authorizer + ONE nonce ledger: the HTTP
/v1/sdk surface (SDK-facing) and the in-cluster ZAP wire. The authorizer
and ledger are built whenever the REK is present (fail-closed: refuse to
boot without them), independent of ZAP_PORT — so /v1/sdk is available
even when the ZAP wire is disabled. Wire the MPC-backed sign/verify via
sdksign.New(mgr) when a vault is configured; nil otherwise.

* docs(llm): document /v1/sdk enveloped secrets+sign surface

---------

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-03 10:37:07 -07:00

122 lines
4.8 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// sign.go — the OpSign / OpVerify threshold-key ops on the /v1/sdk
// surface. Both ride the same signed Envelope as the secret opcodes and
// run through the same verify→authorize→dispatch core: OpSign requires
// the operator (write) authority, OpVerify the validator (read)
// authority. The KMS process NEVER holds full key material — it hands a
// verified request to a SignBackend that delegates to the luxfi/mpc
// t-of-n cluster. A single node cannot produce a signature.
package zapserver
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
)
// SignBackend is the threshold-signing surface OpSign / OpVerify
// dispatch to. Deliberately narrow and transport-free: the zapserver
// verifies the envelope and runs consensus authorization, then hands
// the verified request here. Implementations delegate to the MPC
// t-of-n cluster (luxfi/mpc); they hold no full private key.
type SignBackend interface {
// Sign produces a threshold signature over msg using the named
// validator key. keyType is "bls" or "corona". The t-of-n MPC
// protocol runs across the cluster; no single node can sign.
Sign(ctx context.Context, validatorID, keyType string, msg []byte) (SignResult, error)
// Verify checks sig against the validator's group public key. Pure
// public-key operation — no threshold, no secret material.
Verify(ctx context.Context, validatorID, keyType string, msg, sig []byte) (bool, error)
}
// SignResult is the backend's signature output. Fields mirror the MPC
// daemon's response: Signature for schemes that emit a single blob,
// R/S for the (r,s) ECDSA pair.
type SignResult struct {
Signature string `json:"signature,omitempty"`
R string `json:"r,omitempty"`
S string `json:"s,omitempty"`
}
// errSignerNotConfigured is returned in-band (statusError body) when a
// sign/verify op arrives but no SignBackend was wired. Callers get a
// parseable signal rather than a generic failure.
var errSignerNotConfigured = errors.New("signing not configured")
// isValidKeyType gates the two supported validator key schemes. Anything
// else is rejected before the backend is touched.
func isValidKeyType(kt string) bool { return kt == "bls" || kt == "corona" }
type signReq struct {
ValidatorID string `json:"validator_id"`
KeyType string `json:"key_type"`
Message string `json:"message"` // base64 of the bytes to sign
}
// handleSign runs the threshold-sign op. Auth (operator/write authority)
// has already been enforced by verifyAndAuthorize before this is reached.
func (s *Server) handleSign(ctx context.Context, ident Identity, payload []byte) (byte, []byte, error) {
if s.signer == nil {
return statusError, errJSON(errSignerNotConfigured.Error()), nil
}
var req signReq
if err := json.Unmarshal(payload, &req); err != nil {
return statusError, errJSON(err.Error()), nil
}
if req.ValidatorID == "" || !isValidKeyType(req.KeyType) {
return statusError, errJSON("validator_id and key_type (bls|corona) required"), nil
}
msg, err := base64.StdEncoding.DecodeString(req.Message)
if err != nil || len(msg) == 0 {
return statusError, errJSON("message must be non-empty base64"), nil
}
res, err := s.signer.Sign(ctx, req.ValidatorID, req.KeyType, msg)
if err != nil {
return statusError, nil, err
}
// Audit: who signed what key — never the message or signature bytes.
s.log.Info("kms.sdk sign", "ident", ident.String(), "validator", req.ValidatorID, "key_type", req.KeyType)
b, _ := json.Marshal(res)
return statusOK, b, nil
}
type verifyReq struct {
ValidatorID string `json:"validator_id"`
KeyType string `json:"key_type"`
Message string `json:"message"` // base64
Signature string `json:"signature"` // base64
}
// handleVerify runs the public-key verify op. Read authority (validator)
// has already been enforced. No secret material is touched.
func (s *Server) handleVerify(ctx context.Context, ident Identity, payload []byte) (byte, []byte, error) {
if s.signer == nil {
return statusError, errJSON(errSignerNotConfigured.Error()), nil
}
var req verifyReq
if err := json.Unmarshal(payload, &req); err != nil {
return statusError, errJSON(err.Error()), nil
}
if req.ValidatorID == "" || !isValidKeyType(req.KeyType) {
return statusError, errJSON("validator_id and key_type (bls|corona) required"), nil
}
msg, err := base64.StdEncoding.DecodeString(req.Message)
if err != nil {
return statusError, errJSON("message must be base64"), nil
}
sig, err := base64.StdEncoding.DecodeString(req.Signature)
if err != nil {
return statusError, errJSON("signature must be base64"), nil
}
ok, err := s.signer.Verify(ctx, req.ValidatorID, req.KeyType, msg, sig)
if err != nil {
return statusError, nil, err
}
b, _ := json.Marshal(map[string]bool{"valid": ok})
return statusOK, b, nil
}