Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,17 @@ func (s *Server) GetIdentityWebhookURL() string {
return s.identity.GetWebhookURL()
}

// SetIdentityWebhookSecret sets the HMAC-SHA256 pre-shared secret for
// identity webhook request/response signing (PILOT-240).
func (s *Server) SetIdentityWebhookSecret(secret string) {
s.identity.SetIdentityWebhookSecret(secret)
}

// GetIdentityWebhookSecret returns the current identity webhook HMAC secret.
func (s *Server) GetIdentityWebhookSecret() string {
return s.identity.GetIdentityWebhookSecret()
}

func (s *Server) provisionCallbacks() identpkg.ProvisionCallbacks {
return identpkg.ProvisionCallbacks{
FindOrCreateNetwork: s.findOrCreateNetwork,
Expand Down
64 changes: 60 additions & 4 deletions identity/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -141,9 +142,10 @@ type Store struct {
nodes NodeView
cb Callbacks

mu sync.RWMutex
identityWebhookURL string
idpConfig *BlueprintIdentityProvider
mu sync.RWMutex
identityWebhookURL string
identityWebhookSecret string
idpConfig *BlueprintIdentityProvider

jwksCache *JWKSCache
}
Expand Down Expand Up @@ -179,11 +181,34 @@ func (st *Store) GetWebhookURL() string {
return st.identityWebhookURL
}

// SetIdentityWebhookSecret sets the HMAC-SHA256 pre-shared secret for
// identity webhook request/response signing (PILOT-240). When non-empty,
// VerifyToken signs outbound requests and verifies response signatures.
func (st *Store) SetIdentityWebhookSecret(secret string) {
st.mu.Lock()
st.identityWebhookSecret = secret
st.mu.Unlock()
}

// GetIdentityWebhookSecret returns the currently configured identity
// webhook HMAC secret.
func (st *Store) GetIdentityWebhookSecret() string {
st.mu.RLock()
defer st.mu.RUnlock()
return st.identityWebhookSecret
}

// VerifyToken sends the token to the configured identity webhook and returns
// the verified external ID. Returns ("", nil) if no webhook is configured.
//
// When a webhook secret is configured, outbound requests carry an
// X-Pilot-Signature-256 HMAC-SHA256 header, and the response MUST include
// a matching X-Pilot-Signature-256 header — unsigned responses are rejected
// (PILOT-240).
func (st *Store) VerifyToken(token string) (string, error) {
st.mu.RLock()
url := st.identityWebhookURL
secret := st.identityWebhookSecret
st.mu.RUnlock()

if url == "" {
Expand All @@ -198,7 +223,21 @@ func (st *Store) VerifyToken(token string) (string, error) {
return "", fmt.Errorf("marshal identity request: %w", err)
}

resp, err := sharedHTTPClient.Post(url, "application/json", bytes.NewReader(body))
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("build identity request: %w", err)
}
req.Header.Set("Content-Type", "application/json")

// HMAC-SHA256 request signing (PILOT-240): when a secret is configured,
// sign the request body so the webhook can verify the caller.
if secret != "" {
reqMac := hmac.New(sha256.New, []byte(secret))
reqMac.Write(body)
req.Header.Set("X-Pilot-Signature-256", hex.EncodeToString(reqMac.Sum(nil)))
}

resp, err := sharedHTTPClient.Do(req)
if err != nil {
slog.Warn("identity webhook request failed", "error", err)
return "", fmt.Errorf("identity verification failed: %w", err)
Expand All @@ -214,6 +253,23 @@ func (st *Store) VerifyToken(token string) (string, error) {
return "", fmt.Errorf("read identity response: %w", err)
}

// HMAC-SHA256 response verification (PILOT-240): when a secret is
// configured, the response MUST carry a valid X-Pilot-Signature-256
// header. Unsigned or mis-signed responses are rejected to prevent
// webhook-spoofing attacks.
if secret != "" {
respSig := resp.Header.Get("X-Pilot-Signature-256")
if respSig == "" {
return "", fmt.Errorf("identity webhook response missing X-Pilot-Signature-256 header")
}
respMac := hmac.New(sha256.New, []byte(secret))
respMac.Write(respBody)
expected := hex.EncodeToString(respMac.Sum(nil))
if !hmac.Equal([]byte(respSig), []byte(expected)) {
return "", fmt.Errorf("identity webhook response signature mismatch")
}
}

var result identityVerifyResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("parse identity response: %w", err)
Expand Down
122 changes: 122 additions & 0 deletions identity/zz_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
package identity

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -278,3 +281,122 @@ func TestJsonUint32_Helper(t *testing.T) {
t.Errorf("non-float: got %d", got)
}
}

// TestStore_SetIdentityWebhookSecret_GetRoundtrip (PILOT-240).
func TestStore_SetIdentityWebhookSecret_GetRoundtrip(t *testing.T) {
t.Parallel()
st := newTestStore()
if got := st.GetIdentityWebhookSecret(); got != "" {
t.Errorf("initial = %q, want empty", got)
}
st.SetIdentityWebhookSecret("my-secret")
if got := st.GetIdentityWebhookSecret(); got != "my-secret" {
t.Errorf("after Set = %q", got)
}
st.SetIdentityWebhookSecret("")
if got := st.GetIdentityWebhookSecret(); got != "" {
t.Errorf("after clear = %q", got)
}
}

// TestStore_VerifyToken_SignsRequest (PILOT-240).
func TestStore_VerifyToken_SignsRequest(t *testing.T) {
t.Parallel()
const secret = "my-secret"
var gotSig string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotSig = r.Header.Get("X-Pilot-Signature-256")
respBody := []byte(`{"verified":true,"external_id":"user-42"}`)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(respBody)
w.Header().Set("X-Pilot-Signature-256", hex.EncodeToString(mac.Sum(nil)))
w.Write(respBody)
}))
defer srv.Close()

st := newTestStore()
st.SetWebhookURL(srv.URL)
st.SetIdentityWebhookSecret(secret)

got, err := st.VerifyToken("my-token")
if err != nil {
t.Fatalf("VerifyToken: %v", err)
}
if got != "user-42" {
t.Errorf("got %q, want user-42", got)
}
if gotSig == "" {
t.Fatal("X-Pilot-Signature-256 request header not set")
}
expectedBody, _ := json.Marshal(identityVerifyRequest{Token: "my-token"})
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(expectedBody)
want := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(gotSig), []byte(want)) {
t.Errorf("request HMAC mismatch: got %s, want %s", gotSig, want)
}
}

// TestStore_VerifyToken_RejectsUnsignedResponse (PILOT-240).
func TestStore_VerifyToken_RejectsUnsignedResponse(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"verified":true,"external_id":"user-42"}`))
}))
defer srv.Close()
st := newTestStore()
st.SetWebhookURL(srv.URL)
st.SetIdentityWebhookSecret("my-secret")
if _, err := st.VerifyToken("my-token"); err == nil {
t.Fatal("expected rejection when response is unsigned")
}
}

// TestStore_VerifyToken_RejectsWrongSignature (PILOT-240).
func TestStore_VerifyToken_RejectsWrongSignature(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
respBody := []byte(`{"verified":true,"external_id":"user-42"}`)
w.Header().Set("X-Pilot-Signature-256", "deadbeef")
w.Write(respBody)
}))
defer srv.Close()
st := newTestStore()
st.SetWebhookURL(srv.URL)
st.SetIdentityWebhookSecret("my-secret")
if _, err := st.VerifyToken("my-token"); err == nil {
t.Fatal("expected rejection when response signature mismatches")
}
}

// TestStore_VerifyToken_NoSignatureWithoutSecret (PILOT-240): backward compat.
func TestStore_VerifyToken_NoSignatureWithoutSecret(t *testing.T) {
t.Parallel()
var gotSig string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotSig = r.Header.Get("X-Pilot-Signature-256")
w.Write([]byte(`{"verified":true,"external_id":"user-42"}`))
}))
defer srv.Close()
st := newTestStore()
st.SetWebhookURL(srv.URL)
got, err := st.VerifyToken("my-token")
if err != nil {
t.Fatalf("VerifyToken: %v", err)
}
if got != "user-42" || gotSig != "" {
t.Errorf("got %q sig=%q, want user-42 sig empty", got, gotSig)
}
}

// TestStore_VerifyToken_WithSecret_EmptyTokenShortCircuits (PILOT-240).
func TestStore_VerifyToken_WithSecret_EmptyTokenShortCircuits(t *testing.T) {
t.Parallel()
st := newTestStore()
st.SetWebhookURL("https://idp/verify")
st.SetIdentityWebhookSecret("my-secret")
got, err := st.VerifyToken("")
if err != nil || got != "" {
t.Errorf("empty token with secret: got (%q, %v)", got, err)
}
}
Loading