// 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 }