diff --git a/internal/idgen/aws_id_format_test.go b/internal/idgen/aws_id_format_test.go index 469409c1f..501aec299 100644 --- a/internal/idgen/aws_id_format_test.go +++ b/internal/idgen/aws_id_format_test.go @@ -11,17 +11,27 @@ import ( // client-side, so a too-short or wrong-charset id is rejected before the request // is sent (breaking key rotation, tag ops, GetCommandInvocation, etc.). func TestAWSIDFormats(t *testing.T) { + must := func(id string, err error) string { + t.Helper() + + if err != nil { + t.Fatalf("generate id: %v", err) + } + + return id + } + cases := []struct { name string got string re *regexp.Regexp }{ - {"AccessKeyID", idgen.AccessKeyID(), regexp.MustCompile(`^AKIA[A-Z2-7]{16}$`)}, - {"TempAccessKeyID", idgen.TempAccessKeyID(), regexp.MustCompile(`^ASIA[A-Z2-7]{16}$`)}, - {"AppSyncAPIID", idgen.AppSyncAPIID(), regexp.MustCompile(`^[a-z0-9]{26}$`)}, - {"BedrockInferenceProfileID", idgen.BedrockInferenceProfileID(), regexp.MustCompile(`^[a-z0-9]{12}$`)}, - {"BedrockAgentResourceID", idgen.BedrockAgentResourceID(), regexp.MustCompile(`^[0-9A-Z]{10}$`)}, - {"GenerateLongID", idgen.GenerateLongID("svc-"), regexp.MustCompile(`^svc-[0-9a-f]{17}$`)}, + {"AccessKeyID", must(idgen.AccessKeyID()), regexp.MustCompile(`^AKIA[A-Z2-7]{16}$`)}, + {"TempAccessKeyID", must(idgen.TempAccessKeyID()), regexp.MustCompile(`^ASIA[A-Z2-7]{16}$`)}, + {"AppSyncAPIID", must(idgen.AppSyncAPIID()), regexp.MustCompile(`^[a-z0-9]{26}$`)}, + {"BedrockInferenceProfileID", must(idgen.BedrockInferenceProfileID()), regexp.MustCompile(`^[a-z0-9]{12}$`)}, + {"BedrockAgentResourceID", must(idgen.BedrockAgentResourceID()), regexp.MustCompile(`^[0-9A-Z]{10}$`)}, + {"GenerateLongID", must(idgen.GenerateLongID("svc-")), regexp.MustCompile(`^svc-[0-9a-f]{17}$`)}, {"UUID", idgen.UUID(), regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)}, } for _, tc := range cases { @@ -34,14 +44,14 @@ func TestAWSIDFormats(t *testing.T) { // Access key ids must be >= 16 chars total, the minimum the AWS SDKs enforce // client-side before UpdateAccessKey/DeleteAccessKey. - if len(idgen.AccessKeyID()) < 16 { - t.Fatalf("AccessKeyID length %d < 16", len(idgen.AccessKeyID())) + if id := must(idgen.AccessKeyID()); len(id) < 16 { + t.Fatalf("AccessKeyID length %d < 16", len(id)) } // Uniqueness sanity across a batch (crypto/rand-backed). seen := map[string]bool{} for range 100 { - id := idgen.AccessKeyID() + id := must(idgen.AccessKeyID()) if seen[id] { t.Fatalf("duplicate AccessKeyID %q", id) } diff --git a/internal/idgen/credential_test.go b/internal/idgen/credential_test.go new file mode 100644 index 000000000..e21bf701f --- /dev/null +++ b/internal/idgen/credential_test.go @@ -0,0 +1,54 @@ +package idgen_test + +import ( + "regexp" + "testing" + + "github.com/stackshy/cloudemu/v2/internal/idgen" +) + +var ( + secretKeyShape = regexp.MustCompile(`^[A-Za-z0-9+/]{40}$`) + sessionTokenShape = regexp.MustCompile(`^[A-Za-z0-9+/]{300,}$`) + ociAuthTokenShape = regexp.MustCompile(`^[A-Za-z0-9+/:;<>()#_.-]{20}$`) +) + +// TestCredentialSecretsAreRandomAndShaped guards the generators that mint +// signing secrets. They must match the real cloud's shape and never be +// derivable from anything an attacker can observe, such as a shared counter. +func TestCredentialSecretsAreRandomAndShaped(t *testing.T) { + cases := []struct { + name string + gen func() (string, error) + shape *regexp.Regexp + }{ + {"SecretAccessKey", idgen.SecretAccessKey, secretKeyShape}, + {"SessionToken", idgen.SessionToken, sessionTokenShape}, + {"OCIAuthToken", idgen.OCIAuthToken, ociAuthTokenShape}, + } + + const draws = 200 + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + seen := make(map[string]bool, draws) + + for range draws { + got, err := tc.gen() + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + + if !tc.shape.MatchString(got) { + t.Fatalf("%s = %q, want shape %s", tc.name, got, tc.shape) + } + + if seen[got] { + t.Fatalf("%s repeated value %q", tc.name, got) + } + + seen[got] = true + } + }) + } +} diff --git a/internal/idgen/idgen.go b/internal/idgen/idgen.go index bf46bcdc2..af5198d99 100644 --- a/internal/idgen/idgen.go +++ b/internal/idgen/idgen.go @@ -115,24 +115,30 @@ const ( hexLower = "0123456789abcdef" ) -// randString returns n characters drawn from alphabet via crypto/rand. A random -// source failure degrades to a correctly-shaped constant string rather than -// panicking, so callers always get a valid-length id. -func randString(n int, alphabet string) string { +// randString returns n characters drawn from alphabet via crypto/rand. A +// random source failure is returned rather than papered over with a constant, +// so no caller can end up with a predictable id. +func randString(n int, alphabet string) (string, error) { b := make([]byte, n) if _, err := rand.Read(b); err != nil { - for i := range b { - b[i] = alphabet[0] - } - - return string(b) + return "", fmt.Errorf("generate id: %w", err) } for i := range b { b[i] = alphabet[int(b[i])%len(alphabet)] } - return string(b) + return string(b), nil +} + +// prefixed returns prefix followed by n random characters from alphabet. +func prefixed(prefix string, n int, alphabet string) (string, error) { + r, err := randString(n, alphabet) + if err != nil { + return "", err + } + + return prefix + r, nil } // accessKeyRandLen is the number of characters after the AKIA/ASIA prefix in an @@ -144,11 +150,66 @@ const accessKeyRandLen = 16 // shape client-side (minimum length 16) before sending UpdateAccessKey / // DeleteAccessKey, so a shorter id makes key rotation/deletion impossible through // the real tooling. -func AccessKeyID() string { return "AKIA" + randString(accessKeyRandLen, base32Upper) } +func AccessKeyID() (string, error) { return prefixed("AKIA", accessKeyRandLen, base32Upper) } // TempAccessKeyID is the STS temporary-credential variant of AccessKeyID (ASIA // prefix), used for assumed-role / session credentials. -func TempAccessKeyID() string { return "ASIA" + randString(accessKeyRandLen, base32Upper) } +func TempAccessKeyID() (string, error) { return prefixed("ASIA", accessKeyRandLen, base32Upper) } + +// Signing secrets. These authenticate callers, so unlike the ids above they +// never fall back to a predictable value: a crypto/rand failure is returned. +const ( + base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + ociTokenSymbols = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789:;<>()#_.-+/" + + secretAccessKeyLen = 40 + sessionTokenLen = 356 + ociAuthTokenLen = 20 + + byteRange = 256 +) + +// secureString returns n characters drawn uniformly from alphabet via +// crypto/rand. Rejection sampling keeps alphabets whose size does not divide +// 256 free of modulo bias. +func secureString(n int, alphabet string) (string, error) { + // Bytes at or above limit would favor the first 256%len characters. + limit := byteRange - byteRange%len(alphabet) + + out := make([]byte, 0, n) + buf := make([]byte, n) + + for len(out) < n { + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate secret: %w", err) + } + + for _, b := range buf { + if int(b) >= limit { + continue + } + + out = append(out, alphabet[int(b)%len(alphabet)]) + if len(out) == n { + break + } + } + } + + return string(out), nil +} + +// SecretAccessKey returns a 40-character secret drawn from the base64 +// alphabet, the shape of an AWS secret access key. +func SecretAccessKey() (string, error) { return secureString(secretAccessKeyLen, base64Alphabet) } + +// SessionToken returns a long random token from the base64 alphabet, standing +// in for the opaque session token STS issues with temporary credentials. +func SessionToken() (string, error) { return secureString(sessionTokenLen, base64Alphabet) } + +// OCIAuthToken returns a 20-character OCI auth token, which mixes letters, +// digits and punctuation. +func OCIAuthToken() (string, error) { return secureString(ociAuthTokenLen, ociTokenSymbols) } // longIDRandLen is the hex-suffix length AWS's newer resource ids use. const longIDRandLen = 17 @@ -157,7 +218,7 @@ const longIDRandLen = 17 // the length AWS's newer resource ids use (e.g. VPC Lattice svc-/sn-/tg-/rule-). // The SDKs validate these client-side, so the legacy 8-char GenerateID is too // short and is rejected before the request is sent. -func GenerateLongID(prefix string) string { return prefix + randString(longIDRandLen, hexLower) } +func GenerateLongID(prefix string) (string, error) { return prefixed(prefix, longIDRandLen, hexLower) } // appSyncAPIIDLen is the length of an AppSync GraphQL API id. const appSyncAPIIDLen = 26 @@ -165,14 +226,16 @@ const appSyncAPIIDLen = 26 // AppSyncAPIID returns a 26-character lowercase-alphanumeric id matching the shape // AppSync mints for a GraphQL API. The SDKs embed it in ARNs the CLI validates, so // the legacy 8-char id breaks TagResource/ListTagsForResource client-side. -func AppSyncAPIID() string { return randString(appSyncAPIIDLen, lowerAlphaNum) } +func AppSyncAPIID() (string, error) { return randString(appSyncAPIIDLen, lowerAlphaNum) } // bedrockProfileIDLen is the length of a Bedrock application inference profile id. const bedrockProfileIDLen = 12 // BedrockInferenceProfileID returns a 12-character lowercase-alphanumeric id, // the shape Bedrock mints for an application inference profile. -func BedrockInferenceProfileID() string { return randString(bedrockProfileIDLen, lowerAlphaNum) } +func BedrockInferenceProfileID() (string, error) { + return randString(bedrockProfileIDLen, lowerAlphaNum) +} // bedrockAgentIDLen is the length of every Bedrock Agents resource id. const bedrockAgentIDLen = 10 @@ -181,7 +244,7 @@ const bedrockAgentIDLen = 10 // shape Bedrock Agents mints for agents, aliases, knowledge bases, data sources, // ingestion jobs, flows and prompts. Their ARNs embed it and the tagging API's // ARN pattern requires exactly this shape. -func BedrockAgentResourceID() string { return randString(bedrockAgentIDLen, upperAlphaNum) } +func BedrockAgentResourceID() (string, error) { return randString(bedrockAgentIDLen, upperAlphaNum) } // ARN generates an AWS ARN. func ARN(partition, service, region, accountID, resource string) string { diff --git a/providers/aws/appsync/apikey.go b/providers/aws/appsync/apikey.go index 7ed5f6776..aadb6f6a1 100644 --- a/providers/aws/appsync/apikey.go +++ b/providers/aws/appsync/apikey.go @@ -34,8 +34,13 @@ func (m *Mock) CreateAPIKey(_ context.Context, in *driver.CreateAPIKeyInput) (*d return nil, err } + id, err := newAPIKeyID() + if err != nil { + return nil, err + } + key := driver.APIKey{ - ID: newAPIKeyID(), + ID: id, Description: in.Description, Expires: expires, Deletes: deletes, @@ -125,8 +130,13 @@ func (m *Mock) DeleteAPIKey(_ context.Context, apiID, id string) error { // newAPIKeyID mints a da2 API-key id: "da2-" plus 26 lowercase alphanumeric // characters, the same random shape AppSync uses for an apiId. -func newAPIKeyID() string { - return apiKeyIDPrefix + idgen.AppSyncAPIID() +func newAPIKeyID() (string, error) { + id, err := idgen.AppSyncAPIID() + if err != nil { + return "", err + } + + return apiKeyIDPrefix + id, nil } // computeExpiry resolves an API-key expiry (epoch seconds) and its deletion diff --git a/providers/aws/appsync/appsync.go b/providers/aws/appsync/appsync.go index 465003c84..8eaed2766 100644 --- a/providers/aws/appsync/appsync.go +++ b/providers/aws/appsync/appsync.go @@ -50,7 +50,7 @@ func (m *Mock) now() time.Time { // newAPIID mints a fresh, stable API id. Generated once at create and never // regenerated, so the id (and the ARN and URIs derived from it) never drifts. -func newAPIID() string { +func newAPIID() (string, error) { return idgen.AppSyncAPIID() } diff --git a/providers/aws/appsync/graphqlapi.go b/providers/aws/appsync/graphqlapi.go index 858539976..505a329a4 100644 --- a/providers/aws/appsync/graphqlapi.go +++ b/providers/aws/appsync/graphqlapi.go @@ -31,7 +31,11 @@ func (m *Mock) CreateGraphqlAPI(_ context.Context, in *driver.CreateGraphqlAPIIn return nil, err } - apiID := newAPIID() + apiID, err := newAPIID() + if err != nil { + return nil, err + } + api := driver.GraphqlAPI{ APIID: apiID, diff --git a/providers/aws/bedrock/registries.go b/providers/aws/bedrock/registries.go index 6d40c664e..576ffc000 100644 --- a/providers/aws/bedrock/registries.go +++ b/providers/aws/bedrock/registries.go @@ -37,7 +37,12 @@ func (m *Mock) CreateInferenceProfile(_ context.Context, cfg driver.InferencePro } now := m.now() - id := idgen.BedrockInferenceProfileID() + + id, err := idgen.BedrockInferenceProfileID() + if err != nil { + return nil, err + } + arn := idgen.AWSARN("bedrock", m.opts.Region, m.opts.AccountID, "application-inference-profile/"+id) profile := &driver.InferenceProfile{ diff --git a/providers/aws/bedrockagent/agents.go b/providers/aws/bedrockagent/agents.go index 8eb673738..611489a4c 100644 --- a/providers/aws/bedrockagent/agents.go +++ b/providers/aws/bedrockagent/agents.go @@ -24,7 +24,11 @@ func (m *Mock) CreateAgent(_ context.Context, cfg driver.AgentConfig) (*driver.A return nil, err } - id := newID(m.agents) + id, err := newID(m.agents) + if err != nil { + return nil, err + } + now := m.now() ttl := cfg.IdleSessionTTLInSeconds @@ -180,7 +184,11 @@ func (m *Mock) CreateAgentAlias(_ context.Context, cfg driver.AgentAliasConfig) return nil, errors.Newf(errors.NotFound, "agent %q not found", cfg.AgentID) } - id := newID(m.aliases) + id, err := newID(m.aliases) + if err != nil { + return nil, err + } + now := m.now() alias := &driver.AgentAlias{ ID: id, diff --git a/providers/aws/bedrockagent/data_sources.go b/providers/aws/bedrockagent/data_sources.go index 2aad2a54f..9061960a8 100644 --- a/providers/aws/bedrockagent/data_sources.go +++ b/providers/aws/bedrockagent/data_sources.go @@ -20,7 +20,11 @@ func (m *Mock) CreateDataSource(_ context.Context, cfg driver.DataSourceConfig) return nil, errors.Newf(errors.NotFound, "knowledge base %q not found", cfg.KnowledgeBaseID) } - id := newID(m.dataSource) + id, err := newID(m.dataSource) + if err != nil { + return nil, err + } + now := m.now() ds := &driver.DataSource{ ID: id, @@ -116,7 +120,11 @@ func (m *Mock) StartIngestionJob(_ context.Context, kbID, dsID, description stri return nil, errors.Newf(errors.NotFound, "data source %q not found", dsID) } - id := newID(m.jobs) + id, err := newID(m.jobs) + if err != nil { + return nil, err + } + now := m.now() job := &driver.IngestionJob{ ID: id, diff --git a/providers/aws/bedrockagent/flows.go b/providers/aws/bedrockagent/flows.go index d8081f79d..d21f469e1 100644 --- a/providers/aws/bedrockagent/flows.go +++ b/providers/aws/bedrockagent/flows.go @@ -20,7 +20,11 @@ func (m *Mock) CreateFlow(_ context.Context, cfg driver.FlowConfig) (*driver.Flo return nil, err } - id := newID(m.flows) + id, err := newID(m.flows) + if err != nil { + return nil, err + } + now := m.now() flow := &driver.Flow{ ID: id, diff --git a/providers/aws/bedrockagent/knowledge_bases.go b/providers/aws/bedrockagent/knowledge_bases.go index 9abd17bc0..7cf4ed3a5 100644 --- a/providers/aws/bedrockagent/knowledge_bases.go +++ b/providers/aws/bedrockagent/knowledge_bases.go @@ -20,7 +20,11 @@ func (m *Mock) CreateKnowledgeBase(_ context.Context, cfg driver.KnowledgeBaseCo return nil, err } - id := newID(m.knowledge) + id, err := newID(m.knowledge) + if err != nil { + return nil, err + } + now := m.now() kb := &driver.KnowledgeBase{ ID: id, diff --git a/providers/aws/bedrockagent/prompts.go b/providers/aws/bedrockagent/prompts.go index 4955f6dcd..08bbccd69 100644 --- a/providers/aws/bedrockagent/prompts.go +++ b/providers/aws/bedrockagent/prompts.go @@ -20,7 +20,11 @@ func (m *Mock) CreatePrompt(_ context.Context, cfg driver.PromptConfig) (*driver return nil, err } - id := newID(m.prompts) + id, err := newID(m.prompts) + if err != nil { + return nil, err + } + now := m.now() prompt := &driver.Prompt{ ID: id, diff --git a/providers/aws/bedrockagent/validation.go b/providers/aws/bedrockagent/validation.go index 592cf1f0e..54157561e 100644 --- a/providers/aws/bedrockagent/validation.go +++ b/providers/aws/bedrockagent/validation.go @@ -129,11 +129,15 @@ func paginate[T any](items []T, page driver.Page) (pageItems []T, nextToken stri } // newID mints a 10-character bedrock-agent id not already used in store. -func newID[V any](store *memstore.Store[V]) string { +func newID[V any](store *memstore.Store[V]) (string, error) { for { - id := idgen.BedrockAgentResourceID() + id, err := idgen.BedrockAgentResourceID() + if err != nil { + return "", err + } + if !store.Has(id) { - return id + return id, nil } } } diff --git a/providers/aws/iam/access_key_secret_test.go b/providers/aws/iam/access_key_secret_test.go new file mode 100644 index 000000000..4ca311c97 --- /dev/null +++ b/providers/aws/iam/access_key_secret_test.go @@ -0,0 +1,77 @@ +package iam + +import ( + "context" + "fmt" + "regexp" + "strconv" + "testing" + + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +var ( + accessKeyIDShape = regexp.MustCompile(`^AKIA[A-Z2-7]{16}$`) + secretAccessKeyShape = regexp.MustCompile(`^[A-Za-z0-9+/]{40}$`) +) + +// counterGuesses returns the secrets an attacker would try if secrets were +// built from the shared id counter: every "secret-%08x" up to the counter's +// current value, going back window steps. +func counterGuesses(window uint64) map[string]bool { + probe, err := strconv.ParseUint(idgen.GenerateID(""), 16, 64) + if err != nil { + panic(err) + } + + out := make(map[string]bool, window) + for n := probe - min(window, probe); n <= probe; n++ { + out[fmt.Sprintf("secret-%08x", n)] = true + } + + return out +} + +func TestCreateAccessKeySecretIsRandom(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateUser(ctx, driver.UserConfig{Name: "erin"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + first, err := m.CreateAccessKey(ctx, driver.AccessKeyConfig{UserName: "erin"}) + if err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + + second, err := m.CreateAccessKey(ctx, driver.AccessKeyConfig{UserName: "erin"}) + if err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + + guesses := counterGuesses(64) + + for _, ak := range []*driver.AccessKeyInfo{first, second} { + if !accessKeyIDShape.MatchString(ak.AccessKeyID) { + t.Fatalf("access key id %q, want AKIA plus 16 base32 chars", ak.AccessKeyID) + } + + if !secretAccessKeyShape.MatchString(ak.SecretAccessKey) { + t.Fatalf("secret %q, want 40 base64-alphabet chars", ak.SecretAccessKey) + } + + if guesses[ak.SecretAccessKey] { + t.Fatalf("secret %q is derivable from the id counter", ak.SecretAccessKey) + } + } + + if first.SecretAccessKey == second.SecretAccessKey { + t.Fatal("two access keys share a secret") + } + + if first.AccessKeyID == second.AccessKeyID { + t.Fatal("two access keys share an id") + } +} diff --git a/providers/aws/iam/iam.go b/providers/aws/iam/iam.go index a1c0e8cc4..fc7599f1f 100644 --- a/providers/aws/iam/iam.go +++ b/providers/aws/iam/iam.go @@ -1246,8 +1246,17 @@ func (m *Mock) CreateAccessKey( "Cannot exceed quota for AccessKeysPerUser: %d", maxAccessKeysPerUser) } - keyID := idgen.AccessKeyID() - secret := fmt.Sprintf("secret-%s", idgen.GenerateID("")) + keyID, err := m.newAccessKeyID() + if err != nil { + return nil, err + } + + // The secret signs SigV4 requests, so it must be unguessable even to a + // caller who knows the key id. + secret, err := idgen.SecretAccessKey() + if err != nil { + return nil, errors.Newf(errors.Internal, "generate access key secret: %v", err) + } ak := &accessKeyData{ AccessKeyID: keyID, @@ -1263,6 +1272,26 @@ func (m *Mock) CreateAccessKey( return &info, nil } +// maxKeyIDAttempts bounds the retries when a freshly drawn access key id is +// already taken. With 80 random bits a single collision is already unlikely. +const maxKeyIDAttempts = 5 + +// newAccessKeyID draws an AKIA id not already held by another key. +func (m *Mock) newAccessKeyID() (string, error) { + for range maxKeyIDAttempts { + id, err := idgen.AccessKeyID() + if err != nil { + return "", errors.Newf(errors.Internal, "generate access key id: %v", err) + } + + if !m.accessKeys.Has(id) { + return id, nil + } + } + + return "", errors.Newf(errors.Internal, "could not generate a unique access key id") +} + // DeleteAccessKey deletes an access key. func (m *Mock) DeleteAccessKey( _ context.Context, userName, accessKeyID string, diff --git a/providers/aws/vpclattice/access_logs.go b/providers/aws/vpclattice/access_logs.go index 922488927..ec1fa519e 100644 --- a/providers/aws/vpclattice/access_logs.go +++ b/providers/aws/vpclattice/access_logs.go @@ -20,7 +20,11 @@ func (m *Mock) CreateAccessLogSubscription( m.mu.Lock() defer m.mu.Unlock() - id := idgen.GenerateLongID("als-") + id, err := idgen.GenerateLongID("als-") + if err != nil { + return nil, err + } + a := &driver.AccessLogSubscription{ ID: id, ARN: m.arn("accesslogsubscription/" + id), diff --git a/providers/aws/vpclattice/associations.go b/providers/aws/vpclattice/associations.go index 6a8bd1d6e..7cebe29cf 100644 --- a/providers/aws/vpclattice/associations.go +++ b/providers/aws/vpclattice/associations.go @@ -53,7 +53,11 @@ func (m *Mock) CreateSNVpcAssociation( return nil, err } - id := idgen.GenerateLongID("snva-") + id, err := idgen.GenerateLongID("snva-") + if err != nil { + return nil, err + } + a := &driver.SNVpcAssociation{ ID: id, ARN: m.arn("servicenetworkvpcassociation/" + id), @@ -161,7 +165,11 @@ func (m *Mock) CreateSNServiceAssociation( return nil, serviceNotFound(idFromIdentifier(serviceID)) } - id := idgen.GenerateLongID("snsa-") + id, err := idgen.GenerateLongID("snsa-") + if err != nil { + return nil, err + } + a := &driver.SNServiceAssociation{ ID: id, ARN: m.arn("servicenetworkserviceassociation/" + id), @@ -237,7 +245,12 @@ func (m *Mock) CreateSNResourceAssociation( } rcID := idFromIdentifier(resourceConfigID) - id := idgen.GenerateLongID("snra-") + + id, err := idgen.GenerateLongID("snra-") + if err != nil { + return nil, err + } + a := &driver.SNResourceAssociation{ ID: id, ARN: m.arn("servicenetworkresourceassociation/" + id), diff --git a/providers/aws/vpclattice/domain_verifications.go b/providers/aws/vpclattice/domain_verifications.go index 77777b927..c8dfeae6f 100644 --- a/providers/aws/vpclattice/domain_verifications.go +++ b/providers/aws/vpclattice/domain_verifications.go @@ -22,7 +22,11 @@ func (m *Mock) StartDomainVerification( m.mu.Lock() defer m.mu.Unlock() - id := idgen.GenerateLongID("dv-") + id, err := idgen.GenerateLongID("dv-") + if err != nil { + return nil, err + } + d := &driver.DomainVerification{ ID: id, ARN: m.arn("domainverification/" + id), diff --git a/providers/aws/vpclattice/listeners.go b/providers/aws/vpclattice/listeners.go index c54091019..28aa5b14f 100644 --- a/providers/aws/vpclattice/listeners.go +++ b/providers/aws/vpclattice/listeners.go @@ -42,7 +42,12 @@ func (m *Mock) CreateListener(_ context.Context, in *driver.CreateListenerInput) } sid := idFromIdentifier(in.ServiceID) - id := idgen.GenerateLongID("listener-") + + id, err := idgen.GenerateLongID("listener-") + if err != nil { + return nil, err + } + l := &driver.Listener{ ID: id, ARN: m.arn("service/" + sid + "/listener/" + id), diff --git a/providers/aws/vpclattice/resource_configs.go b/providers/aws/vpclattice/resource_configs.go index ef74a2bae..700b861ec 100644 --- a/providers/aws/vpclattice/resource_configs.go +++ b/providers/aws/vpclattice/resource_configs.go @@ -28,7 +28,11 @@ func (m *Mock) CreateResourceConfiguration( m.mu.Lock() defer m.mu.Unlock() - id := idgen.GenerateLongID("rc-") + id, err := idgen.GenerateLongID("rc-") + if err != nil { + return nil, err + } + c := &driver.ResourceConfiguration{ ID: id, ARN: m.arn("resourceconfiguration/" + id), diff --git a/providers/aws/vpclattice/resource_gateways.go b/providers/aws/vpclattice/resource_gateways.go index 93a412dcc..8f7fa7f3f 100644 --- a/providers/aws/vpclattice/resource_gateways.go +++ b/providers/aws/vpclattice/resource_gateways.go @@ -26,7 +26,11 @@ func (m *Mock) CreateResourceGateway( m.mu.Lock() defer m.mu.Unlock() - id := idgen.GenerateLongID("rg-") + id, err := idgen.GenerateLongID("rg-") + if err != nil { + return nil, err + } + g := &driver.ResourceGateway{ ID: id, ARN: m.arn("resourcegateway/" + id), diff --git a/providers/aws/vpclattice/rules.go b/providers/aws/vpclattice/rules.go index 358301732..1934c445c 100644 --- a/providers/aws/vpclattice/rules.go +++ b/providers/aws/vpclattice/rules.go @@ -29,7 +29,11 @@ func (m *Mock) CreateRule(_ context.Context, in *driver.CreateRuleInput) (*drive return nil, err } - id := idgen.GenerateLongID("rule-") + id, err := idgen.GenerateLongID("rule-") + if err != nil { + return nil, err + } + rule := &driver.Rule{ ID: id, ARN: m.arn("service/" + l.ServiceID + "/listener/" + l.ID + "/rule/" + id), diff --git a/providers/aws/vpclattice/service_networks.go b/providers/aws/vpclattice/service_networks.go index edfb40887..0d1766119 100644 --- a/providers/aws/vpclattice/service_networks.go +++ b/providers/aws/vpclattice/service_networks.go @@ -55,7 +55,11 @@ func (m *Mock) CreateServiceNetwork( authType = authTypeNone } - id := idgen.GenerateLongID("sn-") + id, err := idgen.GenerateLongID("sn-") + if err != nil { + return nil, err + } + sn := &driver.ServiceNetwork{ ID: id, ARN: m.arn("servicenetwork/" + id), diff --git a/providers/aws/vpclattice/services.go b/providers/aws/vpclattice/services.go index c9f6edfca..b3d92e7ac 100644 --- a/providers/aws/vpclattice/services.go +++ b/providers/aws/vpclattice/services.go @@ -34,7 +34,11 @@ func (m *Mock) CreateService(_ context.Context, in *driver.CreateServiceInput) ( idle = defaultIdleTimeoutSec } - id := idgen.GenerateLongID("svc-") + id, err := idgen.GenerateLongID("svc-") + if err != nil { + return nil, err + } + svc := &driver.Service{ ID: id, ARN: m.arn("service/" + id), diff --git a/providers/aws/vpclattice/target_groups.go b/providers/aws/vpclattice/target_groups.go index e8c7662a1..1e9a199e7 100644 --- a/providers/aws/vpclattice/target_groups.go +++ b/providers/aws/vpclattice/target_groups.go @@ -41,7 +41,11 @@ func (m *Mock) CreateTargetGroup(_ context.Context, in *driver.CreateTargetGroup _ = json.Unmarshal(in.Config, &cfg) } - id := idgen.GenerateLongID("tg-") + id, err := idgen.GenerateLongID("tg-") + if err != nil { + return nil, err + } + tg := &driver.TargetGroup{ ID: id, ARN: m.arn("targetgroup/" + id), diff --git a/providers/azure/iam/access_key_secret_test.go b/providers/azure/iam/access_key_secret_test.go new file mode 100644 index 000000000..faee9b772 --- /dev/null +++ b/providers/azure/iam/access_key_secret_test.go @@ -0,0 +1,40 @@ +package iam + +import ( + "context" + "regexp" + "testing" + + "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +var secretAccessKeyShape = regexp.MustCompile(`^[A-Za-z0-9+/]{40}$`) + +func TestCreateAccessKeySecretIsRandom(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateUser(ctx, driver.UserConfig{Name: "erin"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + first, err := m.CreateAccessKey(ctx, driver.AccessKeyConfig{UserName: "erin"}) + if err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + + second, err := m.CreateAccessKey(ctx, driver.AccessKeyConfig{UserName: "erin"}) + if err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + + for _, ak := range []*driver.AccessKeyInfo{first, second} { + if !secretAccessKeyShape.MatchString(ak.SecretAccessKey) { + t.Fatalf("secret %q, want 40 base64-alphabet chars", ak.SecretAccessKey) + } + } + + if first.SecretAccessKey == second.SecretAccessKey { + t.Fatal("two access keys share a secret") + } +} diff --git a/providers/azure/iam/iam.go b/providers/azure/iam/iam.go index 9596e6d4e..642c23f5d 100644 --- a/providers/azure/iam/iam.go +++ b/providers/azure/iam/iam.go @@ -942,7 +942,11 @@ func (m *Mock) CreateAccessKey( } keyID := fmt.Sprintf("azure-key-%s", idgen.GenerateID("")) - secret := fmt.Sprintf("secret-%s", idgen.GenerateID("")) + + secret, err := idgen.SecretAccessKey() + if err != nil { + return nil, cerrors.Newf(cerrors.Internal, "generate access key secret: %v", err) + } ak := &accessKeyData{ AccessKeyID: keyID, diff --git a/providers/gcp/iam/access_key_secret_test.go b/providers/gcp/iam/access_key_secret_test.go new file mode 100644 index 000000000..faee9b772 --- /dev/null +++ b/providers/gcp/iam/access_key_secret_test.go @@ -0,0 +1,40 @@ +package iam + +import ( + "context" + "regexp" + "testing" + + "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +var secretAccessKeyShape = regexp.MustCompile(`^[A-Za-z0-9+/]{40}$`) + +func TestCreateAccessKeySecretIsRandom(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + if _, err := m.CreateUser(ctx, driver.UserConfig{Name: "erin"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + first, err := m.CreateAccessKey(ctx, driver.AccessKeyConfig{UserName: "erin"}) + if err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + + second, err := m.CreateAccessKey(ctx, driver.AccessKeyConfig{UserName: "erin"}) + if err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + + for _, ak := range []*driver.AccessKeyInfo{first, second} { + if !secretAccessKeyShape.MatchString(ak.SecretAccessKey) { + t.Fatalf("secret %q, want 40 base64-alphabet chars", ak.SecretAccessKey) + } + } + + if first.SecretAccessKey == second.SecretAccessKey { + t.Fatal("two access keys share a secret") + } +} diff --git a/providers/gcp/iam/iam.go b/providers/gcp/iam/iam.go index 8fad02ba7..912166bad 100644 --- a/providers/gcp/iam/iam.go +++ b/providers/gcp/iam/iam.go @@ -964,7 +964,11 @@ func (m *Mock) CreateAccessKey( } keyID := fmt.Sprintf("gcp-key-%s", idgen.GenerateID("")) - secret := fmt.Sprintf("secret-%s", idgen.GenerateID("")) + + secret, err := idgen.SecretAccessKey() + if err != nil { + return nil, cerrors.Newf(cerrors.Internal, "generate access key secret: %v", err) + } ak := &accessKeyData{ AccessKeyID: keyID, diff --git a/providers/oci/identity/auth_token_test.go b/providers/oci/identity/auth_token_test.go new file mode 100644 index 000000000..d304d77e5 --- /dev/null +++ b/providers/oci/identity/auth_token_test.go @@ -0,0 +1,32 @@ +package identity + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +func TestCreateAccessKeyAuthTokenIsRandom(t *testing.T) { + m := newMock(t) + ctx := t.Context() + + _, err := m.CreateUser(ctx, driver.UserConfig{Name: "erin"}) + require.NoError(t, err) + + first, err := m.CreateAccessKey(ctx, driver.AccessKeyConfig{UserName: "erin"}) + require.NoError(t, err) + + second, err := m.CreateAccessKey(ctx, driver.AccessKeyConfig{UserName: "erin"}) + require.NoError(t, err) + + for _, tok := range []string{first.SecretAccessKey, second.SecretAccessKey} { + assert.Len(t, tok, 20) + assert.False(t, strings.HasPrefix(tok, "authtoken-"), "token %q is counter-derived", tok) + } + + assert.NotEqual(t, first.SecretAccessKey, second.SecretAccessKey) +} diff --git a/providers/oci/identity/portable.go b/providers/oci/identity/portable.go index 982e0459d..0213985ef 100644 --- a/providers/oci/identity/portable.go +++ b/providers/oci/identity/portable.go @@ -339,10 +339,15 @@ func (m *Mock) CreateAccessKey(_ context.Context, cfg driver.AccessKeyConfig) (* return nil, namedNotFound(kindUser, cfg.UserName) } + token, err := idgen.OCIAuthToken() + if err != nil { + return nil, cerrors.Newf(cerrors.Internal, "generate auth token: %v", err) + } + tok := &authToken{ ID: idgen.GlobalOCID(kindCredential, m.opts.Realm), UserName: cfg.UserName, - Token: idgen.GenerateID("authtoken-"), + Token: token, TimeCreated: m.now(), } m.authTokens.Set(tok.ID, tok) diff --git a/server/aws/authgate.go b/server/aws/authgate.go index f9bb671af..1c8929490 100644 --- a/server/aws/authgate.go +++ b/server/aws/authgate.go @@ -2,6 +2,7 @@ package aws import ( "bytes" + "crypto/subtle" "io" "net/http" "strings" @@ -24,6 +25,10 @@ import ( // is rejected. const tempCredentialPrefix = "ASIA" +// securityTokenParam carries an STS session token, as a header or, on a +// presigned URL, a query parameter. +const securityTokenParam = "X-Amz-Security-Token" //nolint:gosec // a header name, not a credential + // gateConfig is what the auth gate needs from the server it guards. type gateConfig struct { iam iamdriver.IAM @@ -113,7 +118,8 @@ func newAuthGate(g *gateConfig) func(http.ResponseWriter, *http.Request) (*http. // verifyTempCredential authenticates an STS temporary (ASIA) credential. It // resolves the secret STS recorded for the presented access key id, rejects an -// unknown key (InvalidClientTokenId) or an expired session (ExpiredToken), then +// unknown key or one sent without its session token (InvalidClientTokenId) or an +// expired session (ExpiredToken), then // SigV4-verifies the signature against that secret. When no session store is // wired the credential is unverifiable, so it fails closed. The principal is // the session's owner (see stssrv.SessionOwner), and roleSession reports @@ -132,7 +138,7 @@ func verifyTempCredential( } sess, ok := sessions.Lookup(akid) - if !ok { + if !ok || !sessionTokenMatches(r, sess.SessionToken) { return authctx.Principal{}, false, invalid } @@ -159,6 +165,19 @@ func verifyTempCredential( return principal, sess.Owner.Role, aerr } +// sessionTokenMatches reports whether the request carries the session token +// STS issued with the credential, in the X-Amz-Security-Token header or, for a +// presigned URL, the query string. Real STS rejects a temporary key id presented +// without its token. +func sessionTokenMatches(r *http.Request, want string) bool { + got := r.Header.Get(securityTokenParam) + if got == "" { + got = r.URL.Query().Get(securityTokenParam) + } + + return got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + // resolverLookup adapts the IAM access-key resolver to sigv4.LookupFunc, // threading the request context. It reports ok=false when no resolver is wired // or the key is unknown, so an unresolved key becomes InvalidClientTokenId. diff --git a/server/aws/authgate_forgery_test.go b/server/aws/authgate_forgery_test.go new file mode 100644 index 000000000..71d9c491b --- /dev/null +++ b/server/aws/authgate_forgery_test.go @@ -0,0 +1,133 @@ +package aws + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + + cloudemu "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/internal/idgen" + stssrv "github.com/stackshy/cloudemu/v2/server/aws/sts" + iamdriver "github.com/stackshy/cloudemu/v2/services/iam/driver" +) + +// signedWhoami sends a /_whoami request signed with the given long-term +// credentials and returns the status code. +func signedWhoami(t *testing.T, url, akid, secret string) int { + t.Helper() + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url+"/_whoami", strings.NewReader("")) + if err != nil { + t.Fatalf("new request: %v", err) + } + + emptyHash := sha256.Sum256(nil) + creds := aws.Credentials{AccessKeyID: akid, SecretAccessKey: secret} + + if err := v4.NewSigner().SignHTTP( + req.Context(), creds, req, hex.EncodeToString(emptyHash[:]), "iam", "us-east-1", time.Now(), + ); err != nil { + t.Fatalf("sign: %v", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + _ = resp.Body.Close() + + return resp.StatusCode +} + +// TestAuthGateRejectsCounterGuessedSecret replays the forgery an attacker could +// mount when access-key secrets were built from the shared id counter: knowing +// only the access key id, sign with every "secret-%08x" near the counter's +// current value. Under EnforceAuth every guess must be rejected, while the real +// secret still works. +func TestAuthGateRejectsCounterGuessedSecret(t *testing.T) { + cloud := cloudemu.NewAWS() + ctx := context.Background() + + if _, err := cloud.IAM.CreateUser(ctx, iamdriver.UserConfig{Name: "victim"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + ak, err := cloud.IAM.CreateAccessKey(ctx, iamdriver.AccessKeyConfig{UserName: "victim"}) + if err != nil { + t.Fatalf("CreateAccessKey: %v", err) + } + + probe, err := strconv.ParseUint(idgen.GenerateID(""), 16, 64) + if err != nil { + t.Fatalf("parse probe id: %v", err) + } + + srv := New(Drivers{IAM: cloud.IAM, EnforceAuth: true}) + srv.Register(principalProbe{}) + + ts := httptest.NewServer(srv) + defer ts.Close() + + if got := signedWhoami(t, ts.URL, ak.AccessKeyID, ak.SecretAccessKey); got != http.StatusOK { + t.Fatalf("real secret: want 200, got %d", got) + } + + const window = 32 + + for n := probe - min(window, probe); n <= probe; n++ { + guess := fmt.Sprintf("secret-%08x", n) + if got := signedWhoami(t, ts.URL, ak.AccessKeyID, guess); got != http.StatusForbidden { + t.Fatalf("forged with guessed secret %q: want 403, got %d", guess, got) + } + } +} + +// TestVerifyTempCredentialBindsSessionToken checks that an ASIA credential is +// accepted only with the session token STS issued alongside it. +func TestVerifyTempCredentialBindsSessionToken(t *testing.T) { + now := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC) + clock := config.NewFakeClock(now) + store := stssrv.NewSessionStore(clock) + + issued, err := store.Mint(time.Hour, stssrv.SessionOwner{}) + if err != nil { + t.Fatalf("Mint: %v", err) + } + + cases := []struct { + name, token string + wantOK bool + }{ + {"issued token", issued.SessionToken, true}, + {"missing token", "", false}, + {"wrong token", issued.SessionToken + "x", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r, err := http.NewRequestWithContext(context.Background(), http.MethodPost, + "http://example.local/", strings.NewReader("")) + if err != nil { + t.Fatal(err) + } + + signTemp(t, r, issued.AccessKeyID, issued.SecretAccessKey, tc.token, now) + + _, _, aerr := verifyTempCredential(r, nil, issued.AccessKeyID, tempTestAccount, store, clock) + if (aerr == nil) != tc.wantOK { + t.Fatalf("verifyTempCredential error = %v, want ok=%v", aerr, tc.wantOK) + } + }) + } +} diff --git a/server/aws/sts/sessions.go b/server/aws/sts/sessions.go index 53814d1f6..21a5d4982 100644 --- a/server/aws/sts/sessions.go +++ b/server/aws/sts/sessions.go @@ -1,11 +1,12 @@ package sts import ( - "crypto/rand" + "errors" "sync" "time" "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/internal/idgen" ) // Session is one set of temporary credentials STS minted, retained so the SigV4 @@ -58,57 +59,61 @@ func NewSessionStore(clock config.Clock) *SessionStore { return &SessionStore{clock: clock, sessions: make(map[string]Session)} } -// tempKeyRandomLen is the number of random uppercase-alphanumeric characters -// after the ASIA prefix (real STS access key ids are 20 chars: 4 + 16). -const tempKeyRandomLen = 16 - -// secretLen is the length of a generated temporary secret (real STS secrets are -// 40-character base64-ish strings; any high-entropy value works here). -const secretLen = 40 - -// sessionTokenRandomLen is the random suffix length of a generated session token. -const sessionTokenRandomLen = 32 - // Mint generates a unique temporary credential set valid for dur acting as -// owner, records it, and returns it. Each call yields a distinct access key id and a fresh -// high-entropy secret, so a caller that does not hold the issued secret cannot -// forge a valid signature. It fails closed on a crypto/rand read error rather -// than issuing a predictable, forgeable credential. +// owner, records it, and returns it. Each call yields a distinct ASIA access +// key id, a fresh 40-character secret and a long session token, all from +// crypto/rand, so a caller that does not hold the issued secret cannot forge a +// valid signature. It fails closed on a crypto/rand error rather than issuing a +// predictable credential. func (s *SessionStore) Mint(dur time.Duration, owner SessionOwner) (Session, error) { if dur <= 0 { dur = sessionDuration } - akid, err := randUpperAlnum(tempKeyRandomLen) - if err != nil { - return Session{}, err - } - - secret, err := randUpperAlnum(secretLen) + secret, err := idgen.SecretAccessKey() if err != nil { return Session{}, err } - token, err := randUpperAlnum(sessionTokenRandomLen) + token, err := idgen.SessionToken() if err != nil { return Session{}, err } sess := Session{ - AccessKeyID: tempCredentialPrefix + akid, SecretAccessKey: secret, - SessionToken: "cloudemu-session-" + token, + SessionToken: token, Expiration: s.clock.Now().UTC().Add(dur), Owner: owner, } s.mu.Lock() - s.sessions[sess.AccessKeyID] = sess - s.mu.Unlock() + defer s.mu.Unlock() + + for range maxKeyIDAttempts { + id, err := idgen.TempAccessKeyID() + if err != nil { + return Session{}, err + } - return sess, nil + if _, taken := s.sessions[id]; !taken { + sess.AccessKeyID = id + s.sessions[id] = sess + + return sess, nil + } + } + + return Session{}, errKeyIDExhausted } +// maxKeyIDAttempts bounds the retries when a freshly drawn access key id is +// already taken. With 80 random bits a single collision is already unlikely. +const maxKeyIDAttempts = 5 + +// errKeyIDExhausted reports that every attempt drew an id already in use. +var errKeyIDExhausted = errors.New("could not generate a unique temporary access key id") + // Lookup returns the recorded session for id, if any. Expiry is not filtered // here: the gate compares the returned Expiration against its own clock so it // can return the expired-token error shape distinctly from an unknown key. @@ -120,28 +125,3 @@ func (s *SessionStore) Lookup(id string) (Session, bool) { return sess, ok } - -// tempCredentialPrefix marks STS-issued temporary access key ids (real STS uses -// the same "ASIA" prefix). -const tempCredentialPrefix = "ASIA" - -// alnumUpper is the alphabet for generated key ids/secrets (AWS access key ids -// are uppercase alphanumeric). -const alnumUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - -// randUpperAlnum returns n cryptographically-random uppercase-alphanumeric -// characters drawn from crypto/rand. On the practically-impossible read error it -// returns the error rather than a predictable fallback, so a caller never issues -// a forgeable credential built from low-entropy bytes. -func randUpperAlnum(n int) (string, error) { - buf := make([]byte, n) - if _, err := rand.Read(buf); err != nil { - return "", err - } - - for i := range buf { - buf[i] = alnumUpper[int(buf[i])%len(alnumUpper)] - } - - return string(buf), nil -} diff --git a/server/aws/sts/sessions_test.go b/server/aws/sts/sessions_test.go index 24ddedc14..36b46cc28 100644 --- a/server/aws/sts/sessions_test.go +++ b/server/aws/sts/sessions_test.go @@ -1,6 +1,7 @@ package sts_test import ( + "regexp" "strings" "testing" "time" @@ -49,3 +50,30 @@ func distinctChars(s string) int { return len(seen) } + +// TestMintMatchesRealSTSShape checks the minted credential has the shape real +// STS returns: ASIA plus 16 base32 characters, a 40-character base64-alphabet +// secret and a long base64 session token. +func TestMintMatchesRealSTSShape(t *testing.T) { + store := sts.NewSessionStore(config.NewFakeClock(time.Unix(0, 0))) + + sess, err := store.Mint(time.Hour, sts.SessionOwner{}) + if err != nil { + t.Fatalf("Mint: %v", err) + } + + checks := []struct { + name, got string + re *regexp.Regexp + }{ + {"AccessKeyID", sess.AccessKeyID, regexp.MustCompile(`^ASIA[A-Z2-7]{16}$`)}, + {"SecretAccessKey", sess.SecretAccessKey, regexp.MustCompile(`^[A-Za-z0-9+/]{40}$`)}, + {"SessionToken", sess.SessionToken, regexp.MustCompile(`^[A-Za-z0-9+/]{300,}$`)}, + } + + for _, c := range checks { + if !c.re.MatchString(c.got) { + t.Fatalf("%s = %q, want shape %s", c.name, c.got, c.re) + } + } +}