Skip to content
Open
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
8 changes: 8 additions & 0 deletions api/consent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
10 changes: 10 additions & 0 deletions vendorconsent/tcf1/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
111 changes: 96 additions & 15 deletions vendorconsent/tcf2/consent.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,38 +11,76 @@ import (
)

const (
consentStringTCF2Separator = '.'
consentStringTCF2Prefix = 'C'
consentStringTCF2Separator = '.'
consentStringTCF2Prefix = 'C'
segmentTypeDisclosedVendors uint8 = 1
)

// 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)
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 {
return nil, err
}
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
Expand All @@ -57,40 +95,83 @@ 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)
} else {
vendorLegitInts, pubRestrictsStart, err = parseBitField(metadata, legIntMaxVend, metadata.vendorLegitimateInterestStart)
}
if err != nil {
return nil, err
return ConsentMetadata{}, err
}

metadata.vendorLegitimateInterests = vendorLegitInts
metadata.pubRestrictionsStart = pubRestrictsStart

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
Expand Down
49 changes: 49 additions & 0 deletions vendorconsent/tcf2/consent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
17 changes: 17 additions & 0 deletions vendorconsent/tcf2/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type ConsentMetadata struct {
vendorConsents vendorConsentsResolver
vendorLegitimateInterests vendorConsentsResolver
publisherRestrictions pubRestrictResolver
disclosedVendors vendorConsentsResolver
}

type vendorConsentsResolver interface {
Expand Down Expand Up @@ -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
Expand Down