// Package signer implements the request signature of docs/PROBE-PROTOCOL.md ยง Authentication. // // canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + sha256_hex(raw_body_bytes_as_sent) // signature = lowercase hex HMAC-SHA256(key_bytes, canonical) // // PATH is the request path without query string (e.g. "/ingest/v1/batch"). For gzip bodies the hash covers // the compressed bytes exactly as sent; for GET (no body) it is sha256_hex(""). package signer import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "net/http" "strconv" ) // Header names. const ( HeaderProbe = "X-IP-Probe" HeaderTimestamp = "X-IP-Timestamp" HeaderSignature = "X-IP-Signature" ) // Signer holds a probe identity and its decoded HMAC key. type Signer struct { ProbeID string key []byte } // New decodes the 64-hex-char key. Any even-length hex is accepted, but the server issues 32-byte keys. func New(probeID, hexKey string) (*Signer, error) { if probeID == "" { return nil, errors.New("signer: empty probe_id") } key, err := hex.DecodeString(hexKey) if err != nil { return nil, fmt.Errorf("signer: key is not hex: %w", err) } if len(key) < 16 { return nil, fmt.Errorf("signer: key too short (%d bytes, want 32)", len(key)) } return &Signer{ProbeID: probeID, key: key}, nil } // BodyHash returns sha256_hex(body); body may be nil (GET). func BodyHash(body []byte) string { sum := sha256.Sum256(body) return hex.EncodeToString(sum[:]) } // Canonical builds the string to sign. func Canonical(method, path string, ts int64, body []byte) string { return method + "\n" + path + "\n" + strconv.FormatInt(ts, 10) + "\n" + BodyHash(body) } // Sign returns the lowercase hex signature for the given request parameters. func (s *Signer) Sign(method, path string, ts int64, body []byte) string { mac := hmac.New(sha256.New, s.key) mac.Write([]byte(Canonical(method, path, ts, body))) return hex.EncodeToString(mac.Sum(nil)) } // Apply sets the three authentication headers on req. body must be the exact bytes that will be sent // (already gzip-compressed when Content-Encoding: gzip is used). func (s *Signer) Apply(req *http.Request, ts int64, body []byte) { req.Header.Set(HeaderProbe, s.ProbeID) req.Header.Set(HeaderTimestamp, strconv.FormatInt(ts, 10)) req.Header.Set(HeaderSignature, s.Sign(req.Method, req.URL.Path, ts, body)) } // Verify checks a signature in constant time (used by tests and handy for a Go-side server). func (s *Signer) Verify(method, path string, ts int64, body []byte, sig string) bool { want := s.Sign(method, path, ts, body) return hmac.Equal([]byte(want), []byte(sig)) }