// Package auth defines the verified principal boundary for Sense HTTP APIs. package auth import ( "context" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "regexp" "strings" ) const ( PermissionDevicesRead = "sense.devices.read" PermissionDevicesWrite = "sense.devices.write" ) var ( ErrUnauthenticated = errors.New("unauthenticated") logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$`) ) type Principal struct { SubjectID string ActorType string TenantID string SiteIDs []string Permissions map[string]struct{} } func (p Principal) AllowsSite(siteID string) bool { for _, allowed := range p.SiteIDs { if allowed == "*" || allowed == siteID { return true } } return false } func (p Principal) Has(permission string) bool { _, ok := p.Permissions[permission] return ok } type Authenticator interface { Authenticate(context.Context, string) (Principal, error) } type staticFile struct { Version int `json:"version"` Principals []staticPrincipal `json:"principals"` } type staticPrincipal struct { TokenSHA256 string `json:"token_sha256"` SubjectID string `json:"subject_id"` ActorType string `json:"actor_type"` TenantID string `json:"tenant_id"` SiteIDs []string `json:"site_ids"` Permissions []string `json:"permissions"` } type staticEntry struct { digest [sha256.Size]byte principal Principal } // StaticSHA256 authenticates opaque tokens against externally provisioned // SHA-256 digests. The source file is read only during process startup. type StaticSHA256 struct { entries []staticEntry } func LoadStaticSHA256(path string) (*StaticSHA256, error) { contents, err := os.ReadFile(path) if err != nil { return nil, errors.New("read static authentication registry") } decoder := json.NewDecoder(strings.NewReader(string(contents))) decoder.DisallowUnknownFields() var document staticFile if err := decoder.Decode(&document); err != nil { return nil, errors.New("decode static authentication registry") } if err := decoder.Decode(&struct{}{}); err != io.EOF { return nil, errors.New("static authentication registry has trailing JSON") } if document.Version != 1 || len(document.Principals) == 0 { return nil, errors.New("static authentication registry must contain version 1 principals") } entries := make([]staticEntry, 0, len(document.Principals)) seenDigests := make(map[string]struct{}, len(document.Principals)) for index, value := range document.Principals { entry, validationErr := parseStaticPrincipal(value) if validationErr != nil { return nil, fmt.Errorf("invalid static authentication principal %d: %w", index, validationErr) } if _, exists := seenDigests[value.TokenSHA256]; exists { return nil, errors.New("duplicate static authentication token digest") } seenDigests[value.TokenSHA256] = struct{}{} entries = append(entries, entry) } return &StaticSHA256{entries: entries}, nil } func parseStaticPrincipal(value staticPrincipal) (staticEntry, error) { var entry staticEntry if len(value.TokenSHA256) != sha256.Size*2 || value.TokenSHA256 != strings.ToLower(value.TokenSHA256) { return entry, errors.New("token_sha256 must be 64 lowercase hexadecimal characters") } digest, err := hex.DecodeString(value.TokenSHA256) if err != nil { return entry, errors.New("token_sha256 must be hexadecimal") } copy(entry.digest[:], digest) if strings.TrimSpace(value.SubjectID) == "" || len(value.SubjectID) > 200 { return entry, errors.New("subject_id must contain 1 to 200 characters") } if value.ActorType != "user" && value.ActorType != "service" { return entry, errors.New("actor_type must be user or service") } if !logicalIDPattern.MatchString(value.TenantID) { return entry, errors.New("tenant_id is invalid") } if len(value.SiteIDs) == 0 { return entry, errors.New("site_ids must not be empty") } sites := make([]string, 0, len(value.SiteIDs)) seenSites := make(map[string]struct{}, len(value.SiteIDs)) for _, siteID := range value.SiteIDs { if siteID != "*" && !logicalIDPattern.MatchString(siteID) { return entry, errors.New("site_ids contains an invalid site") } if _, exists := seenSites[siteID]; exists { return entry, errors.New("site_ids contains a duplicate") } seenSites[siteID] = struct{}{} sites = append(sites, siteID) } permissions := make(map[string]struct{}, len(value.Permissions)) for _, permission := range value.Permissions { if permission != PermissionDevicesRead && permission != PermissionDevicesWrite { return entry, errors.New("permissions contains an unsupported value") } if _, exists := permissions[permission]; exists { return entry, errors.New("permissions contains a duplicate") } permissions[permission] = struct{}{} } if len(permissions) == 0 { return entry, errors.New("permissions must not be empty") } entry.principal = Principal{ SubjectID: value.SubjectID, ActorType: value.ActorType, TenantID: value.TenantID, SiteIDs: sites, Permissions: permissions, } return entry, nil } func (a *StaticSHA256) Authenticate(_ context.Context, token string) (Principal, error) { if len(token) < 22 || len(token) > 4096 { return Principal{}, ErrUnauthenticated } digest := sha256.Sum256([]byte(token)) match := -1 for index := range a.entries { if subtle.ConstantTimeCompare(digest[:], a.entries[index].digest[:]) == 1 { match = index } } if match < 0 { return Principal{}, ErrUnauthenticated } return a.entries[match].principal, nil }