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
22 changes: 22 additions & 0 deletions api/consent.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,26 @@ 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

// VendorDisclosed determines if a given vendor was disclosed to the user.
// This is mandatory in TCF 2.3 and is used to verify that vendors (especially those
// declaring only Special Purposes) were actually presented to the user.
// Mandatory Inclusion: All TC Strings created on or after February 28, 2026, must include the disclosedVendors segment.
// Source https://iabeurope.eu/all-you-need-to-know-about-the-transition-to-tcf-v2-3/#:~:text=Any%20TC%20String%20created%20without,TC%20String%20created%20under%202.3.
//
// For TCF 2.2 strings that don't include a DisclosedVendors segment,
// this returns false to maintain backward compatibility.
VendorDisclosed(id uint16) bool

VendorDisclosedMaxVendorId() uint16

// HasDisclosedVendors returns true if the consent string includes a disclosedVendors segment.
// This method is particularly useful during the TCF 2.3 transition phase (mandatory from March 1st, 2025)
// to distinguish between:
// - Legacy TCF 2.0/2.2 consent strings that legitimately don't have this segment (returns false)
// - TCF 2.3+ consent strings that should have this segment (returns true if present, false if malformed)
//
// Note: VendorDisclosed(id) returns false both when the segment is missing AND when
// a vendor is not disclosed, so use HasDisclosedVendors() to disambiguate these cases.
HasDisclosedVendors() bool
}
8 changes: 7 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
module github.com/prebid/go-gdpr

go 1.13
go 1.23

require (
github.com/buger/jsonparser v1.1.1
github.com/stretchr/testify v1.7.0
)

require (
github.com/davecgh/go-spew v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
)

// Version v0.9.0 was accidentally tagged as v1.9.0.
retract v1.9.0
3 changes: 1 addition & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
github.com/buger/jsonparser v0.0.0-20180318095312-2cac668e8456 h1:SnUWpAH4lEUoS86woR12h21VMUbDe+DYp88V646wwMI=
github.com/buger/jsonparser v0.0.0-20180318095312-2cac668e8456/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
Expand All @@ -9,6 +7,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
4 changes: 2 additions & 2 deletions vendorconsent/consent20_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ func TestInvalidConsentStrings20(t *testing.T) {
assertInvalid20(t, "CONciguONcjGKADACHENAACIAC0ta__AACiQABgAAYA", "the consent string encoded a VendorListVersion of 0, but this value must be greater than or equal to 1")

// Bad BitFields
assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQAeAA", "a BitField for 60 vendors requires a consent string of 36 bytes. This consent string had 30")
assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQAeAA", "a BitField for 60 vendors requires a consent string of 37 bytes. This consent string had 30")

// Bad RangeSections
assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQABwA", "vendor consent strings using RangeSections require at least 31 bytes. Got 30") // This encodes 184 bits
assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQABwA", "vendor consent strings using RangeSections require at least 31 bytes to read NumEntries. Got 30") // This encodes 184 bits
assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQABwAQQ", "ParseUInt16 expected a 16-bit int to start at bit 243, but the consent string was only 31 bytes long") // 1 single vendor, too few bits
assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQABwAYQAC", "ParseUInt16 expected a 16-bit int to start at bit 259, but the consent string was only 33 bytes long") // 1 vendor range, too few bits
assertInvalid20(t, "CONciguONcjGKADACHENAOCIAC0ta__AACiQABwAgABA", "ParseUInt16 expected a 16-bit int to start at bit 260, but the consent string was only 33 bytes long") // 2 single vendors, too few bits
Expand Down
13 changes: 13 additions & 0 deletions vendorconsent/tcf1/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ func (c consentMetadata) PurposeAllowed(id consentconstants.Purpose) bool {
return isSet(c, uint(id)+131)
}

// VendorDisclosed always returns false for TCF1 (disclosed vendors is a TCF 2.3 feature).
func (c consentMetadata) VendorDisclosed(id uint16) bool {
return false
}

func (c consentMetadata) VendorDisclosedMaxVendorId() uint16 {
return 0
}

func (c consentMetadata) HasDisclosedVendors() bool {
return false
}

// 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
Expand Down
19 changes: 19 additions & 0 deletions vendorconsent/tcf1/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,22 @@ func TestLanguageExtremes(t *testing.T) {
assertNilError(t, err)
assertStringsEqual(t, "ZA", consent.ConsentLanguage())
}

func TestVendorDisclosed(t *testing.T) {
consent, err := Parse(decode(t, "BOOG4uyOOG4uyABFZBAAABAAAAAAEA"))
assertNilError(t, err)
assertBoolsEqual(t, false, consent.VendorDisclosed(1))
assertBoolsEqual(t, false, consent.VendorDisclosed(100))
}

func TestVendorDisclosedMaxVendorID(t *testing.T) {
consent, err := Parse(decode(t, "BOOG4uyOOG4uyABFZBAAABAAAAAAEA"))
assertNilError(t, err)
assertUInt16sEqual(t, 0, consent.VendorDisclosedMaxVendorId())
}

func TestHasDisclosedVendors(t *testing.T) {
consent, err := Parse(decode(t, "BOOG4uyOOG4uyABFZBAAABAAAAAAEA"))
assertNilError(t, err)
assertBoolsEqual(t, false, consent.HasDisclosedVendors())
}
97 changes: 87 additions & 10 deletions vendorconsent/tcf2/consent.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,30 @@ const (
consentStringTCF2Prefix = 'C'
)

// Segment types defined in TCF 2.x specification.
// https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20Consent%20string%20and%20vendor%20list%20formats%20v2.md#publisher-purposes-transparency-and-consent
// Only `SegmentTypeDisclosedVendors` is used in this file, but all types are included for specification completeness.
const (
SegmentTypeCoreString = 0
SegmentTypeDisclosedVendors = 1
SegmentTypePublisherTC = 3
)

// ParseString parses the TCF 2.0 vendor string base64 encoded
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)
decoded := buff
n, err := base64.RawURLEncoding.Decode(decoded, buff)
consentMeta, err := parseCoreAndDisclosedVendors(consent)
if err != nil {
return nil, err
}
decoded = decoded[:n:n]

return Parse(decoded)
return consentMeta, 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).
// Parse parses the TCF 2.0 "Core string" segment. 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)
Expand Down Expand Up @@ -90,10 +92,85 @@ func Parse(data []byte) (api.VendorConsents, error) {
metadata.publisherRestrictions = pubRestrictions

return metadata, err
}

func parseCoreAndDisclosedVendors(consent string) (ConsentMetadata, error) {
// Split TCF 2.0 segments by '.'
// Format: [Core String].[Disclosed Vendors].[Publisher TC]
segments := strings.Split(consent, string(consentStringTCF2Separator))

// Parse the core string (always first segment)
coreSegmentDecoded, err := decodeSegment(segments[0])
if err != nil {
return ConsentMetadata{}, err
}

// Parse the core string
result, err := Parse(coreSegmentDecoded)
if err != nil {
return ConsentMetadata{}, err
}

metadata := result.(ConsentMetadata)

// Parse disclosed vendors segment if present (TCF 2.3+)
// Iterate through segments to find disclosed vendors by type (segments after Core String segment can be in any order)
for _, segment := range segments[1:] {
if segment == "" {
continue
}

decoded, err := decodeSegment(segment)
if err != nil {
return ConsentMetadata{}, err
}

segmentType, err := getSegmentType(decoded)
if err != nil {
return ConsentMetadata{}, err
}

if segmentType == SegmentTypeDisclosedVendors { // Disclosed Vendors segment
disclosedVendors, err := parseDisclosedVendorsSegment(decoded)
if err != nil {
return ConsentMetadata{}, fmt.Errorf("failed to parse disclosed vendors segment: %v", err)
}
metadata.disclosedVendors = disclosedVendors
metadata.hasDisclosedVendors = true
break
}
}

return metadata, nil
}

// IsConsentV2 return true if the consent strings looks like a tcf v2 consent string
func IsConsentV2(consent string) bool {
return len(consent) > 0 && consent[0] == consentStringTCF2Prefix
}

// decodeSegment decodes a base64 encoded segment string.
func decodeSegment(segmentString string) ([]byte, error) {
if segmentString == "" {
return nil, fmt.Errorf("empty segment string")
}

buff := []byte(segmentString)
decoded := buff
n, err := base64.RawURLEncoding.Decode(decoded, buff)
if err != nil {
return nil, fmt.Errorf("failed to decode segment: %v", err)
}

return decoded[:n:n], nil
}

// getSegmentType extracts the 3-bit segment type from the segment data
func getSegmentType(data []byte) (uint8, error) {
if len(data) < 1 {
return 0, fmt.Errorf("segment too short")
}

segmentType := data[0] >> 5
return segmentType, nil
}
56 changes: 56 additions & 0 deletions vendorconsent/tcf2/disclosed_vendors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package vendorconsent

import (
"fmt"

"github.com/prebid/go-gdpr/bitutils"
)

// parseDisclosedVendorsSegment parses the Disclosed Vendors segment (SegmentType=1).
// This segment is mandatory in TCF 2.3.
func parseDisclosedVendorsSegment(data []byte) (vendorConsentsResolver, error) {
if len(data) == 0 {
return nil, fmt.Errorf("data is empty")
}

// Need at least 3 bits for segment type + 16 bits for MaxVendorId + 1 bit for IsRangeEncoding
if len(data) < 3 {
return nil, fmt.Errorf("segment too short: %d bytes, need at least 3", len(data))
}

segmentType, err := bitutils.ParseByte8(data, 0)
if err != nil {
return nil, fmt.Errorf("parse segment type: %v", err)
}
segmentType = segmentType >> 5 // Get first 3 bits

if segmentType != SegmentTypeDisclosedVendors {
return nil, fmt.Errorf("expected segment type 1, got %d", segmentType)
}

maxVendorID, err := bitutils.ParseUInt16(data, 3)
if err != nil {
return nil, fmt.Errorf("parse MaxVendorId: %v", err)
}

// IsRangeEncoding is at bit 19 (0-based indexing)
// see https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20Consent%20string%20and%20vendor%20list%20formats%20v2.md#disclosed-vendors)
isRangeEncoding := isSet(data, 19)

// Create a temporary metadata just for parsing purposes
tempMetadata := ConsentMetadata{data: data}

if isRangeEncoding {
rangeSection, _, err := parseRangeSection(tempMetadata, maxVendorID, 20)
if err != nil {
return nil, fmt.Errorf("parse range section: %v", err)
}
return rangeSection, nil
}

bitField, _, err := parseBitField(tempMetadata, maxVendorID, 20)
if err != nil {
return nil, fmt.Errorf("parse bit field: %v", err)
}
return bitField, nil
}
Loading