Files
zeekayandHanzo Dev a20ace0dd0 a request names the channel it is for, and is honoured only there
Key agreement gives two endpoints a private channel. It does not tell
either of them who is on the far end, so a signature on a request proves
who wrote it and not who they wrote it to. Anyone able to agree keys with
both sides can therefore carry a signed request from one channel to
another and be served on it.

The handshake now derives a second value alongside the session key, under
its own label: a binding that names the channel. Only the two endpoints
that ran that handshake can compute it. The caller signs it into every
envelope; the peer checks it against its own side of the channel the
envelope arrived on. A request written for one channel is inert on any
other — it cannot be re-signed without the caller's key, and a channel
cannot be made to produce a name it did not derive.

It is not optional anywhere it could be skipped:

  - NewSession takes the handshake result, so a session always carries
    the binding derived with its key.
  - envelope.Build requires one, so an envelope addressed to nobody
    cannot be signed; Parse requires one, so it cannot be read.
  - envelope.Verify takes the arriving channel as a parameter, so there
    is no way to verify without saying where the request came from.
  - Both transports supply their own: the ZAP wire its session's, the
    HTTP surface the TLS exporter. A connection with no keys of its own
    has no binding and can carry no envelope.

zapclient loses SkipHandshake and RequireSession. With the binding
required, a client without a session has nothing to sign and cannot
speak, so the dial either agrees keys or fails, and a peer that answers
classical-only when ML-KEM-768 was offered is refused outright rather
than by configuration. VerifyEnvelope goes with them: a verify entry
that checked neither the ledger nor the channel had no callers and no
business existing.

Tests: an attacker that terminates and re-originates both halves of the
handshake, built from the shipping client and server, is refused — both
when it forwards the envelope verbatim and when it rewrites the name to
its own. A caller reaching the KMS directly still gets its secret.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-15 04:49:47 -07:00

164 lines
6.1 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// http.go — the HTTP /v1/sdk secrets surface.
//
// This is a thin transport adapter over the SAME verify→authorize→
// dispatch core the ZAP wire uses (verifyAndAuthorize + dispatch). There
// is exactly ONE implementation of "authenticate an enveloped KMS op and
// run it"; ZAP and HTTP are two framings of it.
//
// The surface is a single RPC-style endpoint:
//
// POST /v1/sdk/secrets body: a signed Envelope (see pkg/envelope)
//
// The operation is taken from the SIGNED env.Op field, never from the
// URL. This is deliberate: the read/write authorization decision keys
// off the signed op, so no URL framing can escalate a read identity into
// a write. Every request — get/put/list/delete secret, sign/verify key —
// rides the same signed envelope and the same authorization core.
//
// Op → verb map (env.Op):
//
// OpSecretGet 0x0040 read (validator authority) { path, name, env }
// OpSecretPut 0x0041 write (operator authority) { path, name, env, value } (also the rotate op — upsert)
// OpSecretList 0x0042 read (validator authority) { path, env }
// OpSecretDelete 0x0043 write (operator authority) { path, name, env }
// OpSign 0x0050 write (operator authority) { validator_id, key_type, message }
// OpVerify 0x0051 read (validator authority) { validator_id, key_type, message, signature }
//
// Like the ZAP wire, this transport supplies the binding of the channel
// the request arrived on, and the core honours an envelope only on the
// channel it names. Here that binding is the TLS exporter (RFC 5705).
// A caller reaching this endpoint over plaintext, or through anything
// that terminates TLS on its behalf, is on a different channel than the
// one it agreed keys on, and its envelopes are refused — which is the
// same answer the ZAP wire gives, for the same reason.
package zapserver
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/luxfi/kms/pkg/envelope"
)
// bindLabel is the RFC 5705 exporter label this surface derives its
// channel binding under.
const bindLabel = "EXPORTER-kms-bind"
// channel returns the binding of the connection r arrived on. A
// connection with no keys of its own has no binding, and nil is the
// honest answer: it names no channel, so it can carry no envelope.
func channel(r *http.Request) []byte {
if r.TLS == nil {
return nil
}
bind, err := r.TLS.ExportKeyingMaterial(bindLabel, nil, envelope.BindSize)
if err != nil {
return nil
}
return bind
}
// MaxEnvelopeBytes caps the /v1/sdk request body. A Put envelope carries
// a base64 secret value; 4 MiB is generous for certs / key bundles while
// bounding the parse/alloc DoS surface. Bodies larger than this are
// rejected with 413 before any JSON is parsed.
const MaxEnvelopeBytes = 4 << 20
// HTTPHandler returns the /v1/sdk transport. Mount it on the KMS HTTP
// mux; it owns only the /v1/sdk/* routes.
func (s *Server) HTTPHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/sdk/secrets", s.handleHTTP)
return mux
}
// handleHTTP is the single /v1/sdk/secrets entry. It reads the signed
// envelope, verifies + authorizes it (shared core), dispatches on the
// signed op, and maps the internal status byte to an HTTP status.
func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) {
// Bounded read. LimitReader + 1 lets us detect the over-cap case
// without allocating the whole oversized body.
raw, err := io.ReadAll(io.LimitReader(r.Body, MaxEnvelopeBytes+1))
if err != nil {
httpJSON(w, http.StatusBadRequest, "read body failed")
return
}
if len(raw) > MaxEnvelopeBytes {
httpJSON(w, http.StatusRequestEntityTooLarge, "envelope too large")
return
}
// Structural parse. A malformed envelope is a client error (400) and
// carries no secret, so echoing the shape reason is safe. The op is
// read from the SIGNED field below.
env, err := ParseEnvelope(raw)
if err != nil {
httpJSON(w, http.StatusBadRequest, err.Error())
return
}
// Verify (ML-DSA-65 signature + wall-clock freshness + replay) and
// run the consensus authorizer keyed on the signed op. Every failure
// — bad signature, stale timestamp, replayed nonce, not-authorized —
// collapses to 403. Replay is deliberately vague ("forbidden") so an
// off-network attacker cannot probe the nonce ledger via status; the
// other reasons carry no secret and match the ZAP wire behaviour.
ident, inner, err := s.verifyAndAuthorize(r.Context(), raw, env.Op, channel(r))
if err != nil {
httpJSON(w, http.StatusForbidden, forbidReason(err))
return
}
status, body, err := s.dispatch(r.Context(), ident, env.Op, inner)
if err != nil {
// Handler-internal failure (store/backend). Do NOT leak the
// internal error to the client; audit-log it and return a
// generic 500.
s.log.Warn("kms.sdk handler error", "ident", ident.String(), "op", Op(env.Op).String(), "err", err)
httpJSON(w, http.StatusInternalServerError, "internal error")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpStatusFor(status))
_, _ = w.Write(body)
}
// httpStatusFor maps the internal status byte to an HTTP status code.
func httpStatusFor(status byte) int {
switch status {
case statusOK:
return http.StatusOK
case statusNotFound:
return http.StatusNotFound
case statusForbid:
return http.StatusForbidden
default: // statusError
return http.StatusBadRequest
}
}
// forbidReason returns a wire-safe reason for a verify/authorize
// failure. Replay is masked behind a generic "forbidden" so the nonce
// ledger state is unprobeable; other reasons (stale, not-a-validator,
// not-an-operator, bad-signature) carry no secret and aid the caller.
func forbidReason(err error) string {
if errors.Is(err, ErrEnvelopeReplay) {
return "forbidden"
}
return err.Error()
}
// httpJSON writes {"error": msg} with the given status.
func httpJSON(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}