// Package record defines the persisted credential-record contract. package record import ( "encoding/base64" "errors" "fmt" "strings" "time" "unicode" "unicode/utf8" ) type Kind string const ( SchemaVersion = 1 KindV1 Kind = "per_installation_v1" KindLegacyRaw Kind = "legacy_raw" LegacyKeyID = "legacy-shared" ) type Digest [32]byte type Record struct { SchemaVersion int `json:"schema_version"` Kind Kind `json:"credential_kind"` KeyID string `json:"key_id"` InstallationID string `json:"installation_id"` Description string `json:"description,omitempty"` SecretSHA256 string `json:"secret_sha256"` CreatedAt time.Time `json:"created_at"` ExpiresAt *time.Time `json:"expires_at,omitempty"` RevokedAt *time.Time `json:"revoked_at,omitempty"` RevocationReason string `json:"revocation_reason,omitempty"` } // Validate verifies that r conforms to the version 1 persisted-record contract. func Validate(r Record) error { if r.SchemaVersion != SchemaVersion { return fmt.Errorf("unsupported schema version %d", r.SchemaVersion) } if err := validateKindAndKeyID(r.Kind, r.KeyID); err != nil { return err } if !validInstallationID(r.InstallationID) { return errors.New("invalid installation ID") } if err := validateMetadata("description", r.Description); err != nil { return err } if !validDigest(r.SecretSHA256) { return errors.New("invalid secret SHA-256 digest") } if !validTimestamp(r.CreatedAt) { return errors.New("invalid creation timestamp") } if r.ExpiresAt != nil { if !validTimestamp(*r.ExpiresAt) { return errors.New("invalid expiry timestamp") } if !r.ExpiresAt.After(r.CreatedAt) { return errors.New("expiry must be after creation") } } if (r.RevokedAt == nil) != (r.RevocationReason == "") { return errors.New("revocation timestamp and reason must be paired") } if r.RevokedAt != nil && !validTimestamp(*r.RevokedAt) { return errors.New("invalid revocation timestamp") } return validateMetadata("revocation reason", r.RevocationReason) } func validateKindAndKeyID(kind Kind, keyID string) error { switch kind { case KindV1: if !validV1KeyID(keyID) { return errors.New("invalid v1 key ID") } case KindLegacyRaw: if keyID != LegacyKeyID { return errors.New("legacy record must use the legacy key ID") } default: return fmt.Errorf("invalid credential kind %q", kind) } return nil } func validV1KeyID(value string) bool { if len(value) != 16 { return false } decoded, err := base64.RawURLEncoding.DecodeString(value) return err == nil && len(decoded) == 12 && base64.RawURLEncoding.EncodeToString(decoded) == value } func validInstallationID(value string) bool { if len(value) < 1 || len(value) > 63 { return false } for i := range len(value) { c := value[i] if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || (i > 0 && c == '-') { continue } return false } return true } func validDigest(value string) bool { decoded, err := base64.RawURLEncoding.DecodeString(value) return err == nil && len(decoded) == 32 && base64.RawURLEncoding.EncodeToString(decoded) == value } func validTimestamp(value time.Time) bool { return !value.IsZero() && value.Location() == time.UTC } func validateMetadata(name, value string) error { if !utf8.ValidString(value) { return fmt.Errorf("%s is not valid UTF-8", name) } if utf8.RuneCountInString(value) > 160 { return fmt.Errorf("%s exceeds 160 characters", name) } if strings.IndexFunc(value, unicode.IsControl) >= 0 { return fmt.Errorf("%s contains a control character", name) } return nil }