diff --git a/docs/verification/connector-secret-types/evidence.md b/docs/verification/connector-secret-types/evidence.md new file mode 100644 index 000000000..63642cc3b --- /dev/null +++ b/docs/verification/connector-secret-types/evidence.md @@ -0,0 +1,19 @@ +# Evidence ledger + +Base: released v0.42.3, commit 2e045d482d592a9462b5f87c70a6113fe87ec54a. Source declaration/guard: 528830db; generated protobuf: 9a550250; immutable advertised schema correction: 4483426d. No release is claimed. + +| Criterion | Executable artifact and assertion | Status | +| --- | --- | --- | +| Declaration presence and schema round-trip | TestSecretContentTypeSchemaRoundTrip, binary/JSON equality and HasSecretContentType absent/present; explicit empty ValidateAll refusal | Verified to stated coverage | +| Illegal declarations | TestSecretContentTypeRegistrationRejectsInvalidDeclarations, global/resource and ordinary/encrypted registration; no published action on invalid input/public/empty/grammar/length | Verified to stated coverage | +| Pre-handler downgrade prevention | TestTypedSecretOutputRefusesUnacknowledgedCallerBeforeHandler, global/resource; missing/opaque/generic/wrong type/purpose/version/ambiguous/invalid JSON plus unsupported recipients/cardinality; empty action ID, handler counter zero, no allocation | Verified to stated coverage | +| Exact structured bytes and immutable registration type | TestTypedSecretOutputEncryptsExactHelperBytesWithFrozenDeclaration, actual manager/helper/JWE; mutate original schema after registration; independently open X-Wing HPKE bytes and compare canonical username/password JSON | Verified to stated coverage | +| Legacy generic compatibility | TestGenericSecretOutputKeepsOpaqueContexts plus existing ActionHandlerWithSecrets tests; absent/explicit generic and old age multi-output/recipient paths | Verified to stated coverage | +| Instrument adequacy | Removed new guard temporarily, ran account/generic regression: failed because expected error was nil; restored source byte-for-byte | Verified bounded mutation | +| Source generation | make protofmt; make protogen exit0. Only config.pb.go, config.pb.validate.go, config_protoopaque.pb.go changed; generated separately | Verified | +| Full package and race | go test -race -v ./pkg/actions -count=1; all named package tests ran/pass, no skips; 11.238s final package execution | Verified to stated coverage | +| Cross-repository integration | C1 consumer adoption, real member/Store/reveal/canonical codec/renderer | Deferred to consumer integration stage; not an SDK-only claim | + +Initial actual invocation test failed on JWE+Paper due to the pre-existing singleton-JWE validator. That failed assumption is preserved in the change-order history; tests now assert the released contract rather than expanding native/shared validation. The caller context guard establishes agreement, not server authentication or payload admission. No provider operations were executed. + +Snapshot correction: TestSecretOutputAdvertisedSchemaIsAnImmutableSnapshot failed in all six global/resource × absent/generic/password_v2 cases before the fix. The final full race run passes original-pointer, listing-copy and getter-copy mutation controls while actual invocation agrees with the unchanged advertised declaration. Proto inputs/generated files are unchanged by this correction; no regeneration was run. diff --git a/docs/verification/connector-secret-types/field-metadata-transition.md b/docs/verification/connector-secret-types/field-metadata-transition.md new file mode 100644 index 000000000..8ee9c35d9 --- /dev/null +++ b/docs/verification/connector-secret-types/field-metadata-transition.md @@ -0,0 +1,15 @@ +# Field metadata transition + +`config.Field.content_type` is MIME metadata. Logical delivery types use one +`multipass-type-` tag, for example `multipass-type-api_key_v2`. Other tags do +not select a type. The earlier prototype used Field 8 for a bare logical type; +that encoding is incompatible and valid bare values are rejected. JSON using +`secret_content_type` on a Field is also rejected by the legacy transport. +Native `request_schema` and descriptor `secret_content_type` remain readable; +when new and legacy declarations coexist they must agree. An absent return +field preserves the legacy issuance contract. A declared native return is one +named required secret string whose name must match the plaintext result. + +Request values remain nonsecret. Hosts apply defaults before approval; SDK +validation does not apply defaults. Payload bytes are opaque and are not +validated against MIME or logical type metadata. diff --git a/docs/verification/connector-secret-types/plan.md b/docs/verification/connector-secret-types/plan.md new file mode 100644 index 000000000..35a509c2e --- /dev/null +++ b/docs/verification/connector-secret-types/plan.md @@ -0,0 +1,48 @@ +# Connector-declared secret action output types + +## Frozen behavior + +A connector declares the content type of each secret action output before its handler runs. The connector owns the value bytes and codec. The caller chooses the destination and acknowledges the declared content type before producer side effects. One secret output can contain a document with multiple fields. + +An absent declaration preserves existing generic output behavior. A present empty declaration, declarations on inputs or public outputs, and malformed identifiers are invalid. Existing arbitrary encryption context behavior remains compatible for undeclared/generic outputs. Explicit structured outputs require a matching typed destination acknowledgement before action allocation and handler execution; an older caller that supplies generic context cannot silently reinterpret a structured output. + +The action schema at registration and invocation is an immutable snapshot. Later caller mutations of schema pointers or encryption configurations cannot change an invocation's declaration or authenticated context. + +Secret bytes remain separate from public return fields and are encrypted without conversion. This change introduces no credential issuance protocol, typed input support, provider lifecycle, release or activation. + +## Risk and verification + +HIGH: a mixed-version caller can otherwise silently change durable payload meaning after a producer performs an external effect. Prevention must run before the handler; post-result checking cannot reverse that effect. No grant-scale or artifact-open hot path changes: checks are linear in schema fields and encryption configurations, with a bounded context parse only for explicit typed outputs. + +| Criterion | Required instrument | Initial status | +| --- | --- | --- | +| Declaration absent/present round-trip | Descriptor and binary/JSON schema round-trip | Evidence incomplete | +| Invalid placement/value refused | Global/resource registration tables; assert no schema published | Evidence incomplete | +| Old generic caller refuses typed output | Invoke actual ActionManager with generic/missing/wrong context; producer-call count zero | Evidence incomplete | +| Matching typed context reaches producer | Actual manager, real JWE recipient and encrypted structured bytes | Evidence incomplete | +| Legacy generic compatibility | Existing secret-action tests plus arbitrary-context controls | Evidence incomplete | +| Immutable declaration/config | Mutate original schema/config after registration/dispatch; verify frozen output | Evidence incomplete | +| Result boundaries | Public-secret collision, undeclared/duplicate/required result checks | Existing tests; current change evidence incomplete | +| Generated compatibility | Owning protobuf generation and diff, source committed before generated | Evidence incomplete | + +Instrument adequacy: demonstrate the mixed-version regression fails when its pre-handler acknowledgement check is removed. Review changed branch coverage and preserve explicit gaps. Registration global/resource paths are a required cross-product; ordinary/encrypted handler registration both enforce declaration placement. No test contacts an upstream provider. + +## Boundary resolution + +Exact additive field and acknowledgement interpretation require architecture disposition before schema or authority changes. Source inspection was explicitly required to establish whether existing APIs suffice; it preceded this plan. Preserve that exception instead of claiming implementation-blind discovery. + +## Change orders + +Append any contract or enforcement ownership change here before implementation. + +### Change order 1: resolved declaration and caller acknowledgement + +Architecture disposition selects optional config.Field.secret_content_type=8 and a pre-handler check of existing full-knowledge JWE context purpose/version/type. Check every JWE recipient, reject duplicate JSON keys, and require one secret return for non-generic types. A matching JWE recipient may coexist with Paper/age recipients. Generic declarations retain opaque contexts and existing multi-output transport. The guard establishes type agreement only, not server authentication or payload validity. Tests cover global/resource invocation, invalid placement, mixed recipients and immutable registered type metadata. Full catalog ownership remains with the consumer; SDK checks syntax only. + +### Change order 2: preserve existing singleton-JWE transport + +Executed invocation evidence showed that released ValidateEncryptionConfigs rejects any JWE recipient in a configuration set with length other than one. The earlier JWE+Paper positive assumption was counterfactual. Architecture correction preserves that existing restriction; JWE+age and multi-JWE are rejection controls before action allocation/handler effects. The positive typed path uses exactly one JWE recipient and one secret output. Generic age-only multi-recipient/multi-output behavior is unchanged. NewEncryptionManager does not itself enforce this configuration validation; the common ActionManager invocation boundary does, before construction and handler dispatch. No shared/native crypto API changes are introduced. + +### Change order 3: immutable advertised schema + +A source check identified a correction required by the original immutable-registration criterion: the guard copied type strings, but registration and schema getters retained shared mutable schema pointers. The new six-case global/resource regression failed when caller mutation changed the advertised absent/generic/password declaration into api_key_v2 while the registered handler retained its original type. Publication and read APIs now clone schemas. Mutations of the original, listed and fetched schemas cannot alter advertised/enforced agreement. This adds linear copying in action schema size on registration/discovery, outside declared artifact-open/grant-scale hot paths; no provider, store or credential lifecycle changes. diff --git a/pb/c1/config/v1/config.pb.go b/pb/c1/config/v1/config.pb.go index bfadc34e3..82ceb721b 100644 --- a/pb/c1/config/v1/config.pb.go +++ b/pb/c1/config/v1/config.pb.go @@ -9,6 +9,7 @@ package v1 import ( + _ "github.com/envoyproxy/protoc-gen-validate/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" anypb "google.golang.org/protobuf/types/known/anypb" @@ -564,6 +565,9 @@ type Field struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3" json:"is_secret,omitempty"` + // Optional: the content type of the field. Only valid for StringField, StringSliceField, & StringMapField. + ContentType string `protobuf:"bytes,8,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` + Tags []string `protobuf:"bytes,9,rep,name=tags,proto3" json:"tags,omitempty"` // Types that are valid to be assigned to Field: // // *Field_StringField @@ -656,6 +660,20 @@ func (x *Field) GetIsSecret() bool { return false } +func (x *Field) GetContentType() string { + if x != nil { + return x.ContentType + } + return "" +} + +func (x *Field) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + func (x *Field) GetField() isField_Field { if x != nil { return x.Field @@ -790,6 +808,14 @@ func (x *Field) SetIsSecret(v bool) { x.IsSecret = v } +func (x *Field) SetContentType(v string) { + x.ContentType = v +} + +func (x *Field) SetTags(v []string) { + x.Tags = v +} + func (x *Field) SetStringField(v *StringField) { if v == nil { x.Field = nil @@ -1102,6 +1128,9 @@ type Field_builder struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool + // Optional: the content type of the field. Only valid for StringField, StringSliceField, & StringMapField. + ContentType string + Tags []string // Fields of oneof Field: StringField *StringField IntField *IntField @@ -1129,6 +1158,8 @@ func (b0 Field_builder) Build() *Field { x.IsRequired = b.IsRequired x.IsOps = b.IsOps x.IsSecret = b.IsSecret + x.ContentType = b.ContentType + x.Tags = b.Tags if b.StringField != nil { x.Field = &Field_StringField{b.StringField} } @@ -2805,7 +2836,7 @@ var File_c1_config_v1_config_proto protoreflect.FileDescriptor const file_c1_config_v1_config_proto_rawDesc = "" + "\n" + - "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\"\xd0\x03\n" + + "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\x1a\x17validate/validate.proto\"\xd0\x03\n" + "\rConfiguration\x12+\n" + "\x06fields\x18\x01 \x03(\v2\x13.c1.config.v1.FieldR\x06fields\x12:\n" + "\vconstraints\x18\x02 \x03(\v2\x18.c1.config.v1.ConstraintR\vconstraints\x12!\n" + @@ -2834,7 +2865,7 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x1b\n" + "\thelp_text\x18\x03 \x01(\tR\bhelpText\x12\x16\n" + "\x06fields\x18\x04 \x03(\tR\x06fields\x12\x18\n" + - "\adefault\x18\x05 \x01(\bR\adefault\"\x9d\b\n" + + "\adefault\x18\x05 \x01(\bR\adefault\"\x9a\t\n" + "\x05Field\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12 \n" + @@ -2843,7 +2874,9 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\vis_required\x18\x05 \x01(\bR\n" + "isRequired\x12\x15\n" + "\x06is_ops\x18\x06 \x01(\bR\x05isOps\x12\x1b\n" + - "\tis_secret\x18\a \x01(\bR\bisSecret\x12>\n" + + "\tis_secret\x18\a \x01(\bR\bisSecret\x12?\n" + + "\fcontent_type\x18\b \x01(\tB\x1c\xfaB\x19r\x17\x10\x01\x18\x80\x012\r^[^/]+/[^/]+$\xd0\x01\x01R\vcontentType\x12:\n" + + "\x04tags\x18\t \x03(\tB&\xfaB#\x92\x01 \x10\x10\x18\x01\"\x1ar\x18\x10\x01\x18\x8f\x012\x11^[a-zA-Z0-9_.-]+$R\x04tags\x12>\n" + "\fstring_field\x18d \x01(\v2\x19.c1.config.v1.StringFieldH\x00R\vstringField\x125\n" + "\tint_field\x18e \x01(\v2\x16.c1.config.v1.IntFieldH\x00R\bintField\x128\n" + "\n" + diff --git a/pb/c1/config/v1/config.pb.validate.go b/pb/c1/config/v1/config.pb.validate.go index 6cacb6839..b650d42eb 100644 --- a/pb/c1/config/v1/config.pb.validate.go +++ b/pb/c1/config/v1/config.pb.validate.go @@ -500,6 +500,85 @@ func (m *Field) validate(all bool) error { // no validation rules for IsSecret + if m.GetContentType() != "" { + + if l := utf8.RuneCountInString(m.GetContentType()); l < 1 || l > 128 { + err := FieldValidationError{ + field: "ContentType", + reason: "value length must be between 1 and 128 runes, inclusive", + } + if !all { + return err + } + errors = append(errors, err) + } + + if !_Field_ContentType_Pattern.MatchString(m.GetContentType()) { + err := FieldValidationError{ + field: "ContentType", + reason: "value does not match regex pattern \"^[^/]+/[^/]+$\"", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + + if len(m.GetTags()) > 16 { + err := FieldValidationError{ + field: "Tags", + reason: "value must contain no more than 16 item(s)", + } + if !all { + return err + } + errors = append(errors, err) + } + + _Field_Tags_Unique := make(map[string]struct{}, len(m.GetTags())) + + for idx, item := range m.GetTags() { + _, _ = idx, item + + if _, exists := _Field_Tags_Unique[item]; exists { + err := FieldValidationError{ + field: fmt.Sprintf("Tags[%v]", idx), + reason: "repeated value must contain unique items", + } + if !all { + return err + } + errors = append(errors, err) + } else { + _Field_Tags_Unique[item] = struct{}{} + } + + if l := utf8.RuneCountInString(item); l < 1 || l > 143 { + err := FieldValidationError{ + field: fmt.Sprintf("Tags[%v]", idx), + reason: "value length must be between 1 and 143 runes, inclusive", + } + if !all { + return err + } + errors = append(errors, err) + } + + if !_Field_Tags_Pattern.MatchString(item) { + err := FieldValidationError{ + field: fmt.Sprintf("Tags[%v]", idx), + reason: "value does not match regex pattern \"^[a-zA-Z0-9_.-]+$\"", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + switch v := m.Field.(type) { case *Field_StringField: if v == nil { @@ -1033,6 +1112,10 @@ var _ interface { ErrorName() string } = FieldValidationError{} +var _Field_ContentType_Pattern = regexp.MustCompile("^[^/]+/[^/]+$") + +var _Field_Tags_Pattern = regexp.MustCompile("^[a-zA-Z0-9_.-]+$") + // Validate checks the field values on Resource with the rules defined in the // proto definition for this message. If any rules are violated, the first // error encountered is returned, or nil if there are no violations. diff --git a/pb/c1/config/v1/config_protoopaque.pb.go b/pb/c1/config/v1/config_protoopaque.pb.go index 96ead9af2..2b1404297 100644 --- a/pb/c1/config/v1/config_protoopaque.pb.go +++ b/pb/c1/config/v1/config_protoopaque.pb.go @@ -9,6 +9,7 @@ package v1 import ( + _ "github.com/envoyproxy/protoc-gen-validate/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" anypb "google.golang.org/protobuf/types/known/anypb" @@ -566,6 +567,8 @@ type Field struct { xxx_hidden_IsRequired bool `protobuf:"varint,5,opt,name=is_required,json=isRequired,proto3"` xxx_hidden_IsOps bool `protobuf:"varint,6,opt,name=is_ops,json=isOps,proto3"` xxx_hidden_IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3"` + xxx_hidden_ContentType string `protobuf:"bytes,8,opt,name=content_type,json=contentType,proto3"` + xxx_hidden_Tags []string `protobuf:"bytes,9,rep,name=tags,proto3"` xxx_hidden_Field isField_Field `protobuf_oneof:"field"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -645,6 +648,20 @@ func (x *Field) GetIsSecret() bool { return false } +func (x *Field) GetContentType() string { + if x != nil { + return x.xxx_hidden_ContentType + } + return "" +} + +func (x *Field) GetTags() []string { + if x != nil { + return x.xxx_hidden_Tags + } + return nil +} + func (x *Field) GetStringField() *StringField { if x != nil { if x, ok := x.xxx_hidden_Field.(*field_StringField); ok { @@ -772,6 +789,14 @@ func (x *Field) SetIsSecret(v bool) { x.xxx_hidden_IsSecret = v } +func (x *Field) SetContentType(v string) { + x.xxx_hidden_ContentType = v +} + +func (x *Field) SetTags(v []string) { + x.xxx_hidden_Tags = v +} + func (x *Field) SetStringField(v *StringField) { if v == nil { x.xxx_hidden_Field = nil @@ -1084,6 +1109,9 @@ type Field_builder struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool + // Optional: the content type of the field. Only valid for StringField, StringSliceField, & StringMapField. + ContentType string + Tags []string // Fields of oneof xxx_hidden_Field: StringField *StringField IntField *IntField @@ -1111,6 +1139,8 @@ func (b0 Field_builder) Build() *Field { x.xxx_hidden_IsRequired = b.IsRequired x.xxx_hidden_IsOps = b.IsOps x.xxx_hidden_IsSecret = b.IsSecret + x.xxx_hidden_ContentType = b.ContentType + x.xxx_hidden_Tags = b.Tags if b.StringField != nil { x.xxx_hidden_Field = &field_StringField{b.StringField} } @@ -2797,7 +2827,7 @@ var File_c1_config_v1_config_proto protoreflect.FileDescriptor const file_c1_config_v1_config_proto_rawDesc = "" + "\n" + - "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\"\xd0\x03\n" + + "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\x1a\x17validate/validate.proto\"\xd0\x03\n" + "\rConfiguration\x12+\n" + "\x06fields\x18\x01 \x03(\v2\x13.c1.config.v1.FieldR\x06fields\x12:\n" + "\vconstraints\x18\x02 \x03(\v2\x18.c1.config.v1.ConstraintR\vconstraints\x12!\n" + @@ -2826,7 +2856,7 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x1b\n" + "\thelp_text\x18\x03 \x01(\tR\bhelpText\x12\x16\n" + "\x06fields\x18\x04 \x03(\tR\x06fields\x12\x18\n" + - "\adefault\x18\x05 \x01(\bR\adefault\"\x9d\b\n" + + "\adefault\x18\x05 \x01(\bR\adefault\"\x9a\t\n" + "\x05Field\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12 \n" + @@ -2835,7 +2865,9 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\vis_required\x18\x05 \x01(\bR\n" + "isRequired\x12\x15\n" + "\x06is_ops\x18\x06 \x01(\bR\x05isOps\x12\x1b\n" + - "\tis_secret\x18\a \x01(\bR\bisSecret\x12>\n" + + "\tis_secret\x18\a \x01(\bR\bisSecret\x12?\n" + + "\fcontent_type\x18\b \x01(\tB\x1c\xfaB\x19r\x17\x10\x01\x18\x80\x012\r^[^/]+/[^/]+$\xd0\x01\x01R\vcontentType\x12:\n" + + "\x04tags\x18\t \x03(\tB&\xfaB#\x92\x01 \x10\x10\x18\x01\"\x1ar\x18\x10\x01\x18\x8f\x012\x11^[a-zA-Z0-9_.-]+$R\x04tags\x12>\n" + "\fstring_field\x18d \x01(\v2\x19.c1.config.v1.StringFieldH\x00R\vstringField\x125\n" + "\tint_field\x18e \x01(\v2\x16.c1.config.v1.IntFieldH\x00R\bintField\x128\n" + "\n" + diff --git a/pkg/actions/actions.go b/pkg/actions/actions.go index d6a8c2965..bd55c466b 100644 --- a/pkg/actions/actions.go +++ b/pkg/actions/actions.go @@ -43,6 +43,7 @@ type actionHandlerResult struct { type registeredActionHandler struct { invoke func(context.Context, *structpb.Struct) actionHandlerResult secretReturnNames map[string]struct{} + secretReturnContentTypes map[string]string requiredSecretReturnNames []string } @@ -436,7 +437,10 @@ func (a *ActionManager) registerActionSchema(_ context.Context, name string, sch if _, ok := a.schemas[name]; ok { return fmt.Errorf("action schema %s already registered", name) } - a.schemas[name] = schema + if err := validateActionSecretContentTypes(schema); err != nil { + return err + } + a.schemas[name] = proto.Clone(schema).(*v2.BatonActionSchema) return nil } @@ -487,6 +491,7 @@ func (a *ActionManager) RegisterWithSecrets(ctx context.Context, schema *v2.Bato return actionHandlerResult{response: response, plaintextData: plaintextData, annotations: annos, err: err} }, secretReturnNames: secretReturnNames, + secretReturnContentTypes: snapshotSecretContentTypes(schema.GetReturnTypes()), requiredSecretReturnNames: requiredSecretReturnNames, }) } @@ -568,6 +573,7 @@ func (a *ActionManager) RegisterResourceActionWithSecrets( return actionHandlerResult{response: response, plaintextData: plaintextData, annotations: annos, err: err} }, secretReturnNames: secretReturnNames, + secretReturnContentTypes: snapshotSecretContentTypes(schema.GetReturnTypes()), requiredSecretReturnNames: requiredSecretReturnNames, }) } @@ -591,6 +597,9 @@ func (a *ActionManager) registerResourceAction( return fmt.Errorf("handler cannot be nil for action %s", schema.GetName()) } + if err := validateActionSecretContentTypes(schema); err != nil { + return err + } a.mu.Lock() defer a.mu.Unlock() @@ -631,7 +640,7 @@ func (a *ActionManager) registerResourceAction( } } - a.resourceSchemas[resourceTypeID][actionName] = schema + a.resourceSchemas[resourceTypeID][actionName] = proto.Clone(schema).(*v2.BatonActionSchema) a.resourceHandlers[resourceTypeID][actionName] = handler ctxzap.Extract(ctx).Debug("registered resource action", zap.String("resource_type", resourceTypeID), zap.String("action_name", actionName)) @@ -701,13 +710,13 @@ func (a *ActionManager) ListActionSchemas(_ context.Context, resourceTypeID stri // Return all global actions rv = make([]*v2.BatonActionSchema, 0, len(a.schemas)) for _, schema := range a.schemas { - rv = append(rv, schema) + rv = append(rv, proto.Clone(schema).(*v2.BatonActionSchema)) } // Also return all resource-scoped actions for _, schemas := range a.resourceSchemas { for _, schema := range schemas { - rv = append(rv, schema) + rv = append(rv, proto.Clone(schema).(*v2.BatonActionSchema)) } } } else { @@ -719,7 +728,7 @@ func (a *ActionManager) ListActionSchemas(_ context.Context, resourceTypeID stri rv = make([]*v2.BatonActionSchema, 0, len(schemas)) for _, schema := range schemas { - rv = append(rv, schema) + rv = append(rv, proto.Clone(schema).(*v2.BatonActionSchema)) } } @@ -734,7 +743,7 @@ func (a *ActionManager) GetActionSchema(_ context.Context, name string) (*v2.Bat if !ok { return nil, nil, status.Error(codes.NotFound, fmt.Sprintf("action %s not found", name)) } - return schema, nil, nil + return proto.Clone(schema).(*v2.BatonActionSchema), nil, nil } func (a *ActionManager) GetActionStatus(ctx context.Context, actionId string) (v2.BatonActionStatus, string, *structpb.Struct, annotations.Annotations, error) { @@ -915,6 +924,9 @@ func (a *ActionManager) invokeRegisteredAction( if err := crypto.ValidateEncryptionConfigs(encryptionConfigs); err != nil { return "", v2.BatonActionStatus_BATON_ACTION_STATUS_FAILED, nil, nil, nil, err } + if err := validateTypedOutputContext(handler.secretReturnContentTypes, encryptionConfigs); err != nil { + return "", v2.BatonActionStatus_BATON_ACTION_STATUS_FAILED, nil, nil, nil, status.Error(codes.FailedPrecondition, err.Error()) + } var err error encryptionManager, err = crypto.NewEncryptionManager(nil, encryptionConfigs) if err != nil { diff --git a/pkg/actions/secret_content_type.go b/pkg/actions/secret_content_type.go new file mode 100644 index 000000000..45a788385 --- /dev/null +++ b/pkg/actions/secret_content_type.go @@ -0,0 +1,85 @@ +package actions + +import ( + "errors" + "strings" + + config "github.com/conductorone/baton-sdk/pb/c1/config/v1" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/crypto" + "github.com/conductorone/baton-sdk/pkg/crypto/providers/jwe" +) + +func validateActionSecretContentTypes(schema *v2.BatonActionSchema) error { + for _, field := range schema.GetArguments() { + meta, err := crypto.ResolveFieldOutput(field) + if err != nil { + return err + } + if meta.HasType { + return errors.New("logical type is only valid on secret action returns") + } + } + secretReturns := 0 + hasTypedReturn := false + for _, field := range schema.GetReturnTypes() { + meta, err := crypto.ResolveFieldOutput(field) + if err != nil { + return err + } + if field.GetIsSecret() { + secretReturns++ + } + if meta.HasType && !field.GetIsSecret() { + return errors.New("logical type is only valid on secret action returns") + } + if meta.HasType && meta.LogicalType != "generic" { + hasTypedReturn = true + } + } + if hasTypedReturn && secretReturns != 1 { + return errors.New("typed secret action output requires exactly one secret return") + } + return nil +} + +func snapshotSecretContentTypes(fields []*config.Field) map[string]string { + types := make(map[string]string) + for _, field := range fields { + if field.GetIsSecret() { + meta, _ := crypto.ResolveFieldOutput(field) + types[field.GetName()] = meta.LogicalType + } + } + return types +} + +// This checks caller type agreement before producer effects; it does not authenticate the caller or validate secret payloads. +func validateTypedOutputContext(types map[string]string, configs []*v2.EncryptionConfig) error { + contentType := "" + for _, value := range types { + if value != "generic" { + contentType = value + } + } + if contentType == "" { + return nil + } + if len(types) != 1 { + return errors.New("typed secret action output requires exactly one secret return") + } + matched := false + for _, config := range configs { + if strings.ToLower(strings.TrimSpace(config.GetProvider())) != jwe.EncryptionProvider { + continue + } + if !crypto.TypedOutputContextMatches(config.GetJwkPublicKeyConfig().GetAdditionalAuthenticatedData(), contentType) { + return errors.New("typed secret action output requires a matching full-knowledge recipient context") + } + matched = true + } + if !matched { + return errors.New("typed secret action output requires a full-knowledge JWE recipient") + } + return nil +} diff --git a/pkg/actions/secret_content_type_test.go b/pkg/actions/secret_content_type_test.go new file mode 100644 index 000000000..c9a5ede9e --- /dev/null +++ b/pkg/actions/secret_content_type_test.go @@ -0,0 +1,385 @@ +package actions + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + "filippo.io/hpke" + config "github.com/conductorone/baton-sdk/pb/c1/config/v1" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/crypto/providers/jwe" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" +) + +func setLogicalType(field *config.Field, value string) { + field.SetTags([]string{"multipass-type-" + value}) +} + +func typedSecretSchema(contentType string) *v2.BatonActionSchema { + schema := secretActionSchema("issue_structured") + setLogicalType(schema.GetReturnTypes()[1], contentType) + return schema +} + +func typedContext(contentType string) []byte { + return []byte(fmt.Sprintf(`{"purpose":"c1/fk-credential-delivery","version":1,"content_type":%q}`, contentType)) +} + +func typedRecipient(t *testing.T, keyID string, aad []byte) (*v2.EncryptionConfig, hpke.PrivateKey) { + t.Helper() + private, err := hpke.MLKEM768X25519().GenerateKey() + require.NoError(t, err) + public, err := json.Marshal(map[string]string{"kty": "AKP", "alg": jwe.Algorithm, + "pub": base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes())}) + require.NoError(t, err) + return v2.EncryptionConfig_builder{Provider: jwe.EncryptionProvider, KeyId: keyID, + JwkPublicKeyConfig: v2.EncryptionConfig_JWKPublicKeyConfig_builder{PubKey: public, AdditionalAuthenticatedData: aad}.Build()}.Build(), private +} + +func registerTypedHandler(t *testing.T, manager *ActionManager, resource string, schema *v2.BatonActionSchema, calls *atomic.Int32) { + t.Helper() + handler := func(context.Context, *structpb.Struct) (*structpb.Struct, []*v2.PlaintextData, annotations.Annotations, error) { + calls.Add(1) + //nolint:gosec // G117: constant fixture payload; the value is not a credential. + value, err := json.Marshal(struct { + Username string `json:"username"` + Password string `json:"password"` + }{Username: "svc-user", Password: "fixture-only"}) + if err != nil { + return nil, nil, nil, err + } + return NewReturnValues(true), []*v2.PlaintextData{v2.PlaintextData_builder{Name: "token", Bytes: value}.Build()}, nil, nil + } + if resource == "" { + require.NoError(t, RegisterWithSecrets(t.Context(), manager, schema, handler)) + } else { + require.NoError(t, manager.RegisterResourceActionWithSecrets(t.Context(), resource, schema, handler)) + } +} + +func TestSecretContentTypeSchemaRoundTrip(t *testing.T) { + for _, present := range []bool{false, true} { + schema := secretActionSchema("schema") + if present { + setLogicalType(schema.GetReturnTypes()[1], "password_v2") + } + for _, encoding := range []string{"binary", "JSON"} { + t.Run(fmt.Sprintf("present=%t/%s", present, encoding), func(t *testing.T) { + var encoded []byte + var err error + if encoding == "binary" { + encoded, err = proto.Marshal(schema) + } else { + encoded, err = protojson.Marshal(schema) + } + require.NoError(t, err) + var decoded v2.BatonActionSchema + if encoding == "binary" { + err = proto.Unmarshal(encoded, &decoded) + } else { + err = protojson.Unmarshal(encoded, &decoded) + } + require.NoError(t, err) + require.True(t, proto.Equal(schema, &decoded)) + require.Equal(t, present, len(decoded.GetReturnTypes()[1].GetTags()) > 0) + }) + } + } + field := typedSecretSchema("").GetReturnTypes()[1] + require.True(t, len(field.GetTags()) > 0) + require.Error(t, validateActionSecretContentTypes(typedSecretSchema(""))) +} + +func TestSecretContentTypeRegistrationRejectsInvalidDeclarations(t *testing.T) { + for _, resource := range []string{"", "account"} { + for _, tc := range []struct { + name string + mutate func(*v2.BatonActionSchema) + }{ + {"empty", func(s *v2.BatonActionSchema) { setLogicalType(s.GetReturnTypes()[1], "") }}, + {"uppercase", func(s *v2.BatonActionSchema) { setLogicalType(s.GetReturnTypes()[1], "Password_V2") }}, + {"whitespace", func(s *v2.BatonActionSchema) { setLogicalType(s.GetReturnTypes()[1], " password_v2") }}, + {"overlong", func(s *v2.BatonActionSchema) { setLogicalType(s.GetReturnTypes()[1], strings.Repeat("a", 129)) }}, + {"public return", func(s *v2.BatonActionSchema) { setLogicalType(s.GetReturnTypes()[0], "password_v2") }}, + {"input", func(s *v2.BatonActionSchema) { + s.SetArguments([]*config.Field{config.Field_builder{Name: "input", IsSecret: true, Tags: []string{"multipass-type-password_v2"}}.Build()}) + }}, + } { + t.Run(resource+"/"+tc.name, func(t *testing.T) { + manager := NewActionManager(t.Context()) + schema := typedSecretSchema("password_v2") + tc.mutate(schema) + handler := func(context.Context, *structpb.Struct) (*structpb.Struct, []*v2.PlaintextData, annotations.Annotations, error) { + t.Fatal("invalid declaration must not publish a handler") + return nil, nil, nil, nil + } + var err error + if resource == "" { + err = manager.RegisterWithSecrets(t.Context(), schema, handler) + } else { + err = manager.RegisterResourceActionWithSecrets(t.Context(), resource, schema, handler) + } + require.Error(t, err) + require.False(t, manager.HasActions()) + }) + } + for _, resourceOnly := range []bool{false, true} { + t.Run(resource+fmt.Sprintf("/ordinary registration=%t", resourceOnly), func(t *testing.T) { + manager := NewActionManager(t.Context()) + schema := secretActionSchema("public") + schema.SetReturnTypes(schema.GetReturnTypes()[:1]) + setLogicalType(schema.GetReturnTypes()[0], "password_v2") + handler := func(context.Context, *structpb.Struct) (*structpb.Struct, annotations.Annotations, error) { + return nil, nil, nil + } + var err error + if resourceOnly { + err = manager.RegisterResourceAction(t.Context(), "account", schema, handler) + } else { + err = manager.Register(t.Context(), schema, handler) + } + require.Error(t, err) + require.False(t, manager.HasActions()) + }) + } + } +} + +func TestSecretOutputRegistrationCardinality(t *testing.T) { + for _, resource := range []string{"", "account"} { + for _, tc := range []struct { + name string + contentType string + extra *config.Field + wantError bool + }{ + {"typed singleton with public return", "password_v2", nil, false}, + {"typed plus generic secret", "password_v2", config.Field_builder{Name: "other", IsSecret: true}.Build(), true}, + {"typed plus typed secret", "password_v2", config.Field_builder{Name: "other", IsSecret: true, Tags: []string{"multipass-type-api_key_v2"}}.Build(), true}, + {"generic multiple secrets", "generic", config.Field_builder{Name: "other", IsSecret: true}.Build(), false}, + {"legacy multiple secrets", "generic", config.Field_builder{Name: "other", IsSecret: true}.Build(), false}, + {"generic plus typed secret", "generic", config.Field_builder{Name: "other", IsSecret: true, Tags: []string{"multipass-type-api_key_v2"}}.Build(), true}, + } { + t.Run(resource+"/"+tc.name, func(t *testing.T) { + manager := NewActionManager(t.Context()) + schema := typedSecretSchema(tc.contentType) + if tc.name == "legacy multiple secrets" { + schema.GetReturnTypes()[1].SetTags(nil) + } + if tc.extra != nil { + schema.SetReturnTypes(append(schema.GetReturnTypes(), tc.extra)) + } + handler := func(context.Context, *structpb.Struct) (*structpb.Struct, []*v2.PlaintextData, annotations.Annotations, error) { + return nil, nil, nil, nil + } + var err error + if resource == "" { + err = manager.RegisterWithSecrets(t.Context(), schema, handler) + } else { + err = manager.RegisterResourceActionWithSecrets(t.Context(), resource, schema, handler) + } + if tc.wantError { + require.ErrorContains(t, err, "exactly one secret return") + require.False(t, manager.HasActions()) + } else { + require.NoError(t, err) + require.True(t, manager.HasActions()) + } + }) + } + } +} + +func TestTypedOutputContextRetainsCardinalityGuard(t *testing.T) { + err := validateTypedOutputContext(map[string]string{"one": "password_v2", "two": "generic"}, nil) + require.ErrorContains(t, err, "exactly one secret return") +} + +func TestTypedSecretOutputRefusesUnacknowledgedCallerBeforeHandler(t *testing.T) { + for _, resource := range []string{"", "account"} { + for _, tc := range []struct{ name, context string }{ + {"absent", ""}, {"opaque", "opaque"}, {"generic", string(typedContext("generic"))}, + {"wrong type", string(typedContext("api_key_v2"))}, + {"wrong purpose", `{"purpose":"other","version":1,"content_type":"password_v2"}`}, + {"wrong version", `{"purpose":"c1/fk-credential-delivery","version":2,"content_type":"password_v2"}`}, + {"string version", `{"purpose":"c1/fk-credential-delivery","version":"1","content_type":"password_v2"}`}, + {"duplicate type", `{"purpose":"c1/fk-credential-delivery","version":1,"content_type":"generic","content_type":"password_v2"}`}, + {"escaped duplicate", `{"purpose":"c1/fk-credential-delivery","version":1,"content_type":"password_v2","content\u005ftype":"password_v2"}`}, + {"malformed", `{"purpose":`}, {"array", `[]`}, {"trailing", string(typedContext("password_v2")) + `{}`}, + } { + t.Run(resource+"/"+tc.name, func(t *testing.T) { + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + registerTypedHandler(t, manager, resource, typedSecretSchema("password_v2"), &calls) + config, _ := typedRecipient(t, "recipient", []byte(tc.context)) + id, _, _, _, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", resource, nil, time.Second, []*v2.EncryptionConfig{config}) + require.Error(t, err) + require.Empty(t, id) + require.Zero(t, calls.Load()) + require.Empty(t, manager.actions) + }) + } + for _, scenario := range []string{"age only", "conflicting JWE", "JWE plus age"} { + t.Run(resource+"/"+scenario, func(t *testing.T) { + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + schema := typedSecretSchema("password_v2") + registerTypedHandler(t, manager, resource, schema, &calls) + matching, _ := typedRecipient(t, "matching", typedContext("password_v2")) + configs := []*v2.EncryptionConfig{matching} + switch scenario { + case "age only": + age, _ := ageEncryptionConfig(t) + configs = []*v2.EncryptionConfig{age} + case "JWE plus age": + age, _ := ageEncryptionConfig(t) + configs = append(configs, age) + case "conflicting JWE": + conflicting, _ := typedRecipient(t, "conflicting", typedContext("api_key_v2")) + configs = append(configs, conflicting) + } + id, _, _, _, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", resource, nil, time.Second, configs) + require.Error(t, err) + require.Empty(t, id) + require.Zero(t, calls.Load()) + require.Empty(t, manager.actions) + }) + } + } +} + +func TestTypedSecretOutputEncryptsExactHelperBytesWithFrozenDeclaration(t *testing.T) { + for _, resource := range []string{"", "account"} { + t.Run(resource, func(t *testing.T) { + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + schema := typedSecretSchema("password_v2") + registerTypedHandler(t, manager, resource, schema, &calls) + setLogicalType(schema.GetReturnTypes()[1], "api_key_v2") + config, private := typedRecipient(t, "recipient", typedContext("password_v2")) + id, result, public, encrypted, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", resource, nil, time.Second, []*v2.EncryptionConfig{config}) + require.NoError(t, err) + require.NotEmpty(t, id) + require.Equal(t, v2.BatonActionStatus_BATON_ACTION_STATUS_COMPLETE, result) + require.Equal(t, int32(1), calls.Load()) + require.NotContains(t, public.GetFields(), "token") + require.Len(t, encrypted, 1) + jweResult := encrypted[0] + require.Equal(t, jwe.EncryptionProvider, jweResult.GetProvider()) + var envelope jwe.FlattenedJWE + require.NoError(t, json.Unmarshal(jweResult.GetEncryptedBytes(), &envelope)) + aad, err := base64.RawURLEncoding.Strict().DecodeString(envelope.AAD) + require.NoError(t, err) + require.Equal(t, typedContext("password_v2"), aad) + enc, err := base64.RawURLEncoding.Strict().DecodeString(envelope.EncryptedKey) + require.NoError(t, err) + ciphertext, err := base64.RawURLEncoding.Strict().DecodeString(envelope.Ciphertext) + require.NoError(t, err) + recipient, err := hpke.NewRecipient(enc, private, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), nil) + require.NoError(t, err) + opened, err := recipient.Open([]byte(envelope.Protected+"."+envelope.AAD), ciphertext) + require.NoError(t, err) + require.Equal(t, []byte(`{"username":"svc-user","password":"fixture-only"}`), opened) + }) + } +} + +func TestGenericSecretOutputKeepsOpaqueContexts(t *testing.T) { + for _, explicit := range []bool{false, true} { + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + schema := secretActionSchema("issue_structured") + if explicit { + setLogicalType(schema.GetReturnTypes()[1], "generic") + } + registerTypedHandler(t, manager, "", schema, &calls) + config, _ := typedRecipient(t, "recipient", []byte("opaque legacy context")) + _, result, _, _, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", "", nil, time.Second, []*v2.EncryptionConfig{config}) + require.NoError(t, err) + require.Equal(t, v2.BatonActionStatus_BATON_ACTION_STATUS_COMPLETE, result) + require.Equal(t, int32(1), calls.Load()) + } +} + +func TestSecretOutputAdvertisedSchemaIsAnImmutableSnapshot(t *testing.T) { + for _, resource := range []string{"", "account"} { + for _, declared := range []*string{nil, proto.String("generic"), proto.String("password_v2")} { + name := "absent" + if declared != nil { + name = *declared + } + t.Run(resource+"/"+name, func(t *testing.T) { + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + schema := secretActionSchema("issue_structured") + if declared != nil { + setLogicalType(schema.GetReturnTypes()[1], *declared) + } + registerTypedHandler(t, manager, resource, schema, &calls) + setLogicalType(schema.GetReturnTypes()[1], "api_key_v2") + assertDeclaration := func(field *config.Field) { + require.Equal(t, declared != nil, len(field.GetTags()) > 0) + if declared != nil { + require.Equal(t, *declared, strings.TrimPrefix(field.GetTags()[0], "multipass-type-")) + } + } + for _, filter := range []string{"", resource} { + listed, _, err := manager.ListActionSchemas(t.Context(), filter) + require.NoError(t, err) + require.Len(t, listed, 1) + assertDeclaration(listed[0].GetReturnTypes()[1]) + setLogicalType(listed[0].GetReturnTypes()[1], "api_key_v2") + } + if resource == "" { + got, _, err := manager.GetActionSchema(t.Context(), "issue_structured") + require.NoError(t, err) + assertDeclaration(got.GetReturnTypes()[1]) + setLogicalType(got.GetReturnTypes()[1], "api_key_v2") + got, _, err = manager.GetActionSchema(t.Context(), "issue_structured") + require.NoError(t, err) + assertDeclaration(got.GetReturnTypes()[1]) + } + context := []byte("opaque generic context") + if declared != nil && *declared != "generic" { + context = typedContext(*declared) + } + recipientConfig, _ := typedRecipient(t, "recipient", context) + _, result, _, _, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", resource, nil, time.Second, []*v2.EncryptionConfig{recipientConfig}) + require.NoError(t, err) + require.Equal(t, v2.BatonActionStatus_BATON_ACTION_STATUS_COMPLETE, result) + require.Equal(t, int32(1), calls.Load()) + }) + } + } +} + +func TestLegacyBinaryFieldEightRejectedAtRegistration(t *testing.T) { + for _, value := range []string{"generic", "password_v2"} { + schema := secretActionSchema("legacy") + field := schema.GetReturnTypes()[1] + // Field 8 was a length-delimited string in the prototype. + var decoded config.Field + encoded := protowire.AppendString(protowire.AppendTag(nil, 8, protowire.BytesType), value) + require.NoError(t, proto.Unmarshal(encoded, &decoded)) + field.SetContentType(decoded.GetContentType()) + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + err := manager.RegisterWithSecrets(t.Context(), schema, func(context.Context, *structpb.Struct) (*structpb.Struct, []*v2.PlaintextData, annotations.Annotations, error) { + calls.Add(1) + return nil, nil, nil, nil + }) + require.ErrorContains(t, err, "incompatible") + require.Zero(t, calls.Load()) + } +} diff --git a/pkg/crypto/field_metadata.go b/pkg/crypto/field_metadata.go new file mode 100644 index 000000000..1db2a4ac1 --- /dev/null +++ b/pkg/crypto/field_metadata.go @@ -0,0 +1,58 @@ +package crypto + +import ( + "fmt" + "regexp" + "strings" + + config "github.com/conductorone/baton-sdk/pb/c1/config/v1" +) + +const multipassTypePrefix = "multipass-type-" + +var fieldTagPattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) +var fieldMIMEPattern = regexp.MustCompile(`^[^/]+/[^/]+$`) + +// FieldOutput describes one field's encoding and logical delivery type. +type FieldOutput struct { + Name string + ContentType string + LogicalType string + HasType bool +} + +// ResolveFieldOutput validates metadata and resolves the reserved type tag. +func ResolveFieldOutput(field *config.Field) (FieldOutput, error) { + if field == nil { + return FieldOutput{}, fmt.Errorf("field is nil") + } + result := FieldOutput{Name: field.GetName(), ContentType: field.GetContentType(), LogicalType: "generic"} + if result.ContentType != "" { + if len(result.ContentType) > 128 || !fieldMIMEPattern.MatchString(result.ContentType) { + return FieldOutput{}, fmt.Errorf("incompatible field content type: expected MIME type") + } + switch field.WhichField() { + case config.Field_StringField_case, config.Field_StringSliceField_case, config.Field_StringMapField_case: + default: + return FieldOutput{}, fmt.Errorf("field content type requires a string field") + } + } + if len(field.GetTags()) > 16 { + return FieldOutput{}, fmt.Errorf("field has too many tags") + } + seen := make(map[string]bool, len(field.GetTags())) + for _, tag := range field.GetTags() { + if len(tag) > 143 || !fieldTagPattern.MatchString(tag) || seen[tag] { + return FieldOutput{}, fmt.Errorf("invalid or duplicate field tag") + } + seen[tag] = true + if strings.HasPrefix(tag, multipassTypePrefix) { + value := strings.TrimPrefix(tag, multipassTypePrefix) + if result.HasType || !ValidSecretContentType(value) { + return FieldOutput{}, fmt.Errorf("invalid or ambiguous logical type tag") + } + result.LogicalType, result.HasType = value, true + } + } + return result, nil +} diff --git a/pkg/crypto/secret_content_type.go b/pkg/crypto/secret_content_type.go new file mode 100644 index 000000000..21a23802f --- /dev/null +++ b/pkg/crypto/secret_content_type.go @@ -0,0 +1,59 @@ +package crypto + +import ( + "bytes" + "encoding/json" + "io" + "regexp" +) + +var secretContentTypePattern = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)*$`) + +// ValidSecretContentType reports whether a type identifier can be declared. +func ValidSecretContentType(value string) bool { + return len(value) <= 128 && secretContentTypePattern.MatchString(value) +} + +// TypedOutputContextMatches checks the type-bearing recipient context. +func TypedOutputContextMatches(encoded []byte, contentType string) bool { + if len(encoded) == 0 || len(encoded) > 16*1024 { + return false + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return false + } + fields := make(map[string]json.RawMessage) + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return false + } + name, ok := token.(string) + if !ok { + return false + } + if _, duplicate := fields[name]; duplicate { + return false + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return false + } + fields[name] = value + } + if token, err := decoder.Token(); err != nil || token != json.Delim('}') { + return false + } + if _, err := decoder.Token(); err != io.EOF { + return false + } + var purpose, actualType string + var version uint32 + if json.Unmarshal(fields["purpose"], &purpose) != nil || json.Unmarshal(fields["version"], &version) != nil || + json.Unmarshal(fields["content_type"], &actualType) != nil { + return false + } + return purpose == "c1/fk-credential-delivery" && version == 1 && actualType == contentType +} diff --git a/pkg/lambda/grpc/transport.go b/pkg/lambda/grpc/transport.go index 7da6f05d2..e2c4590c9 100644 --- a/pkg/lambda/grpc/transport.go +++ b/pkg/lambda/grpc/transport.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" spb "google.golang.org/genproto/googleapis/rpc/status" "google.golang.org/grpc/codes" @@ -11,10 +12,11 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" "google.golang.org/protobuf/types/known/anypb" pbtransport "github.com/conductorone/baton-sdk/pb/c1/transport/v1" - "google.golang.org/protobuf/reflect/protoregistry" ) const annotationsFieldName = "annotations" @@ -46,6 +48,10 @@ func unmarshalTransportJSON(b []byte, msg proto.Message) (bool, error) { return true, err } + if err := rejectLegacyFieldJSON(b, msg.ProtoReflect().Descriptor()); err != nil { + return false, err + } + unmarshalOptions := protojson.UnmarshalOptions{ DiscardUnknown: true, } @@ -353,3 +359,61 @@ func (f *Response) Headers() metadata.MD { func (f *Response) Trailers() metadata.MD { return UnmarshalMetadata(f.msg.GetTrailers()) } + +func rejectLegacyFieldJSON(raw json.RawMessage, descriptor protoreflect.MessageDescriptor) error { + var obj map[string]json.RawMessage + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return nil + } + if err := json.Unmarshal(raw, &obj); err != nil { + return err + } + if descriptor.FullName() == "google.protobuf.Any" { + var typeURL string + if json.Unmarshal(obj["@type"], &typeURL) == nil { + if typ, err := protoregistry.GlobalTypes.FindMessageByURL(typeURL); err == nil { + if typ.Descriptor().FullName() == descriptor.FullName() { + return rejectLegacyFieldJSON(obj["value"], descriptor) + } + return rejectLegacyFieldJSON(raw, typ.Descriptor()) + } + } + return nil + } + if descriptor.FullName() == "c1.config.v1.Field" { + if _, ok := obj["secret_content_type"]; ok { + return fmt.Errorf("incompatible legacy Field secret_content_type") + } + if _, ok := obj["secretContentType"]; ok { + return fmt.Errorf("incompatible legacy Field secret_content_type") + } + } + fields := descriptor.Fields() + for i := 0; i < fields.Len(); i++ { + f := fields.Get(i) + if f.Message() == nil || f.IsMap() { + continue + } + v, ok := obj[f.JSONName()] + if !ok { + v, ok = obj[string(f.Name())] + } + if !ok { + continue + } + if f.IsList() { + var values []json.RawMessage + if json.Unmarshal(v, &values) == nil { + for _, item := range values { + if err := rejectLegacyFieldJSON(item, f.Message()); err != nil { + return err + } + } + } + } else if err := rejectLegacyFieldJSON(v, f.Message()); err != nil { + return err + } + } + return nil +} diff --git a/pkg/lambda/grpc/wire_test.go b/pkg/lambda/grpc/wire_test.go index f7ac9cf02..c612cfb98 100644 --- a/pkg/lambda/grpc/wire_test.go +++ b/pkg/lambda/grpc/wire_test.go @@ -260,3 +260,21 @@ func TestDecodeWireFrame_UnsupportedVersion(t *testing.T) { err := rt.UnmarshalJSON([]byte(`{"v": 3, "frame": "AA=="}`)) require.ErrorContains(t, err, "unsupported wire frame version 3") } + +func TestLegacyFieldJSONIsRefused(t *testing.T) { + for _, name := range []string{"secret_content_type", "secretContentType"} { + payload := []byte(`{"schema":{"name":"a","returnTypes":[{"name":"secret","` + name + `":"generic"}]}}`) + var msg v2.GetActionSchemaResponse + _, err := unmarshalTransportJSON(payload, &msg) + require.ErrorContains(t, err, "incompatible legacy Field") + } +} + +func TestLegacyFieldJSONAnyGuard(t *testing.T) { + var message anypb.Any + require.NoError(t, rejectLegacyFieldJSON([]byte(`{"@type":"type.googleapis.com/google.protobuf.Any"}`), message.ProtoReflect().Descriptor())) + nested := []byte(`{"@type":"type.googleapis.com/google.protobuf.Any","value":{"@type":"type.googleapis.com/c1.config.v1.Field","secretContentType":"generic"}}`) + require.ErrorContains(t, rejectLegacyFieldJSON(nested, message.ProtoReflect().Descriptor()), "incompatible legacy Field") + field := []byte(`{"@type":"type.googleapis.com/c1.config.v1.Field","secretContentType":"generic"}`) + require.ErrorContains(t, rejectLegacyFieldJSON(field, message.ProtoReflect().Descriptor()), "incompatible legacy Field") +} diff --git a/proto/c1/config/v1/config.proto b/proto/c1/config/v1/config.proto index 29f3c4ca6..49f7207df 100644 --- a/proto/c1/config/v1/config.proto +++ b/proto/c1/config/v1/config.proto @@ -4,6 +4,7 @@ package c1.config.v1; import "c1/config/v1/rules.proto"; import "google/protobuf/any.proto"; +import "validate/validate.proto"; option go_package = "github.com/conductorone/baton-sdk/pb/c1/config/v1"; @@ -58,6 +59,24 @@ message Field { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. bool is_secret = 7; + // Optional: the content type of the field. Only valid for StringField, StringSliceField, & StringMapField. + string content_type = 8 [(validate.rules).string = { + ignore_empty: true + min_len: 1 + max_len: 128 + pattern: "^[^/]+/[^/]+$" + }]; + repeated string tags = 9 [(validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 143 + pattern: "^[a-zA-Z0-9_.-]+$" + } + } + max_items: 16 + }]; oneof field { StringField string_field = 100;