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 }