Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions internal/idgen/aws_id_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down
54 changes: 54 additions & 0 deletions internal/idgen/credential_test.go
Original file line number Diff line number Diff line change
@@ -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
}
})
}
}
95 changes: 79 additions & 16 deletions internal/idgen/idgen.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -157,22 +218,24 @@ 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

// 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
Expand All @@ -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 {
Expand Down
16 changes: 13 additions & 3 deletions providers/aws/appsync/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion providers/aws/appsync/appsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
6 changes: 5 additions & 1 deletion providers/aws/appsync/graphqlapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion providers/aws/bedrock/registries.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
12 changes: 10 additions & 2 deletions providers/aws/bedrockagent/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions providers/aws/bedrockagent/data_sources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion providers/aws/bedrockagent/flows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion providers/aws/bedrockagent/knowledge_bases.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading