Files

147 lines
4.9 KiB
Go
Raw Permalink Normal View History

2026-08-26 06:41:07 +08:00
package deviceauth
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
2026-09-06 17:04:11 +08:00
"data-terminal-cloud/internal/deviceactivation"
2026-08-26 06:41:07 +08:00
)
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)
}
2026-09-06 17:04:11 +08:00
if identity.Status != "registered" || identity.TenantID == "" || !deviceactivation.AllowsDeviceAuthentication(identity.ActivationState) {
2026-08-26 06:41:07 +08:00
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
}