122 lines
4.3 KiB
Go
122 lines
4.3 KiB
Go
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)
|
|
}
|