chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const deviceSecretReferencePrefix = "device-secret://"
|
||||
|
||||
var deviceSecretNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
// DirectorySecretResolver resolves a per-device credential from a controlled
|
||||
// secret mount. It is suitable for Docker/Kubernetes Secrets and cloud KMS CSI
|
||||
// drivers: the database stores only device-secret://<name>, never secret bytes.
|
||||
// Files are read for every verification so an atomic secret-mount rotation is
|
||||
// observed without restarting the API process.
|
||||
type DirectorySecretResolver struct {
|
||||
root string
|
||||
}
|
||||
|
||||
func NewDirectorySecretResolver(root string) (*DirectorySecretResolver, error) {
|
||||
if strings.TrimSpace(root) == "" || !filepath.IsAbs(root) {
|
||||
return nil, errors.New("device secret directory must be an absolute path")
|
||||
}
|
||||
absolute, err := filepath.Abs(filepath.Clean(root))
|
||||
if err != nil {
|
||||
return nil, errors.New("resolve device secret directory")
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(absolute)
|
||||
if err != nil {
|
||||
return nil, errors.New("resolve device secret directory")
|
||||
}
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil || !info.IsDir() {
|
||||
return nil, errors.New("device secret directory is not readable")
|
||||
}
|
||||
return &DirectorySecretResolver{root: resolved}, nil
|
||||
}
|
||||
|
||||
func (r *DirectorySecretResolver) Resolve(ctx context.Context, reference string) ([]byte, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.HasPrefix(reference, deviceSecretReferencePrefix) {
|
||||
return nil, ErrCredentialInvalid
|
||||
}
|
||||
name := strings.TrimPrefix(reference, deviceSecretReferencePrefix)
|
||||
if !deviceSecretNamePattern.MatchString(name) || name == "." || name == ".." {
|
||||
return nil, ErrCredentialInvalid
|
||||
}
|
||||
candidate := filepath.Join(r.root, name)
|
||||
resolved, err := filepath.EvalSymlinks(candidate)
|
||||
if err != nil || !pathWithin(r.root, resolved) {
|
||||
return nil, ErrCredentialInvalid
|
||||
}
|
||||
file, err := os.Open(resolved)
|
||||
if err != nil {
|
||||
return nil, ErrCredentialInvalid
|
||||
}
|
||||
defer file.Close()
|
||||
secret, err := io.ReadAll(io.LimitReader(file, 4097))
|
||||
if err != nil || len(secret) > 4096 {
|
||||
return nil, ErrCredentialInvalid
|
||||
}
|
||||
secret = bytes.TrimRight(secret, "\r\n")
|
||||
if len(secret) < 32 {
|
||||
return nil, ErrCredentialInvalid
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]byte(nil), secret...), nil
|
||||
}
|
||||
|
||||
func pathWithin(root, candidate string) bool {
|
||||
relative, err := filepath.Rel(root, candidate)
|
||||
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDirectorySecretResolverReadsPerDeviceSecretAndObservesRotation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "terminal-001")
|
||||
first := strings.Repeat("a", 40)
|
||||
if err := os.WriteFile(path, []byte(first+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolver, err := NewDirectorySecretResolver(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret, err := resolver.Resolve(context.Background(), "device-secret://terminal-001")
|
||||
if err != nil || string(secret) != first {
|
||||
t.Fatalf("secret=%q err=%v", secret, err)
|
||||
}
|
||||
second := strings.Repeat("b", 48)
|
||||
if err := os.WriteFile(path, []byte(second), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret, err = resolver.Resolve(context.Background(), "device-secret://terminal-001")
|
||||
if err != nil || string(secret) != second {
|
||||
t.Fatalf("rotated secret=%q err=%v", secret, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectorySecretResolverRejectsUnsafeOrInvalidReferences(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "short"), []byte("too-short"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolver, err := NewDirectorySecretResolver(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, reference := range []string{
|
||||
"local-dev://terminal-1", "device-secret://../outside", "device-secret://nested/file", "device-secret://short", "device-secret://missing",
|
||||
} {
|
||||
if _, err := resolver.Resolve(context.Background(), reference); !errors.Is(err, ErrCredentialInvalid) {
|
||||
t.Fatalf("Resolve(%q) error=%v", reference, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectorySecretResolverRequiresExistingAbsoluteDirectory(t *testing.T) {
|
||||
if _, err := NewDirectorySecretResolver("relative/secrets"); err == nil {
|
||||
t.Fatal("relative directory must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package deviceauth
|
||||
|
||||
import "context"
|
||||
|
||||
type contextKey string
|
||||
|
||||
const identityKey contextKey = "device-identity"
|
||||
|
||||
type Identity struct {
|
||||
ID string
|
||||
PublicID string
|
||||
TenantID string
|
||||
Status string
|
||||
CredentialRef string
|
||||
}
|
||||
|
||||
func WithIdentity(ctx context.Context, identity Identity) context.Context {
|
||||
return context.WithValue(ctx, identityKey, identity)
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) (Identity, bool) {
|
||||
identity, ok := ctx.Value(identityKey).(Identity)
|
||||
return identity, ok
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type ReplayGuard interface {
|
||||
Claim(context.Context, string, string, time.Duration) (bool, error)
|
||||
}
|
||||
|
||||
type RedisReplayGuard struct {
|
||||
client *redis.Client
|
||||
prefix string
|
||||
}
|
||||
|
||||
func NewRedisReplayGuard(client *redis.Client, prefix string) *RedisReplayGuard {
|
||||
return &RedisReplayGuard{client: client, prefix: prefix}
|
||||
}
|
||||
|
||||
func (g *RedisReplayGuard) Claim(ctx context.Context, deviceID, nonce string, ttl time.Duration) (bool, error) {
|
||||
digest := sha256.Sum256([]byte(deviceID + "\x00" + nonce))
|
||||
key := g.prefix + ":device-nonce:" + hex.EncodeToString(digest[:16])
|
||||
return g.client.SetNX(ctx, key, "1", ttl).Result()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestRedisReplayGuardClaimsNonceOnce(t *testing.T) {
|
||||
address := os.Getenv("TEST_REDIS_ADDR")
|
||||
if address == "" {
|
||||
t.Skip("TEST_REDIS_ADDR is not set")
|
||||
}
|
||||
client := redis.NewClient(&redis.Options{Addr: address})
|
||||
defer client.Close()
|
||||
guard := NewRedisReplayGuard(client, "voice-platform-test")
|
||||
nonce := time.Now().UTC().Format("20060102T150405.000000000")
|
||||
claimed, err := guard.Claim(context.Background(), "device-test", nonce, time.Minute)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("first claim = %v, err = %v", claimed, err)
|
||||
}
|
||||
claimed, err = guard.Claim(context.Background(), "device-test", nonce, time.Minute)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("second claim = %v, err = %v", claimed, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrDeviceNotFound = errors.New("device identity was not found")
|
||||
|
||||
type Repository struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} }
|
||||
|
||||
func (r *Repository) FindByPublicID(ctx context.Context, publicID string) (Identity, error) {
|
||||
var identity Identity
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT id::text, public_id::text, tenant_id::text, status, credential_ref
|
||||
FROM device
|
||||
WHERE public_id = $1::uuid`, publicID,
|
||||
).Scan(&identity.ID, &identity.PublicID, &identity.TenantID, &identity.Status, &identity.CredentialRef)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Identity{}, ErrDeviceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Identity{}, fmt.Errorf("find device identity: %w", err)
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"data-terminal-cloud/internal/database"
|
||||
)
|
||||
|
||||
func TestRepositoryFindsRegisteredDeviceWithoutCredentialMaterial(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := database.Open(ctx, databaseURL, "device-auth-integration-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
repository := NewRepository(pool)
|
||||
identity, err := repository.FindByPublicID(ctx, "10000000-0000-0000-0000-000000000511")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if identity.Status != "registered" || identity.TenantID != "10000000-0000-0000-0000-000000000001" || identity.CredentialRef == "" {
|
||||
t.Fatalf("identity = %#v", identity)
|
||||
}
|
||||
if _, err := repository.FindByPublicID(ctx, "30000000-0000-0000-0000-000000000511"); !errors.Is(err, ErrDeviceNotFound) {
|
||||
t.Fatalf("missing device error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
identity Identity
|
||||
err error
|
||||
}
|
||||
|
||||
func (r fakeRepository) FindByPublicID(context.Context, string) (Identity, error) {
|
||||
return r.identity, r.err
|
||||
}
|
||||
|
||||
type memoryReplayGuard struct{ claimed map[string]bool }
|
||||
|
||||
func (g *memoryReplayGuard) Claim(_ context.Context, deviceID, nonce string, _ time.Duration) (bool, error) {
|
||||
key := deviceID + nonce
|
||||
if g.claimed[key] {
|
||||
return false, nil
|
||||
}
|
||||
g.claimed[key] = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestVerifierAcceptsValidRequestAndRejectsReplay(t *testing.T) {
|
||||
secret := []byte("01234567890123456789012345678901")
|
||||
now := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC)
|
||||
verifier, err := NewVerifier(
|
||||
fakeRepository{identity: Identity{ID: "internal", PublicID: "device-1", TenantID: "tenant-1", Status: "registered", CredentialRef: "local://one"}},
|
||||
StaticSecretResolver{"local://one": secret},
|
||||
&memoryReplayGuard{claimed: map[string]bool{}},
|
||||
5*time.Minute,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier.now = func() time.Time { return now }
|
||||
request := signedRequest(secret, now, "0123456789abcdef", `{"firmware":"1"}`)
|
||||
identity, err := verifier.Verify(request)
|
||||
if err != nil || identity.PublicID != "device-1" {
|
||||
t.Fatalf("identity = %#v, err = %v", identity, err)
|
||||
}
|
||||
if body := readTestBody(t, request); body != `{"firmware":"1"}` {
|
||||
t.Fatalf("restored body = %q", body)
|
||||
}
|
||||
request = signedRequest(secret, now, "0123456789abcdef", `{"firmware":"1"}`)
|
||||
if _, err := verifier.Verify(request); !errors.Is(err, ErrReplayDetected) {
|
||||
t.Fatalf("replay error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifierRejectsTamperExpiredAndInactive(t *testing.T) {
|
||||
secret := []byte("01234567890123456789012345678901")
|
||||
now := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC)
|
||||
repository := fakeRepository{identity: Identity{PublicID: "device-1", TenantID: "tenant-1", Status: "registered", CredentialRef: "local://one"}}
|
||||
verifier, _ := NewVerifier(repository, StaticSecretResolver{"local://one": secret}, &memoryReplayGuard{claimed: map[string]bool{}}, time.Minute)
|
||||
verifier.now = func() time.Time { return now }
|
||||
|
||||
tampered := signedRequest(secret, now, "0123456789abcdef", `{}`)
|
||||
tampered.Header.Set(HeaderContentSHA256, strings.Repeat("0", 64))
|
||||
if _, err := verifier.Verify(tampered); !errors.Is(err, ErrCredentialInvalid) {
|
||||
t.Fatalf("tamper error = %v", err)
|
||||
}
|
||||
expired := signedRequest(secret, now.Add(-2*time.Minute), "fedcba9876543210", `{}`)
|
||||
if _, err := verifier.Verify(expired); !errors.Is(err, ErrRequestExpired) {
|
||||
t.Fatalf("expired error = %v", err)
|
||||
}
|
||||
verifier.repository = fakeRepository{identity: Identity{PublicID: "device-1", TenantID: "tenant-1", Status: "revoked", CredentialRef: "local://one"}}
|
||||
inactive := signedRequest(secret, now, "abcdef0123456789", `{}`)
|
||||
if _, err := verifier.Verify(inactive); !errors.Is(err, ErrDeviceInactive) {
|
||||
t.Fatalf("inactive error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityContextAndEmptyBodyDigest(t *testing.T) {
|
||||
expected := Identity{ID: "internal", PublicID: "device", TenantID: "tenant", Status: "registered"}
|
||||
if _, ok := FromContext(context.Background()); ok {
|
||||
t.Fatal("empty context must not contain a device identity")
|
||||
}
|
||||
actual, ok := FromContext(WithIdentity(context.Background(), expected))
|
||||
if !ok || actual != expected {
|
||||
t.Fatalf("identity=%+v ok=%t", actual, ok)
|
||||
}
|
||||
digest := sha256.Sum256(nil)
|
||||
if EmptyBodyDigest() != hex.EncodeToString(digest[:]) {
|
||||
t.Fatalf("empty body digest=%s", EmptyBodyDigest())
|
||||
}
|
||||
}
|
||||
|
||||
func signedRequest(secret []byte, timestamp time.Time, nonce, body string) *http.Request {
|
||||
request := httptest.NewRequest(http.MethodPost, "/device/v2/example?x=1", strings.NewReader(body))
|
||||
digest := sha256.Sum256([]byte(body))
|
||||
digestText := hex.EncodeToString(digest[:])
|
||||
timestampText := timestamp.Format(time.RFC3339)
|
||||
request.Header.Set(HeaderDeviceID, "device-1")
|
||||
request.Header.Set(HeaderTimestamp, timestampText)
|
||||
request.Header.Set(HeaderNonce, nonce)
|
||||
request.Header.Set(HeaderContentSHA256, digestText)
|
||||
request.Header.Set(HeaderSignature, SignatureHeader(secret, request.Method, request.URL.RequestURI(), timestampText, nonce, digestText))
|
||||
return request
|
||||
}
|
||||
|
||||
func readTestBody(t *testing.T, request *http.Request) string {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
Reference in New Issue
Block a user