chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user