145 lines
4.8 KiB
Go
145 lines
4.8 KiB
Go
package deviceauth
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"crypto/hmac"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/hex"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
HeaderDeviceID = "X-Device-Id"
|
||
|
|
HeaderTimestamp = "X-Device-Timestamp"
|
||
|
|
HeaderNonce = "X-Device-Nonce"
|
||
|
|
HeaderContentSHA256 = "X-Device-Content-SHA256"
|
||
|
|
HeaderSignature = "X-Device-Signature"
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
ErrCredentialsMissing = errors.New("device credentials are missing")
|
||
|
|
ErrCredentialInvalid = errors.New("device credential is invalid")
|
||
|
|
ErrDeviceInactive = errors.New("device is not active")
|
||
|
|
ErrRequestExpired = errors.New("device request timestamp is outside the allowed window")
|
||
|
|
ErrReplayDetected = errors.New("device request nonce was already used")
|
||
|
|
)
|
||
|
|
|
||
|
|
type IdentityRepository interface {
|
||
|
|
FindByPublicID(context.Context, string) (Identity, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type SecretResolver interface {
|
||
|
|
Resolve(context.Context, string) ([]byte, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
type StaticSecretResolver map[string][]byte
|
||
|
|
|
||
|
|
func (r StaticSecretResolver) Resolve(_ context.Context, reference string) ([]byte, error) {
|
||
|
|
secret, ok := r[reference]
|
||
|
|
if !ok || len(secret) < 32 {
|
||
|
|
return nil, ErrCredentialInvalid
|
||
|
|
}
|
||
|
|
return append([]byte(nil), secret...), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type Verifier struct {
|
||
|
|
repository IdentityRepository
|
||
|
|
secrets SecretResolver
|
||
|
|
replay ReplayGuard
|
||
|
|
maxSkew time.Duration
|
||
|
|
now func() time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
func NewVerifier(repository IdentityRepository, secrets SecretResolver, replay ReplayGuard, maxSkew time.Duration) (*Verifier, error) {
|
||
|
|
if repository == nil || secrets == nil || replay == nil || maxSkew <= 0 {
|
||
|
|
return nil, errors.New("device repository, secret resolver, replay guard and positive clock skew are required")
|
||
|
|
}
|
||
|
|
return &Verifier{repository: repository, secrets: secrets, replay: replay, maxSkew: maxSkew, now: time.Now}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (v *Verifier) Verify(request *http.Request) (Identity, error) {
|
||
|
|
deviceID := strings.TrimSpace(request.Header.Get(HeaderDeviceID))
|
||
|
|
timestampText := strings.TrimSpace(request.Header.Get(HeaderTimestamp))
|
||
|
|
nonce := strings.TrimSpace(request.Header.Get(HeaderNonce))
|
||
|
|
contentDigest := strings.ToLower(strings.TrimSpace(request.Header.Get(HeaderContentSHA256)))
|
||
|
|
signatureText := strings.TrimSpace(request.Header.Get(HeaderSignature))
|
||
|
|
if deviceID == "" || timestampText == "" || len(nonce) < 16 || len(nonce) > 128 || contentDigest == "" || signatureText == "" {
|
||
|
|
return Identity{}, ErrCredentialsMissing
|
||
|
|
}
|
||
|
|
timestamp, err := time.Parse(time.RFC3339, timestampText)
|
||
|
|
if err != nil || absoluteDuration(v.now().Sub(timestamp)) > v.maxSkew {
|
||
|
|
return Identity{}, ErrRequestExpired
|
||
|
|
}
|
||
|
|
body, err := io.ReadAll(request.Body)
|
||
|
|
if err != nil {
|
||
|
|
return Identity{}, fmt.Errorf("read signed request body: %w", err)
|
||
|
|
}
|
||
|
|
request.Body.Close()
|
||
|
|
request.Body = io.NopCloser(bytes.NewReader(body))
|
||
|
|
digest := sha256.Sum256(body)
|
||
|
|
actualDigest := hex.EncodeToString(digest[:])
|
||
|
|
if !hmac.Equal([]byte(actualDigest), []byte(contentDigest)) {
|
||
|
|
return Identity{}, ErrCredentialInvalid
|
||
|
|
}
|
||
|
|
identity, err := v.repository.FindByPublicID(request.Context(), deviceID)
|
||
|
|
if err != nil {
|
||
|
|
if errors.Is(err, ErrDeviceNotFound) {
|
||
|
|
return Identity{}, ErrCredentialInvalid
|
||
|
|
}
|
||
|
|
return Identity{}, fmt.Errorf("lookup device identity: %w", err)
|
||
|
|
}
|
||
|
|
if identity.Status != "registered" || identity.TenantID == "" {
|
||
|
|
return Identity{}, ErrDeviceInactive
|
||
|
|
}
|
||
|
|
secret, err := v.secrets.Resolve(request.Context(), identity.CredentialRef)
|
||
|
|
if err != nil {
|
||
|
|
return Identity{}, ErrCredentialInvalid
|
||
|
|
}
|
||
|
|
providedSignature, err := base64.RawURLEncoding.DecodeString(signatureText)
|
||
|
|
if err != nil {
|
||
|
|
return Identity{}, ErrCredentialInvalid
|
||
|
|
}
|
||
|
|
expected := Sign(secret, request.Method, request.URL.RequestURI(), timestampText, nonce, actualDigest)
|
||
|
|
if !hmac.Equal(providedSignature, expected) {
|
||
|
|
return Identity{}, ErrCredentialInvalid
|
||
|
|
}
|
||
|
|
claimed, err := v.replay.Claim(request.Context(), identity.PublicID, nonce, v.maxSkew*2)
|
||
|
|
if err != nil {
|
||
|
|
return Identity{}, fmt.Errorf("claim device request nonce: %w", err)
|
||
|
|
}
|
||
|
|
if !claimed {
|
||
|
|
return Identity{}, ErrReplayDetected
|
||
|
|
}
|
||
|
|
return identity, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func Sign(secret []byte, method, requestURI, timestamp, nonce, contentDigest string) []byte {
|
||
|
|
canonical := strings.Join([]string{strings.ToUpper(method), requestURI, timestamp, nonce, strings.ToLower(contentDigest)}, "\n")
|
||
|
|
mac := hmac.New(sha256.New, secret)
|
||
|
|
_, _ = mac.Write([]byte(canonical))
|
||
|
|
return mac.Sum(nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
func SignatureHeader(secret []byte, method, requestURI, timestamp, nonce, contentDigest string) string {
|
||
|
|
return base64.RawURLEncoding.EncodeToString(Sign(secret, method, requestURI, timestamp, nonce, contentDigest))
|
||
|
|
}
|
||
|
|
|
||
|
|
func EmptyBodyDigest() string {
|
||
|
|
digest := sha256.Sum256(nil)
|
||
|
|
return hex.EncodeToString(digest[:])
|
||
|
|
}
|
||
|
|
|
||
|
|
func absoluteDuration(duration time.Duration) time.Duration {
|
||
|
|
if duration < 0 {
|
||
|
|
return -duration
|
||
|
|
}
|
||
|
|
return duration
|
||
|
|
}
|