From 6752bfee94089c609270939fb2b8a925076b43a3 Mon Sep 17 00:00:00 2001 From: guillrak Date: Mon, 9 Feb 2026 14:53:56 +0100 Subject: [PATCH] Add TCF 2.3 Disclosed Vendors segment support (#40) Parse the Disclosed Vendors segment (SegmentType=1) from TC Strings and expose it via MaxDisclosedVendorID() and DisclosedVendor(id) on the VendorConsents interface. Maintains backward compatibility with TCF 2.2 strings where the segment is absent. Co-Authored-By: Claude Opus 4.6 --- api/consent.go | 8 +++ vendorconsent/tcf1/metadata.go | 10 +++ vendorconsent/tcf2/consent.go | 111 +++++++++++++++++++++++++---- vendorconsent/tcf2/consent_test.go | 49 +++++++++++++ vendorconsent/tcf2/metadata.go | 17 +++++ 5 files changed, 180 insertions(+), 15 deletions(-) diff --git a/api/consent.go b/api/consent.go index 64fec55..235fdd0 100644 --- a/api/consent.go +++ b/api/consent.go @@ -61,4 +61,12 @@ type VendorConsents interface { // It is the caller's responsibility to get the right Vendor List version for the semantics of the ID. // For more information, see VendorListVersion(). VendorConsent(id uint16) bool + + // MaxDisclosedVendorID returns the maximum Vendor ID in the Disclosed Vendors segment. + // Returns 0 if the segment is not present (e.g. TCF 2.2 strings). + MaxDisclosedVendorID() uint16 + + // DisclosedVendor returns true if the given vendor was disclosed to the user by the CMP. + // Returns false if the Disclosed Vendors segment is not present or if the vendor was not disclosed. + DisclosedVendor(id uint16) bool } diff --git a/vendorconsent/tcf1/metadata.go b/vendorconsent/tcf1/metadata.go index f9594f5..2112b5e 100644 --- a/vendorconsent/tcf1/metadata.go +++ b/vendorconsent/tcf1/metadata.go @@ -38,6 +38,16 @@ func parseMetadata(data []byte) (consentMetadata, error) { // to make sure that functions on it don't overflow the bounds of the byte array. type consentMetadata []byte +// MaxDisclosedVendorID returns 0 for TCF 1.x strings (no Disclosed Vendors segment). +func (c consentMetadata) MaxDisclosedVendorID() uint16 { + return 0 +} + +// DisclosedVendor returns false for TCF 1.x strings (no Disclosed Vendors segment). +func (c consentMetadata) DisclosedVendor(id uint16) bool { + return false +} + func (c consentMetadata) Version() uint8 { // Stored in bits 0-5 return uint8(c[0] >> 2) diff --git a/vendorconsent/tcf2/consent.go b/vendorconsent/tcf2/consent.go index 4bd7a95..80e39de 100644 --- a/vendorconsent/tcf2/consent.go +++ b/vendorconsent/tcf2/consent.go @@ -11,8 +11,9 @@ import ( ) const ( - consentStringTCF2Separator = '.' - consentStringTCF2Prefix = 'C' + consentStringTCF2Separator = '.' + consentStringTCF2Prefix = 'C' + segmentTypeDisclosedVendors uint8 = 1 ) // ParseString parses the TCF 2.0 vendor string base64 encoded @@ -20,12 +21,11 @@ func ParseString(consent string) (api.VendorConsents, error) { if consent == "" { return nil, consentconstants.ErrEmptyDecodedConsent } - // split TCF 2.0 segments - if index := strings.IndexByte(consent, consentStringTCF2Separator); index != -1 { - consent = consent[:index] - } - buff := []byte(consent) + segments := strings.Split(consent, string(consentStringTCF2Separator)) + + // Decode and parse the Core String (first segment) + buff := []byte(segments[0]) decoded := buff n, err := base64.RawURLEncoding.Decode(decoded, buff) if err != nil { @@ -33,16 +33,54 @@ func ParseString(consent string) (api.VendorConsents, error) { } decoded = decoded[:n:n] - return Parse(decoded) + metadata, err := parseCoreString(decoded) + if err != nil { + return nil, err + } + + // Parse additional segments + for _, seg := range segments[1:] { + segBuff := []byte(seg) + segDecoded := segBuff + sn, err := base64.RawURLEncoding.Decode(segDecoded, segBuff) + if err != nil { + continue + } + segDecoded = segDecoded[:sn:sn] + if len(segDecoded) == 0 { + continue + } + + // First 3 bits are the segment type + segType := segDecoded[0] >> 5 + if segType == segmentTypeDisclosedVendors { + dv, err := parseDisclosedVendors(segDecoded) + if err != nil { + continue + } + metadata.disclosedVendors = dv + } + } + + return metadata, nil } // Parse parses the TCF 2.0 vendor consent data from the string. This string should *not* be encoded (by base64 or any other encoding). // If the data is malformed and cannot be interpreted as a vendor consent string, this will return an error. func Parse(data []byte) (api.VendorConsents, error) { - metadata, err := parseMetadata(data) + metadata, err := parseCoreString(data) if err != nil { return nil, err } + return metadata, nil +} + +// parseCoreString parses the core string data and returns the populated ConsentMetadata. +func parseCoreString(data []byte) (ConsentMetadata, error) { + metadata, err := parseMetadata(data) + if err != nil { + return ConsentMetadata{}, err + } var vendorConsents vendorConsentsResolver var vendorLegitInts vendorConsentsResolver @@ -57,18 +95,18 @@ func Parse(data []byte) (api.VendorConsents, error) { vendorConsents, legitIntStart, err = parseBitField(metadata, metadata.MaxVendorID(), 230) } if err != nil { - return nil, err + return ConsentMetadata{}, err } metadata.vendorConsents = vendorConsents metadata.vendorLegitimateInterestStart = legitIntStart + 17 legIntMaxVend, err := bitutils.ParseUInt16(data, legitIntStart) if err != nil { - return nil, err + return ConsentMetadata{}, err } if legitIntStart+16 >= uint(len(data))*8 { - return nil, fmt.Errorf("invalid consent data: no legitimate interest start position") + return ConsentMetadata{}, fmt.Errorf("invalid consent data: no legitimate interest start position") } if isSet(data, legitIntStart+16) { vendorLegitInts, pubRestrictsStart, err = parseRangeSection(metadata, legIntMaxVend, metadata.vendorLegitimateInterestStart) @@ -76,7 +114,7 @@ func Parse(data []byte) (api.VendorConsents, error) { vendorLegitInts, pubRestrictsStart, err = parseBitField(metadata, legIntMaxVend, metadata.vendorLegitimateInterestStart) } if err != nil { - return nil, err + return ConsentMetadata{}, err } metadata.vendorLegitimateInterests = vendorLegitInts @@ -84,13 +122,56 @@ func Parse(data []byte) (api.VendorConsents, error) { pubRestrictions, _, err := parsePubRestriction(metadata, pubRestrictsStart) if err != nil { - return nil, err + return ConsentMetadata{}, err } metadata.publisherRestrictions = pubRestrictions - return metadata, err + return metadata, nil +} + +// parseDisclosedVendors parses a Disclosed Vendors segment. +func parseDisclosedVendors(data []byte) (vendorConsentsResolver, error) { + // Minimum: 3 bits (SegmentType) + 16 bits (MaxVendorId) + 1 bit (IsRangeEncoding) = 20 bits = 3 bytes + if len(data) < 3 { + return nil, fmt.Errorf("disclosed vendors segment requires at least 3 bytes. Got %d", len(data)) + } + + maxVendorID, err := bitutils.ParseUInt16(data, 3) + if err != nil { + return nil, err + } + + if isSet(data, 19) { + return parseSegmentRangeSection(data, maxVendorID, 20) + } + segMetadata := ConsentMetadata{data: data} + resolver, _, err := parseBitField(segMetadata, maxVendorID, 20) + return resolver, err +} + +// parseSegmentRangeSection parses a range-encoded vendor section from segment data. +// Unlike parseRangeSection, it does not require a minimum of 31 bytes for the full core string. +func parseSegmentRangeSection(data []byte, maxVendorID uint16, startbit uint) (*rangeSection, error) { + numEntries, err := bitutils.ParseUInt12(data, startbit) + if err != nil { + return nil, err + } + + currentOffset := startbit + 12 + consents := make([]rangeConsent, numEntries) + for i := range consents { + bitsConsumed, err := parseRangeConsent(&consents[i], data, currentOffset, maxVendorID) + if err != nil { + return nil, err + } + currentOffset = currentOffset + bitsConsumed + } + return &rangeSection{ + consents: consents, + maxVendorID: maxVendorID, + }, nil } // IsConsentV2 return true if the consent strings looks like a tcf v2 consent string diff --git a/vendorconsent/tcf2/consent_test.go b/vendorconsent/tcf2/consent_test.go index 6dbd92a..935f787 100644 --- a/vendorconsent/tcf2/consent_test.go +++ b/vendorconsent/tcf2/consent_test.go @@ -15,3 +15,52 @@ func TestParseLegitIntSetWithRangeSection(t *testing.T) { _, err := Parse(decode(t, "COvcSpYOvcSpYC9AAAENAPCAAAAAAAAAAAAAAFQBgAAgABAACAAEAAQAAgAA")) assertError(t, err) } + +func TestParseStringWithDisclosedVendors(t *testing.T) { + // TCF 2.3 string with Disclosed Vendors segment (range-encoded): + // Disclosed vendors: 1-5, 100, 404 (MaxVendorId=404) + consent, err := ParseString("CQSbk4AQSbk4ANwAAAENAwCgAAAAAAAAAAYgACPAAAAA.IDKQA4AAgAKAGQAygAAA.YAAAAAAAAAAA") + assertNilError(t, err) + + metadata := consent.(ConsentMetadata) + assertUInt16sEqual(t, 404, metadata.MaxDisclosedVendorID()) + + // Vendors in range 1-5 should be disclosed + for id := uint16(1); id <= 5; id++ { + assertBoolsEqual(t, true, metadata.DisclosedVendor(id)) + } + // Vendor 6 should not be disclosed + assertBoolsEqual(t, false, metadata.DisclosedVendor(6)) + // Vendor 100 should be disclosed + assertBoolsEqual(t, true, metadata.DisclosedVendor(100)) + // Vendor 101 should not be disclosed + assertBoolsEqual(t, false, metadata.DisclosedVendor(101)) + // Vendor 404 should be disclosed + assertBoolsEqual(t, true, metadata.DisclosedVendor(404)) + + // Core string fields should still work + assertUInt8sEqual(t, 2, metadata.Version()) +} + +func TestParseStringWithoutDisclosedVendors(t *testing.T) { + // TCF 2.2 string without Disclosed Vendors segment + consent, err := ParseString("CQc78IAQc78IAAHABAFRCPFsAP_gAAAAAAAAKlwJ4AFgAYABUAC4AGQAQAAnABaADIAGgAWwAwgBzAD8AIQATgAuABlADjAIQARAAicBHAEdAJKAYoA0ACIgETAKWAXUAvMBgIDFgGMgMsAf2BAECMwEdgKlgAAAGKQAYAAgvwOgAwABBfghABgACC_BKADAAEF-AkAGAAIL8FoAMAAQX4A") + assertNilError(t, err) + + metadata := consent.(ConsentMetadata) + assertUInt16sEqual(t, 0, metadata.MaxDisclosedVendorID()) + assertBoolsEqual(t, false, metadata.DisclosedVendor(1)) +} + +func TestParseStringDisclosedVendorEdgeCases(t *testing.T) { + // TCF 2.3 string - test boundary vendor IDs + consent, err := ParseString("CQSbk4AQSbk4ANwAAAENAwCgAAAAAAAAAAYgACPAAAAA.IDKQA4AAgAKAGQAygAAA.YAAAAAAAAAAA") + assertNilError(t, err) + + metadata := consent.(ConsentMetadata) + + // Vendor ID 0 should return false + assertBoolsEqual(t, false, metadata.DisclosedVendor(0)) + // Vendor ID beyond MaxDisclosedVendorID should return false + assertBoolsEqual(t, false, metadata.DisclosedVendor(405)) +} diff --git a/vendorconsent/tcf2/metadata.go b/vendorconsent/tcf2/metadata.go index 450f105..ffe3a6a 100644 --- a/vendorconsent/tcf2/metadata.go +++ b/vendorconsent/tcf2/metadata.go @@ -45,6 +45,7 @@ type ConsentMetadata struct { vendorConsents vendorConsentsResolver vendorLegitimateInterests vendorConsentsResolver publisherRestrictions pubRestrictResolver + disclosedVendors vendorConsentsResolver } type vendorConsentsResolver interface { @@ -205,6 +206,22 @@ func (c ConsentMetadata) CheckPubRestriction(purposeID uint8, restrictType uint8 return c.publisherRestrictions.CheckPubRestriction(purposeID, restrictType, vendor) } +// MaxDisclosedVendorID returns the maximum Vendor ID in the Disclosed Vendors segment. +func (c ConsentMetadata) MaxDisclosedVendorID() uint16 { + if c.disclosedVendors == nil { + return 0 + } + return c.disclosedVendors.MaxVendorID() +} + +// DisclosedVendor returns true if the given vendor was disclosed to the user. +func (c ConsentMetadata) DisclosedVendor(id uint16) bool { + if c.disclosedVendors == nil { + return false + } + return c.disclosedVendors.VendorConsent(id) +} + // Returns true if the bitIndex'th bit in data is a 1, and false if it's a 0. func isSet(data []byte, bitIndex uint) bool { byteIndex := bitIndex / 8