feat(admin): add device credential isolation
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusActive = "ACTIVE"
|
||||
StatusRevoked = "REVOKED"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredential = errors.New("invalid device credential input")
|
||||
ErrCredentialNotFound = errors.New("device credential not found")
|
||||
)
|
||||
|
||||
type Credential struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
// IssuedCredential is the only value that can carry the plaintext token. It is returned only
|
||||
// after SQLite has committed the hash and is intended for the management CLI's one stdout write.
|
||||
type IssuedCredential struct {
|
||||
Credential
|
||||
Token string `json:"-"`
|
||||
}
|
||||
|
||||
type CredentialStore struct {
|
||||
database *sql.DB
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
randomMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewCredentialStore(database *sql.DB) (*CredentialStore, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("device credential database is required")
|
||||
}
|
||||
if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil {
|
||||
return nil, errors.New("device credential migration is not available")
|
||||
}
|
||||
return &CredentialStore{database: database, now: time.Now, random: rand.Reader}, nil
|
||||
}
|
||||
|
||||
func (store *CredentialStore) Issue(ctx context.Context, displayName string) (IssuedCredential, error) {
|
||||
if !ValidDisplayName(displayName) {
|
||||
return IssuedCredential{}, ErrInvalidCredential
|
||||
}
|
||||
randomBytes := make([]byte, 16+32)
|
||||
store.randomMu.Lock()
|
||||
_, randomErr := io.ReadFull(store.random, randomBytes)
|
||||
store.randomMu.Unlock()
|
||||
if randomErr != nil {
|
||||
return IssuedCredential{}, fmt.Errorf("generate device credential: %w", randomErr)
|
||||
}
|
||||
deviceID := formatUUIDv4(randomBytes[:16])
|
||||
token := hex.EncodeToString(randomBytes[16:])
|
||||
tokenHash := sha256.Sum256(randomBytes[16:])
|
||||
createdAt := store.now().UTC()
|
||||
if createdAt.IsZero() {
|
||||
return IssuedCredential{}, errors.New("device credential clock is invalid")
|
||||
}
|
||||
_, err := store.database.ExecContext(ctx, `INSERT INTO device_credentials
|
||||
(device_id, display_name, token_sha256, status, created_at, revoked_at)
|
||||
VALUES (?, ?, ?, ?, ?, NULL)`,
|
||||
deviceID, displayName, tokenHash[:], StatusActive, createdAt.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return IssuedCredential{}, fmt.Errorf("persist device credential: %w", err)
|
||||
}
|
||||
return IssuedCredential{Credential: Credential{
|
||||
DeviceID: deviceID, DisplayName: displayName, Status: StatusActive, CreatedAt: createdAt,
|
||||
}, Token: token}, nil
|
||||
}
|
||||
|
||||
func (store *CredentialStore) List(ctx context.Context) ([]Credential, error) {
|
||||
rows, err := store.database.QueryContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at
|
||||
FROM device_credentials ORDER BY created_at, device_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list device credentials: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
credentials := make([]Credential, 0)
|
||||
for rows.Next() {
|
||||
credential, err := scanCredential(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials = append(credentials, credential)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list device credentials: %w", err)
|
||||
}
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func (store *CredentialStore) Revoke(ctx context.Context, deviceID string) (Credential, bool, error) {
|
||||
if !ValidDeviceID(deviceID) {
|
||||
return Credential{}, false, ErrInvalidCredential
|
||||
}
|
||||
revokedAt := store.now().UTC()
|
||||
if revokedAt.IsZero() {
|
||||
return Credential{}, false, errors.New("device credential clock is invalid")
|
||||
}
|
||||
transaction, err := store.database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Credential{}, false, fmt.Errorf("begin device credential revocation: %w", err)
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
result, err := transaction.ExecContext(ctx, `UPDATE device_credentials
|
||||
SET status = ?, revoked_at = ? WHERE device_id = ? AND status = ?`,
|
||||
StatusRevoked, revokedAt.Format(time.RFC3339Nano), deviceID, StatusActive)
|
||||
if err != nil {
|
||||
return Credential{}, false, fmt.Errorf("revoke device credential: %w", err)
|
||||
}
|
||||
changedRows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return Credential{}, false, fmt.Errorf("inspect device credential revocation: %w", err)
|
||||
}
|
||||
credential, err := scanCredential(transaction.QueryRowContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at
|
||||
FROM device_credentials WHERE device_id = ?`, deviceID))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Credential{}, false, ErrCredentialNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Credential{}, false, err
|
||||
}
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return Credential{}, false, fmt.Errorf("commit device credential revocation: %w", err)
|
||||
}
|
||||
return credential, changedRows == 1, nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func scanCredential(row rowScanner) (Credential, error) {
|
||||
var credential Credential
|
||||
var created string
|
||||
var revoked sql.NullString
|
||||
if err := row.Scan(&credential.DeviceID, &credential.DisplayName, &credential.Status, &created, &revoked); err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
if !ValidDeviceID(credential.DeviceID) || !ValidDisplayName(credential.DisplayName) || (credential.Status != StatusActive && credential.Status != StatusRevoked) {
|
||||
return Credential{}, errors.New("stored device credential metadata is invalid")
|
||||
}
|
||||
createdAt, err := parseStoredTime(created)
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
credential.CreatedAt = createdAt
|
||||
if revoked.Valid {
|
||||
revokedAt, err := parseStoredTime(revoked.String)
|
||||
if err != nil {
|
||||
return Credential{}, err
|
||||
}
|
||||
if revokedAt.Before(createdAt) {
|
||||
return Credential{}, errors.New("stored device credential status is invalid")
|
||||
}
|
||||
credential.RevokedAt = &revokedAt
|
||||
}
|
||||
if (credential.Status == StatusActive) != (credential.RevokedAt == nil) {
|
||||
return Credential{}, errors.New("stored device credential status is invalid")
|
||||
}
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
func ValidDisplayName(value string) bool {
|
||||
if value == "" || len([]rune(value)) > 128 || strings.TrimSpace(value) != value {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if unicode.IsControl(character) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseStoredTime(value string) (time.Time, error) {
|
||||
if strings.TrimSpace(value) != value || !strings.HasSuffix(value, "Z") {
|
||||
return time.Time{}, errors.New("stored device credential time is invalid")
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || parsed.Location() != time.UTC {
|
||||
return time.Time{}, errors.New("stored device credential time is invalid")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func formatUUIDv4(bytes []byte) string {
|
||||
copyBytes := append([]byte(nil), bytes...)
|
||||
copyBytes[6] = (copyBytes[6] & 0x0f) | 0x40
|
||||
copyBytes[8] = (copyBytes[8] & 0x3f) | 0x80
|
||||
encoded := hex.EncodeToString(copyBytes)
|
||||
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:]
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Package deviceauth owns the machine identity boundary shared by all device routes.
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthorizationHeader = "Authorization"
|
||||
DeviceIDHeader = "X-CMBuyer-Device-ID"
|
||||
tokenHexLength = 64
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUnauthenticated deliberately covers every credential defect. Callers must not reveal
|
||||
// whether a device exists, is revoked, or supplied a mismatched token.
|
||||
ErrUnauthenticated = errors.New("device authentication failed")
|
||||
// ErrUnavailable is distinct so a storage outage is not disguised as a bad credential.
|
||||
// HTTP callers still return no diagnostic body because database details are server-only.
|
||||
ErrUnavailable = errors.New("device authentication unavailable")
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type Authenticator interface {
|
||||
Authenticate(*http.Request) (Principal, error)
|
||||
}
|
||||
|
||||
// RejectAllAuthenticator is useful for tests and for fail-closed wiring where no credential
|
||||
// store is available. Production startup uses SQLiteAuthenticator.
|
||||
type RejectAllAuthenticator struct{}
|
||||
|
||||
func (RejectAllAuthenticator) Authenticate(*http.Request) (Principal, error) {
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
|
||||
type SQLiteAuthenticator struct {
|
||||
database *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLiteAuthenticator(database *sql.DB) (*SQLiteAuthenticator, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("device credential database is required")
|
||||
}
|
||||
if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil {
|
||||
return nil, errors.New("device credential migration is not available")
|
||||
}
|
||||
return &SQLiteAuthenticator{database: database}, nil
|
||||
}
|
||||
|
||||
func (authenticator *SQLiteAuthenticator) Authenticate(request *http.Request) (Principal, error) {
|
||||
if request == nil {
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
deviceID, token, ok := requestCredentials(request)
|
||||
if !ok {
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
|
||||
candidateHash := sha256.Sum256(token)
|
||||
var storedHash []byte
|
||||
var hashType string
|
||||
var hashLength sql.NullInt64
|
||||
var status sql.NullString
|
||||
var revokedAt sql.NullString
|
||||
var found bool
|
||||
err := authenticator.database.QueryRowContext(
|
||||
request.Context(),
|
||||
`SELECT CASE WHEN credentials.device_id IS NULL THEN zeroblob(32) ELSE credentials.token_sha256 END,
|
||||
typeof(credentials.token_sha256),
|
||||
length(credentials.token_sha256),
|
||||
credentials.status,
|
||||
credentials.revoked_at,
|
||||
credentials.device_id IS NOT NULL
|
||||
FROM (SELECT 1) AS singleton
|
||||
LEFT JOIN device_credentials AS credentials ON credentials.device_id = ?`,
|
||||
deviceID,
|
||||
).Scan(&storedHash, &hashType, &hashLength, &status, &revokedAt, &found)
|
||||
if err != nil {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
if len(storedHash) != sha256.Size {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
matched := subtle.ConstantTimeCompare(candidateHash[:], storedHash) == 1
|
||||
if !found {
|
||||
// The LEFT JOIN supplies a 32-byte dummy hash, so unknown ids take the same compare path
|
||||
// as known credentials without requiring a plaintext token lookup.
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
if hashType != "blob" || !hashLength.Valid || hashLength.Int64 != sha256.Size || len(storedHash) != sha256.Size || !status.Valid {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
switch status.String {
|
||||
case StatusActive:
|
||||
if revokedAt.Valid {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
case StatusRevoked:
|
||||
if !revokedAt.Valid {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
if _, err := parseStoredTime(revokedAt.String); err != nil {
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
default:
|
||||
return Principal{}, ErrUnavailable
|
||||
}
|
||||
if !matched || status.String == StatusRevoked {
|
||||
return Principal{}, ErrUnauthenticated
|
||||
}
|
||||
return Principal{ID: deviceID}, nil
|
||||
}
|
||||
|
||||
func requestCredentials(request *http.Request) (string, []byte, bool) {
|
||||
authorizations := request.Header.Values(AuthorizationHeader)
|
||||
deviceIDs := request.Header.Values(DeviceIDHeader)
|
||||
if len(authorizations) != 1 || len(deviceIDs) != 1 {
|
||||
return "", nil, false
|
||||
}
|
||||
authorization := authorizations[0]
|
||||
if len(authorization) != len("Bearer ")+tokenHexLength || !strings.EqualFold(authorization[:len("Bearer")], "Bearer") || authorization[len("Bearer")] != ' ' {
|
||||
return "", nil, false
|
||||
}
|
||||
tokenHex := authorization[len("Bearer "):]
|
||||
if !validLowerHex(tokenHex, tokenHexLength) || !ValidDeviceID(deviceIDs[0]) {
|
||||
return "", nil, false
|
||||
}
|
||||
token, err := hex.DecodeString(tokenHex)
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
return deviceIDs[0], token, true
|
||||
}
|
||||
|
||||
func ValidDeviceID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
|
||||
func validLowerHex(value string, length int) bool {
|
||||
if len(value) != length {
|
||||
return false
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
return err == nil && hex.EncodeToString(decoded) == value
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package deviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
func TestIssueStoresOnlyRawTokenHashAndGenericJSONOmitsSecret(t *testing.T) {
|
||||
database, store := newCredentialStore(t)
|
||||
first, err := store.Issue(context.Background(), "采购工具一号")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue first: %v", err)
|
||||
}
|
||||
second, err := store.Issue(context.Background(), "采购工具二号")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue second: %v", err)
|
||||
}
|
||||
if first.DeviceID == second.DeviceID || first.Token == second.Token || !ValidDeviceID(first.DeviceID) || !validLowerHex(first.Token, tokenHexLength) {
|
||||
t.Fatalf("issued identifiers are not independent canonical values")
|
||||
}
|
||||
|
||||
rawToken, err := hex.DecodeString(first.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("decode issued token: %v", err)
|
||||
}
|
||||
wantHash := sha256.Sum256(rawToken)
|
||||
var storedHash []byte
|
||||
var storageType string
|
||||
if err := database.QueryRow(`SELECT token_sha256, typeof(token_sha256) FROM device_credentials WHERE device_id = ?`, first.DeviceID).Scan(&storedHash, &storageType); err != nil {
|
||||
t.Fatalf("read stored hash: %v", err)
|
||||
}
|
||||
if storageType != "blob" || len(storedHash) != sha256.Size || !equalBytes(storedHash, wantHash[:]) {
|
||||
t.Fatalf("stored hash type/length/value = %q/%d/%t", storageType, len(storedHash), equalBytes(storedHash, wantHash[:]))
|
||||
}
|
||||
var leakedCopies int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE CAST(token_sha256 AS TEXT) IN (?, ?)`, first.Token, hex.EncodeToString(wantHash[:])).Scan(&leakedCopies); err != nil {
|
||||
t.Fatalf("search token copies: %v", err)
|
||||
}
|
||||
if leakedCopies != 0 {
|
||||
t.Fatal("database stored a plaintext or hex-encoded token/hash copy")
|
||||
}
|
||||
encoded, err := json.Marshal(first)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal issued credential: %v", err)
|
||||
}
|
||||
if strings.Contains(string(encoded), first.Token) || strings.Contains(string(encoded), "token") {
|
||||
t.Fatalf("generic serialization disclosed token field: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateStrictHeaderMatrixAndBinding(t *testing.T) {
|
||||
_, store := newCredentialStore(t)
|
||||
first, err := store.Issue(context.Background(), "one")
|
||||
if err != nil {
|
||||
t.Fatalf("issue first: %v", err)
|
||||
}
|
||||
second, err := store.Issue(context.Background(), "two")
|
||||
if err != nil {
|
||||
t.Fatalf("issue second: %v", err)
|
||||
}
|
||||
authenticator := authenticatorForStore(t, store)
|
||||
|
||||
for _, scheme := range []string{"Bearer", "bearer", "BEARER"} {
|
||||
request := credentialRequest(first.DeviceID, scheme+" "+first.Token)
|
||||
principal, err := authenticator.Authenticate(request)
|
||||
if err != nil || principal.ID != first.DeviceID {
|
||||
t.Fatalf("scheme %q Authenticate = (%q, %v)", scheme, principal.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
unknownID := newRuntimeUUID(t)
|
||||
wrongToken := newRuntimeToken(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*http.Request)
|
||||
}{
|
||||
{"missing authorization", func(request *http.Request) { request.Header.Del(AuthorizationHeader) }},
|
||||
{"missing device", func(request *http.Request) { request.Header.Del(DeviceIDHeader) }},
|
||||
{"empty authorization", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "") }},
|
||||
{"empty device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, "") }},
|
||||
{"duplicate authorization", func(request *http.Request) { request.Header.Add(AuthorizationHeader, "Bearer "+first.Token) }},
|
||||
{"duplicate device", func(request *http.Request) { request.Header.Add(DeviceIDHeader, first.DeviceID) }},
|
||||
{"combined authorization", func(request *http.Request) {
|
||||
request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+", Bearer "+first.Token)
|
||||
}},
|
||||
{"combined device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, first.DeviceID+", "+first.DeviceID) }},
|
||||
{"extra separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token) }},
|
||||
{"tab separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer\t"+first.Token) }},
|
||||
{"uppercase token", func(request *http.Request) {
|
||||
request.Header.Set(AuthorizationHeader, "Bearer "+strings.ToUpper(first.Token))
|
||||
}},
|
||||
{"short token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:62]) }},
|
||||
{"long token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+"00") }},
|
||||
{"non hex token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:63]+"g") }},
|
||||
{"token separator", func(request *http.Request) {
|
||||
request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:32]+"-"+first.Token[33:])
|
||||
}},
|
||||
{"uppercase device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, strings.ToUpper(first.DeviceID)) }},
|
||||
{"padded device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, " "+first.DeviceID) }},
|
||||
{"wrong uuid version", func(request *http.Request) {
|
||||
request.Header.Set(DeviceIDHeader, first.DeviceID[:14]+"3"+first.DeviceID[15:])
|
||||
}},
|
||||
{"wrong uuid variant", func(request *http.Request) {
|
||||
request.Header.Set(DeviceIDHeader, first.DeviceID[:19]+"7"+first.DeviceID[20:])
|
||||
}},
|
||||
{"unknown device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, unknownID) }},
|
||||
{"wrong token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+wrongToken) }},
|
||||
{"token device mismatch", func(request *http.Request) { request.Header.Set(DeviceIDHeader, second.DeviceID) }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
request := credentialRequest(first.DeviceID, "Bearer "+first.Token)
|
||||
test.mutate(request)
|
||||
principal, err := authenticator.Authenticate(request)
|
||||
if !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
|
||||
t.Fatalf("Authenticate = (%#v, %v), want empty unauthenticated", principal, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if principal, err := authenticator.Authenticate(nil); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
|
||||
t.Fatalf("Authenticate(nil) = (%#v, %v)", principal, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeIsImmediateAndIdempotent(t *testing.T) {
|
||||
_, store := newCredentialStore(t)
|
||||
issued, err := store.Issue(context.Background(), "device")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
authenticator := authenticatorForStore(t, store)
|
||||
request := credentialRequest(issued.DeviceID, "Bearer "+issued.Token)
|
||||
if _, err := authenticator.Authenticate(request); err != nil {
|
||||
t.Fatalf("Authenticate before revoke: %v", err)
|
||||
}
|
||||
|
||||
first, changed, err := store.Revoke(context.Background(), issued.DeviceID)
|
||||
if err != nil || !changed || first.Status != StatusRevoked || first.RevokedAt == nil {
|
||||
t.Fatalf("first Revoke = (%#v, %t, %v)", first, changed, err)
|
||||
}
|
||||
if principal, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
|
||||
t.Fatalf("Authenticate after committed revoke = (%#v, %v)", principal, err)
|
||||
}
|
||||
second, changed, err := store.Revoke(context.Background(), issued.DeviceID)
|
||||
if err != nil || changed || second.RevokedAt == nil || !second.RevokedAt.Equal(*first.RevokedAt) {
|
||||
t.Fatalf("second Revoke = (%#v, %t, %v)", second, changed, err)
|
||||
}
|
||||
listed, err := store.List(context.Background())
|
||||
if err != nil || len(listed) != 1 || listed[0].Status != StatusRevoked {
|
||||
t.Fatalf("List = (%#v, %v)", listed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAuthenticationAndRevocation(t *testing.T) {
|
||||
_, store := newCredentialStore(t)
|
||||
issued, err := store.Issue(context.Background(), "concurrent")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
authenticator := authenticatorForStore(t, store)
|
||||
request := func() *http.Request { return credentialRequest(issued.DeviceID, "Bearer "+issued.Token) }
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 16)
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < 16; index++ {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
<-start
|
||||
_, err := authenticator.Authenticate(request())
|
||||
results <- err
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
if _, _, err := store.Revoke(context.Background(), issued.DeviceID); err != nil {
|
||||
t.Fatalf("Revoke: %v", err)
|
||||
}
|
||||
wait.Wait()
|
||||
close(results)
|
||||
for err := range results {
|
||||
if err != nil && !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("concurrent Authenticate error = %v", err)
|
||||
}
|
||||
}
|
||||
for index := 0; index < 16; index++ {
|
||||
if _, err := authenticator.Authenticate(request()); !errors.Is(err, ErrUnauthenticated) {
|
||||
t.Fatalf("post-commit Authenticate %d error = %v", index, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticationDatabaseFaultAndCorruptionAreUnavailable(t *testing.T) {
|
||||
database, store := newCredentialStore(t)
|
||||
issued, err := store.Issue(context.Background(), "device")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
authenticator := authenticatorForStore(t, store)
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("close database: %v", err)
|
||||
}
|
||||
if _, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token)); !errors.Is(err, ErrUnavailable) {
|
||||
t.Fatalf("closed database Authenticate error = %v", err)
|
||||
}
|
||||
|
||||
corruptDB, err := sqlite.Open(filepath.Join(t.TempDir(), "corrupt.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open corrupt database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = corruptDB.Close() })
|
||||
if _, err := corruptDB.Exec(`CREATE TABLE device_credentials (device_id TEXT PRIMARY KEY, token_sha256 BLOB, status TEXT, revoked_at TEXT)`); err != nil {
|
||||
t.Fatalf("create corrupt table: %v", err)
|
||||
}
|
||||
corruptAuthenticator, err := NewSQLiteAuthenticator(corruptDB)
|
||||
if err != nil {
|
||||
t.Fatalf("new corrupt authenticator: %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
hashValue func([sha256.Size]byte) any
|
||||
status string
|
||||
revokedAt any
|
||||
}{
|
||||
{name: "null hash", hashValue: func([sha256.Size]byte) any { return nil }, status: StatusActive},
|
||||
{name: "matching text hash", hashValue: func(hash [sha256.Size]byte) any { return string(hash[:]) }, status: StatusActive},
|
||||
{name: "unknown status", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: "BROKEN"},
|
||||
{name: "active with revoked time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusActive, revokedAt: "2026-08-04T00:00:00Z"},
|
||||
{name: "revoked without time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked},
|
||||
{name: "revoked with invalid time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked, revokedAt: "not-a-time"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rawToken, token := newRuntimeTokenPair(t)
|
||||
hash := sha256.Sum256(rawToken)
|
||||
deviceID := newRuntimeUUID(t)
|
||||
if _, err := corruptDB.Exec(`INSERT INTO device_credentials VALUES (?, ?, ?, ?)`, deviceID, test.hashValue(hash), test.status, test.revokedAt); err != nil {
|
||||
t.Fatalf("insert corrupt row: %v", err)
|
||||
}
|
||||
principal, err := corruptAuthenticator.Authenticate(credentialRequest(deviceID, "Bearer "+token))
|
||||
if !errors.Is(err, ErrUnavailable) || principal != (Principal{}) {
|
||||
t.Fatalf("corrupt Authenticate = (%#v, %v), want unavailable", principal, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialAuthenticationSurvivesDatabaseReopen(t *testing.T) {
|
||||
databaseSource := filepath.Join(t.TempDir(), "reopen.db")
|
||||
database, err := sqlite.Open(databaseSource)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
store, err := NewCredentialStore(database)
|
||||
if err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("new store: %v", err)
|
||||
}
|
||||
issued, err := store.Issue(context.Background(), "reopen")
|
||||
if err != nil {
|
||||
_ = database.Close()
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
if err := database.Close(); err != nil {
|
||||
t.Fatalf("close database: %v", err)
|
||||
}
|
||||
|
||||
reopened, err := sqlite.Open(databaseSource)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen database: %v", err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
authenticator, err := NewSQLiteAuthenticator(reopened)
|
||||
if err != nil {
|
||||
t.Fatalf("new reopened authenticator: %v", err)
|
||||
}
|
||||
principal, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token))
|
||||
if err != nil || principal.ID != issued.DeviceID {
|
||||
t.Fatalf("Authenticate after reopen = (%#v, %v)", principal, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialInputAndMigrationAreRequired(t *testing.T) {
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "unmigrated.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if _, err := NewCredentialStore(database); err == nil {
|
||||
t.Fatal("NewCredentialStore accepted an unmigrated database")
|
||||
}
|
||||
if _, err := NewSQLiteAuthenticator(database); err == nil {
|
||||
t.Fatal("NewSQLiteAuthenticator accepted an unmigrated database")
|
||||
}
|
||||
|
||||
_, store := newCredentialStore(t)
|
||||
for _, name := range []string{"", " leading", "trailing ", "line\nbreak", strings.Repeat("名", 129)} {
|
||||
if _, err := store.Issue(context.Background(), name); !errors.Is(err, ErrInvalidCredential) {
|
||||
t.Fatalf("Issue(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := store.Revoke(context.Background(), "not-a-uuid"); !errors.Is(err, ErrInvalidCredential) {
|
||||
t.Fatalf("Revoke invalid id error = %v", err)
|
||||
}
|
||||
if _, _, err := store.Revoke(context.Background(), newRuntimeUUID(t)); !errors.Is(err, ErrCredentialNotFound) {
|
||||
t.Fatalf("Revoke unknown id error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newCredentialStore(t *testing.T) (*sql.DB, *CredentialStore) {
|
||||
t.Helper()
|
||||
databaseSource := filepath.Join(t.TempDir(), "device-auth.db") + "?_busy_timeout=5000&_journal_mode=WAL"
|
||||
database, err := sqlite.Open(databaseSource)
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
store, err := NewCredentialStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCredentialStore: %v", err)
|
||||
}
|
||||
store.now = func() time.Time { return time.Date(2026, 8, 4, 12, 0, 0, 123, time.UTC) }
|
||||
return database, store
|
||||
}
|
||||
|
||||
func authenticatorForStore(t *testing.T, store *CredentialStore) *SQLiteAuthenticator {
|
||||
t.Helper()
|
||||
authenticator, err := NewSQLiteAuthenticator(store.database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteAuthenticator: %v", err)
|
||||
}
|
||||
return authenticator
|
||||
}
|
||||
|
||||
func credentialRequest(deviceID, authorization string) *http.Request {
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/id/evidence", nil)
|
||||
request.Header.Set(DeviceIDHeader, deviceID)
|
||||
request.Header.Set(AuthorizationHeader, authorization)
|
||||
return request
|
||||
}
|
||||
|
||||
func newRuntimeToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, token := newRuntimeTokenPair(t)
|
||||
return token
|
||||
}
|
||||
|
||||
func newRuntimeTokenPair(t *testing.T) ([]byte, string) {
|
||||
t.Helper()
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
t.Fatalf("generate runtime token: %v", err)
|
||||
}
|
||||
return raw, hex.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func newRuntimeUUID(t *testing.T) string {
|
||||
t.Helper()
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
t.Fatalf("generate runtime UUID: %v", err)
|
||||
}
|
||||
return formatUUIDv4(raw)
|
||||
}
|
||||
|
||||
func equalBytes(left, right []byte) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func deviceMigrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate migrations")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
Reference in New Issue
Block a user