| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307 |
- package acn
- /*
- ArozOS Cluster Node protocol (ACN) - request signing
- Node-to-node requests do not use user sessions. Every request carries a
- set of headers that identify the sending node and an Ed25519 signature
- over the request method, target, cluster, node, timestamp, nonce and body
- hash. Receivers look the sender up in the membership list (through a
- PeerResolver), check the clock skew, reject replayed nonces and verify the
- signature with the sender's published public key.
- Relayed requests (see RelayPrefix) are signed over the target path with the
- relay prefix removed, so both the relaying node and the final node can
- verify the very same signature.
- */
- import (
- "bytes"
- "crypto/ed25519"
- "crypto/rand"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "errors"
- "io"
- "net/http"
- "strconv"
- "strings"
- "sync"
- "time"
- )
- const (
- HeaderNode = "X-Aroz-Node"
- HeaderCluster = "X-Aroz-Cluster"
- HeaderTimestamp = "X-Aroz-Timestamp"
- HeaderNonce = "X-Aroz-Nonce"
- HeaderSignature = "X-Aroz-Signature"
- // BasePath is the URL prefix every ACN endpoint lives under on each node.
- BasePath = "/cluster/acn"
- // RelayPrefix is the prefix used to ask a node to forward a request to a
- // node that is only reachable through a tunnel terminating at that node.
- // The full form is RelayPrefix + "/" + targetNodeID + originalPath.
- RelayPrefix = BasePath + "/relay"
- // MaxClockSkew is the accepted difference between sender and receiver clocks.
- MaxClockSkew = 5 * time.Minute
- // MaxSignedBody limits how much of a request body a verifier will buffer.
- MaxSignedBody = 64 << 20
- )
- var (
- ErrNotSigned = errors.New("request is not signed")
- ErrUnknownNode = errors.New("sender is not a member of this cluster")
- ErrWrongCluster = errors.New("request targets another cluster")
- ErrClockSkew = errors.New("request timestamp outside the accepted window")
- ErrReplay = errors.New("request nonce already seen")
- ErrBadSignature = errors.New("request signature invalid")
- ErrBodyTooLarge = errors.New("request body exceeds signed body limit")
- ErrPeerNoEndpoint = errors.New("peer has no reachable endpoint")
- )
- // Peer is the transport-level view of a cluster member.
- type Peer struct {
- ID string
- Name string
- PublicKey ed25519.PublicKey
- AdvertiseURL string //Base URL where the peer's HTTP server can be reached, empty for NAT-only nodes
- TunnelVia string //Node ID that terminates the peer's tunnel when it has no AdvertiseURL
- }
- // PeerResolver is implemented by the membership layer so the transport can
- // look up public keys and endpoints without importing it.
- type PeerResolver interface {
- ResolvePeer(nodeID string) (*Peer, bool)
- }
- // Signer produces the ACN headers for outgoing requests.
- type Signer struct {
- NodeID string
- ClusterID string
- Key *NodeKey
- }
- // SignedIdentity is what a verifier learns about a valid request.
- type SignedIdentity struct {
- NodeID string
- ClusterID string
- Peer *Peer
- }
- // canonicalString builds the byte string that is signed and verified.
- func canonicalString(method, target, clusterID, nodeID, timestamp, nonce string, bodyHash []byte) []byte {
- var b bytes.Buffer
- b.WriteString(strings.ToUpper(method))
- b.WriteByte('\n')
- b.WriteString(target)
- b.WriteByte('\n')
- b.WriteString(clusterID)
- b.WriteByte('\n')
- b.WriteString(nodeID)
- b.WriteByte('\n')
- b.WriteString(timestamp)
- b.WriteByte('\n')
- b.WriteString(nonce)
- b.WriteByte('\n')
- b.WriteString(hex.EncodeToString(bodyHash))
- return b.Bytes()
- }
- // SignedTarget returns the path (plus query when present) a request is signed over.
- func SignedTarget(path string, rawQuery string) string {
- if rawQuery == "" {
- return path
- }
- return path + "?" + rawQuery
- }
- // StripRelayPrefix removes the relay routing prefix from a request path and
- // returns the target node ID and the original path. ok is false when the path
- // is not a relay path.
- func StripRelayPrefix(path string) (targetNode string, originalPath string, ok bool) {
- if !strings.HasPrefix(path, RelayPrefix+"/") {
- return "", path, false
- }
- rest := strings.TrimPrefix(path, RelayPrefix+"/")
- idx := strings.Index(rest, "/")
- if idx <= 0 {
- return "", path, false
- }
- return rest[:idx], rest[idx:], true
- }
- func newNonce() string {
- buf := make([]byte, 16)
- rand.Read(buf)
- return hex.EncodeToString(buf)
- }
- // Sign adds the ACN authentication headers to req. target must be the path
- // (and query) the receiver will verify against, which for relayed requests is
- // the path without the relay prefix. body is the full request body.
- func (s *Signer) Sign(req *http.Request, target string, body []byte) {
- s.SignAt(req, target, body, time.Now())
- }
- // SignAt is Sign with an explicit timestamp, used by tests.
- func (s *Signer) SignAt(req *http.Request, target string, body []byte, at time.Time) {
- ts := strconv.FormatInt(at.Unix(), 10)
- nonce := newNonce()
- sum := sha256.Sum256(body)
- msg := canonicalString(req.Method, target, s.ClusterID, s.NodeID, ts, nonce, sum[:])
- sig := ed25519.Sign(s.Key.Private, msg)
- req.Header.Set(HeaderNode, s.NodeID)
- req.Header.Set(HeaderCluster, s.ClusterID)
- req.Header.Set(HeaderTimestamp, ts)
- req.Header.Set(HeaderNonce, nonce)
- req.Header.Set(HeaderSignature, base64.StdEncoding.EncodeToString(sig))
- }
- // nonceCache remembers recently seen nonces for replay protection.
- type nonceCache struct {
- mu sync.Mutex
- seen map[string]int64 //nonce -> expiry unix time
- lastGC int64
- ttl int64
- maxSize int
- }
- func newNonceCache(ttl time.Duration) *nonceCache {
- return &nonceCache{
- seen: map[string]int64{},
- ttl: int64(ttl.Seconds()),
- maxSize: 100000,
- }
- }
- // remember returns false when the nonce was already recorded.
- func (c *nonceCache) remember(nonce string, now int64) bool {
- c.mu.Lock()
- defer c.mu.Unlock()
- if now-c.lastGC > 60 || len(c.seen) > c.maxSize {
- for k, exp := range c.seen {
- if exp < now {
- delete(c.seen, k)
- }
- }
- c.lastGC = now
- }
- if _, exists := c.seen[nonce]; exists {
- return false
- }
- c.seen[nonce] = now + c.ttl
- return true
- }
- // Verifier checks incoming ACN requests.
- type Verifier struct {
- ClusterID func() string //Returns the cluster this node belongs to, empty when not in a cluster
- Resolver PeerResolver
- nonces *nonceCache
- Now func() time.Time
- }
- // NewVerifier creates a verifier backed by the given resolver.
- func NewVerifier(clusterID func() string, resolver PeerResolver) *Verifier {
- return &Verifier{
- ClusterID: clusterID,
- Resolver: resolver,
- nonces: newNonceCache(MaxClockSkew * 2),
- Now: time.Now,
- }
- }
- // ReadBody buffers and restores a request body so it can be hashed and then
- // consumed by the handler.
- func ReadBody(r *http.Request) ([]byte, error) {
- if r.Body == nil {
- return []byte{}, nil
- }
- body, err := io.ReadAll(io.LimitReader(r.Body, MaxSignedBody+1))
- if err != nil {
- return nil, err
- }
- if len(body) > MaxSignedBody {
- return nil, ErrBodyTooLarge
- }
- r.Body.Close()
- r.Body = io.NopCloser(bytes.NewReader(body))
- return body, nil
- }
- // Verify validates the ACN headers of r against target (the signed path) and
- // body and returns the identity of the sender. The request body is left
- // readable for the handler.
- func (v *Verifier) Verify(r *http.Request, target string, body []byte) (*SignedIdentity, error) {
- nodeID := r.Header.Get(HeaderNode)
- clusterID := r.Header.Get(HeaderCluster)
- ts := r.Header.Get(HeaderTimestamp)
- nonce := r.Header.Get(HeaderNonce)
- sigEnc := r.Header.Get(HeaderSignature)
- if nodeID == "" || clusterID == "" || ts == "" || nonce == "" || sigEnc == "" {
- return nil, ErrNotSigned
- }
- localCluster := v.ClusterID()
- if localCluster == "" || localCluster != clusterID {
- return nil, ErrWrongCluster
- }
- tsInt, err := strconv.ParseInt(ts, 10, 64)
- if err != nil {
- return nil, ErrClockSkew
- }
- now := v.Now().Unix()
- skew := now - tsInt
- if skew < 0 {
- skew = -skew
- }
- if skew > int64(MaxClockSkew.Seconds()) {
- return nil, ErrClockSkew
- }
- peer, ok := v.Resolver.ResolvePeer(nodeID)
- if !ok || peer == nil || len(peer.PublicKey) != ed25519.PublicKeySize {
- return nil, ErrUnknownNode
- }
- sig, err := base64.StdEncoding.DecodeString(sigEnc)
- if err != nil {
- return nil, ErrBadSignature
- }
- sum := sha256.Sum256(body)
- msg := canonicalString(r.Method, target, clusterID, nodeID, ts, nonce, sum[:])
- if !ed25519.Verify(peer.PublicKey, msg, sig) {
- return nil, ErrBadSignature
- }
- //Signature is valid, only now spend the nonce so bad requests cannot poison the cache
- if !v.nonces.remember(nodeID+":"+nonce, now) {
- return nil, ErrReplay
- }
- return &SignedIdentity{NodeID: nodeID, ClusterID: clusterID, Peer: peer}, nil
- }
- // VerifyRequest is the common entry point for HTTP handlers: it buffers the
- // body, derives the signed target (stripping the relay prefix when present)
- // and verifies the request.
- func (v *Verifier) VerifyRequest(r *http.Request) (*SignedIdentity, []byte, error) {
- body, err := ReadBody(r)
- if err != nil {
- return nil, nil, err
- }
- path := r.URL.Path
- if _, original, ok := StripRelayPrefix(path); ok {
- path = original
- }
- identity, err := v.Verify(r, SignedTarget(path, r.URL.RawQuery), body)
- if err != nil {
- return nil, nil, err
- }
- return identity, body, nil
- }
|