feat: define DWH installation credentials

This commit is contained in:
User
2026-08-20 23:00:34 +02:00
parent 4ef0a6a833
commit 3bc84b0bc3
5 changed files with 452 additions and 0 deletions
+133
View File
@@ -0,0 +1,133 @@
// 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
}
@@ -0,0 +1,120 @@
package record
import (
"crypto/sha256"
"encoding/base64"
"strings"
"testing"
"time"
)
func validRecord() Record {
created := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC)
sum := sha256.Sum256([]byte("synthetic credential"))
return Record{
SchemaVersion: SchemaVersion,
Kind: KindV1,
KeyID: "AAAAAAAAAAAAAAAA",
InstallationID: "psd-clinical",
Description: "Synthetic test credential",
SecretSHA256: base64.RawURLEncoding.EncodeToString(sum[:]),
CreatedAt: created,
}
}
func TestValidateAcceptsV1AndLegacyRecords(t *testing.T) {
if err := Validate(validRecord()); err != nil {
t.Fatalf("Validate(v1) error = %v", err)
}
legacy := validRecord()
legacy.Kind = KindLegacyRaw
legacy.KeyID = LegacyKeyID
if err := Validate(legacy); err != nil {
t.Fatalf("Validate(legacy) error = %v", err)
}
}
func TestValidateRejectsInvalidKindsAndKeyRelationships(t *testing.T) {
for name, mutate := range map[string]func(*Record){
"unknown kind": func(r *Record) { r.Kind = "other" },
"v1 legacy key": func(r *Record) { r.KeyID = LegacyKeyID },
"v1 malformed key": func(r *Record) { r.KeyID = "short" },
"legacy nonlegacy key": func(r *Record) { r.Kind = KindLegacyRaw },
} {
t.Run(name, func(t *testing.T) {
r := validRecord()
mutate(&r)
if err := Validate(r); err == nil {
t.Fatal("Validate() error = nil, want validation error")
}
})
}
}
func TestValidateRejectsInvalidSchemaIdentifierAndDigest(t *testing.T) {
for name, mutate := range map[string]func(*Record){
"schema": func(r *Record) { r.SchemaVersion = 2 },
"identifier upper": func(r *Record) { r.InstallationID = "PSD" },
"identifier leading dash": func(r *Record) { r.InstallationID = "-psd" },
"identifier too long": func(r *Record) { r.InstallationID = "a" + strings.Repeat("b", 63) },
"digest padding": func(r *Record) { r.SecretSHA256 += "=" },
"digest short": func(r *Record) { r.SecretSHA256 = "AAAA" },
} {
t.Run(name, func(t *testing.T) {
r := validRecord()
mutate(&r)
if err := Validate(r); err == nil {
t.Fatal("Validate() error = nil, want validation error")
}
})
}
}
func TestValidateRejectsInvalidMetadata(t *testing.T) {
for name, mutate := range map[string]func(*Record){
"description control": func(r *Record) { r.Description = "bad\nmetadata" },
"description too long": func(r *Record) { r.Description = strings.Repeat("é", 161) },
"reason control": func(r *Record) {
now := r.CreatedAt.Add(time.Hour)
r.RevokedAt = &now
r.RevocationReason = "bad\tmetadata"
},
"reason too long": func(r *Record) {
now := r.CreatedAt.Add(time.Hour)
r.RevokedAt = &now
r.RevocationReason = strings.Repeat("é", 161)
},
} {
t.Run(name, func(t *testing.T) {
r := validRecord()
mutate(&r)
if err := Validate(r); err == nil {
t.Fatal("Validate() error = nil, want validation error")
}
})
}
}
func TestValidateRejectsInvalidTimesExpiryAndRevocation(t *testing.T) {
for name, mutate := range map[string]func(*Record){
"created non UTC": func(r *Record) { r.CreatedAt = r.CreatedAt.In(time.FixedZone("CET", 3600)) },
"expiry non UTC": func(r *Record) { at := r.CreatedAt.Add(time.Hour).In(time.FixedZone("CET", 3600)); r.ExpiresAt = &at },
"expiry equals created": func(r *Record) { at := r.CreatedAt; r.ExpiresAt = &at },
"revoked without reason": func(r *Record) { at := r.CreatedAt.Add(time.Hour); r.RevokedAt = &at },
"reason without revoked": func(r *Record) { r.RevocationReason = "synthetic" },
"revoked non UTC": func(r *Record) {
at := r.CreatedAt.Add(time.Hour).In(time.FixedZone("CET", 3600))
r.RevokedAt = &at
r.RevocationReason = "synthetic"
},
} {
t.Run(name, func(t *testing.T) {
r := validRecord()
mutate(&r)
if err := Validate(r); err == nil {
t.Fatal("Validate() error = nil, want validation error")
}
})
}
}