mcp and ucp
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
package ucp
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SigningConfig — ECDSA P-256 key for RFC 9421 HTTP Message Signatures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SigningConfig holds the ECDSA P-256 private key and key ID for signing UCP
|
||||
// HTTP responses with RFC 9421 HTTP Message Signatures. The corresponding
|
||||
// public key is published in the UCP profile (signing_keys) and served at
|
||||
// /.well-known/ucp so any UCP platform can verify response authenticity.
|
||||
type SigningConfig struct {
|
||||
PrivateKey *ecdsa.PrivateKey
|
||||
KeyID string // matches the kid in the UCP profile signing_keys
|
||||
}
|
||||
|
||||
// NewSigningConfigFromPEM parses a PEM-encoded ECDSA P-256 private key and
|
||||
// returns a SigningConfig ready for use.
|
||||
//
|
||||
// data — PEM block containing an EC PRIVATE KEY (PKCS#8 or SEC1)
|
||||
// keyID — the kid matched by the UCP profile signing_keys entry
|
||||
func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, error) {
|
||||
block, _ := pem.Decode(pemData)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("ucp signing: no PEM block found")
|
||||
}
|
||||
|
||||
var priv any
|
||||
var err error
|
||||
switch block.Type {
|
||||
case "EC PRIVATE KEY":
|
||||
// SEC1 format (openssl ecparam -genkey -name prime256v1)
|
||||
priv, err = x509.ParseECPrivateKey(block.Bytes)
|
||||
case "PRIVATE KEY":
|
||||
// PKCS#8 format
|
||||
priv, err = x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
default:
|
||||
return nil, fmt.Errorf("ucp signing: unsupported PEM type %q", block.Type)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ucp signing: parse key: %w", err)
|
||||
}
|
||||
|
||||
ecKey, ok := priv.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("ucp signing: key is not ECDSA")
|
||||
}
|
||||
if ecKey.Curve != elliptic.P256() {
|
||||
return nil, fmt.Errorf("ucp signing: key is not P-256 (got %s)", ecKey.Curve.Params().Name)
|
||||
}
|
||||
|
||||
return &SigningConfig{PrivateKey: ecKey, KeyID: keyID}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WithSigning — HTTP middleware wrapper for RFC 9421 response signing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// WithSigning wraps h with an HTTP middleware that adds RFC 9421 HTTP Message
|
||||
// Signatures to every response. The Signature-Input and Signature headers are
|
||||
// computed over @status, content-type, and x-ucp-timestamp, signed with the
|
||||
// configured ECDSA P-256 key.
|
||||
//
|
||||
// Mount it on any existing UCP handler:
|
||||
//
|
||||
// mux.Handle("/ucp/v1/carts", ucp.WithSigning(ucp.CartHandler(pool), cfg))
|
||||
func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler {
|
||||
if cfg == nil || cfg.PrivateKey == nil {
|
||||
return h // pass through without signing
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sw := &signedWriter{
|
||||
ResponseWriter: w,
|
||||
cfg: cfg,
|
||||
}
|
||||
h.ServeHTTP(sw, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// signedWriter — response interceptor that adds signature headers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type signedWriter struct {
|
||||
http.ResponseWriter
|
||||
cfg *SigningConfig
|
||||
statusCode int
|
||||
wroteHeader bool
|
||||
}
|
||||
|
||||
func (w *signedWriter) WriteHeader(statusCode int) {
|
||||
if w.wroteHeader {
|
||||
return
|
||||
}
|
||||
w.wroteHeader = true
|
||||
w.statusCode = statusCode
|
||||
|
||||
created := time.Now().Unix()
|
||||
w.ResponseWriter.Header().Set("x-ucp-timestamp", strconv.FormatInt(created, 10))
|
||||
|
||||
// Add signature headers before flushing.
|
||||
w.addSignatureHeaders(created)
|
||||
|
||||
w.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (w *signedWriter) Write(p []byte) (int, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
|
||||
// addSignatureHeaders computes and writes the Signature-Input and Signature
|
||||
// headers per RFC 9421 §3.2 / §3.3.
|
||||
func (w *signedWriter) addSignatureHeaders(created int64) {
|
||||
contentType := w.ResponseWriter.Header().Get("Content-Type")
|
||||
sigTimestamp := strconv.FormatInt(created, 10)
|
||||
|
||||
// Covered components (in order). RFC 9421 §3.2: derived component names
|
||||
// (@status) are bare identifiers; HTTP header field names are sf-strings
|
||||
// and MUST be quoted.
|
||||
components := []string{
|
||||
"@status",
|
||||
`"content-type"`,
|
||||
`"x-ucp-timestamp"`,
|
||||
}
|
||||
|
||||
// Build the signature base string (RFC 9421 §2.2).
|
||||
var base strings.Builder
|
||||
base.WriteString(fmt.Sprintf(`"@status": %d`, w.statusCode))
|
||||
base.WriteByte('\n')
|
||||
base.WriteString(fmt.Sprintf(`"content-type": %s`, contentType))
|
||||
base.WriteByte('\n')
|
||||
base.WriteString(fmt.Sprintf(`"x-ucp-timestamp": %s`, sigTimestamp))
|
||||
base.WriteByte('\n')
|
||||
|
||||
// Append the signature-params pseudo-line (RFC 9421 §2.2).
|
||||
compList := strings.Join(components, " ")
|
||||
base.WriteString(fmt.Sprintf(`"@signature-params": (%s);created=%d;keyid=%q;alg="ecdsa-p256"`,
|
||||
compList, created, w.cfg.KeyID))
|
||||
|
||||
// Sign the SHA-256 digest.
|
||||
digest := sha256.Sum256([]byte(base.String()))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, w.cfg.PrivateKey, digest[:])
|
||||
if err != nil {
|
||||
// Fail open — response is sent without signature headers.
|
||||
return
|
||||
}
|
||||
|
||||
// ECDSA P-256 signature raw bytes: r||s concatenation, each 32 bytes.
|
||||
rBytes := r.Bytes()
|
||||
sBytes := s.Bytes()
|
||||
rawSig := make([]byte, 64)
|
||||
copy(rawSig[32-len(rBytes):32], rBytes)
|
||||
copy(rawSig[64-len(sBytes):64], sBytes)
|
||||
|
||||
sigB64 := base64.RawURLEncoding.EncodeToString(rawSig)
|
||||
|
||||
// Signature-Input: sig1=(components);created=N;keyid="...";alg="ecdsa-p256"
|
||||
sigInput := fmt.Sprintf(`sig1=(%s);created=%d;keyid=%q;alg="ecdsa-p256"`,
|
||||
compList, created, w.cfg.KeyID)
|
||||
|
||||
// Signature: sig1=:base64url:
|
||||
sigValue := fmt.Sprintf("sig1=:%s:", sigB64)
|
||||
|
||||
w.ResponseWriter.Header().Set("Signature-Input", sigInput)
|
||||
w.ResponseWriter.Header().Set("Signature", sigValue)
|
||||
}
|
||||
Reference in New Issue
Block a user