169 lines
5.1 KiB
Go
169 lines
5.1 KiB
Go
// 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
|
|
}
|