ConsentOS — a privacy-first cookie consent management platform. Self-hosted, source-available alternative to OneTrust, Cookiebot, and CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant architecture with role-based access, configuration cascade (system → org → group → site → region), dark-pattern detection in the scanner, and a tamper-evident consent record audit trail. This is the initial public release. Prior development history is retained internally. See README.md for the feature list, architecture overview, and quick-start instructions. Licensed under the Elastic Licence 2.0 — self-host freely; do not resell as a managed service.
197 lines
6.7 KiB
Swift
197 lines
6.7 KiB
Swift
import Foundation
|
|
|
|
// MARK: - TCF String Encoder
|
|
|
|
/// Encodes a TC string (Transparency & Consent Framework v2.2) from consent state.
|
|
///
|
|
/// The TC string is a Base64url-encoded bit field described in the IAB TCF v2.2 specification.
|
|
/// This implementation encodes the core consent section (segment type 0) sufficient for
|
|
/// signalling purpose consent to downstream vendors.
|
|
///
|
|
/// Reference: https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework
|
|
public final class TCFStringEncoder: Sendable {
|
|
|
|
// MARK: - Constants
|
|
|
|
/// TCF specification version.
|
|
private static let specVersion: Int = 2
|
|
|
|
/// A fixed CMP ID — replace with your registered IAB CMP ID in production.
|
|
private static let cmpId: Int = 0
|
|
|
|
/// CMP SDK version number.
|
|
private static let cmpVersion: Int = 1
|
|
|
|
/// IAB consent language (en).
|
|
private static let consentLanguage: String = "EN"
|
|
|
|
/// Vendor list version. In production, this should be fetched from the GVL.
|
|
private static let vendorListVersion: Int = 1
|
|
|
|
/// Number of TCF purposes defined in the specification.
|
|
private static let tcfPurposeCount: Int = 24
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Encodes a TC string for the given consent state and site configuration.
|
|
///
|
|
/// - Parameters:
|
|
/// - state: The resolved consent state containing accepted/rejected categories.
|
|
/// - config: The site configuration (used for CMP metadata).
|
|
/// - Returns: A Base64url-encoded TC string, or `nil` if encoding fails.
|
|
public static func encode(state: ConsentState, config: ConsentConfig) -> String? {
|
|
guard state.hasInteracted, let consentedAt = state.consentedAt else {
|
|
return nil
|
|
}
|
|
|
|
// Derive the set of consented TCF purpose IDs from accepted categories.
|
|
let consentedPurposeIds: Set<Int> = state.accepted.reduce(into: []) { result, category in
|
|
category.tcfPurposeIds.forEach { result.insert($0) }
|
|
}
|
|
|
|
return buildCoreString(
|
|
consentedAt: consentedAt,
|
|
consentedPurposeIds: consentedPurposeIds
|
|
)
|
|
}
|
|
|
|
// MARK: - Core String Construction
|
|
|
|
private static func buildCoreString(
|
|
consentedAt: Date,
|
|
consentedPurposeIds: Set<Int>
|
|
) -> String? {
|
|
var bits = BitWriter()
|
|
|
|
// --- Core segment fields (IAB TCF v2.2 spec, Table 1) ---
|
|
|
|
// Version (6 bits)
|
|
bits.write(specVersion, bitCount: 6)
|
|
|
|
// Created — deciseconds since epoch (36 bits)
|
|
let deciseconds = Int(consentedAt.timeIntervalSince1970 * 10)
|
|
bits.write(deciseconds, bitCount: 36)
|
|
|
|
// LastUpdated — deciseconds since epoch (36 bits)
|
|
bits.write(deciseconds, bitCount: 36)
|
|
|
|
// CmpId (12 bits)
|
|
bits.write(cmpId, bitCount: 12)
|
|
|
|
// CmpVersion (12 bits)
|
|
bits.write(cmpVersion, bitCount: 12)
|
|
|
|
// ConsentScreen (6 bits) — screen number within the CMP UI
|
|
bits.write(1, bitCount: 6)
|
|
|
|
// ConsentLanguage (12 bits) — two 6-bit characters, A=0 … Z=25
|
|
let langBits = encodeTwoLetterLanguage(consentLanguage)
|
|
bits.write(langBits.0, bitCount: 6)
|
|
bits.write(langBits.1, bitCount: 6)
|
|
|
|
// VendorListVersion (12 bits)
|
|
bits.write(vendorListVersion, bitCount: 12)
|
|
|
|
// TcfPolicyVersion (6 bits) — must be 4 for TCF v2.2
|
|
bits.write(4, bitCount: 6)
|
|
|
|
// IsServiceSpecific (1 bit)
|
|
bits.write(0, bitCount: 1)
|
|
|
|
// UseNonStandardTexts (1 bit)
|
|
bits.write(0, bitCount: 1)
|
|
|
|
// SpecialFeatureOptIns (12 bits) — none opted in
|
|
bits.write(0, bitCount: 12)
|
|
|
|
// PurposesConsent (24 bits) — one bit per purpose, LSB = purpose 1
|
|
for purposeId in 1 ... tcfPurposeCount {
|
|
bits.write(consentedPurposeIds.contains(purposeId) ? 1 : 0, bitCount: 1)
|
|
}
|
|
|
|
// PurposesLITransparency (24 bits) — legitimate interest; none asserted
|
|
bits.write(0, bitCount: 24)
|
|
|
|
// PurposeOneTreatment (1 bit)
|
|
bits.write(0, bitCount: 1)
|
|
|
|
// PublisherCC (12 bits) — "GB"
|
|
let ccBits = encodeTwoLetterLanguage("GB")
|
|
bits.write(ccBits.0, bitCount: 6)
|
|
bits.write(ccBits.1, bitCount: 6)
|
|
|
|
// Vendor Consents — using BitRange encoding with MaxVendorId = 0 (no vendors)
|
|
bits.write(0, bitCount: 16) // MaxVendorId
|
|
bits.write(0, bitCount: 1) // IsRangeEncoding = false
|
|
// (no bits to write for an empty vendor list)
|
|
|
|
// Vendor Legitimate Interests — MaxVendorId = 0
|
|
bits.write(0, bitCount: 16)
|
|
bits.write(0, bitCount: 1)
|
|
|
|
// Publisher Restrictions count = 0
|
|
bits.write(0, bitCount: 12)
|
|
|
|
// Serialise and Base64url-encode
|
|
let data = bits.toData()
|
|
return base64UrlEncode(data)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Encodes a two-letter language/country code into two 6-bit integers (A=0, Z=25).
|
|
private static func encodeTwoLetterLanguage(_ code: String) -> (Int, Int) {
|
|
// ASCII value of 'A' is 65. Subtracting this gives 0-based index (A=0 … Z=25).
|
|
let asciiA: Int = 65
|
|
let upper = code.uppercased()
|
|
let chars = Array(upper)
|
|
guard chars.count == 2,
|
|
let first = chars[0].asciiValue,
|
|
let second = chars[1].asciiValue else {
|
|
return (4, 13) // "EN" fallback (E=4, N=13)
|
|
}
|
|
return (Int(first) - asciiA, Int(second) - asciiA)
|
|
}
|
|
|
|
/// Converts a `Data` value to a Base64url string (RFC 4648, no padding).
|
|
private static func base64UrlEncode(_ data: Data) -> String {
|
|
data.base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.trimmingCharacters(in: CharacterSet(charactersIn: "="))
|
|
}
|
|
}
|
|
|
|
// MARK: - Bit Writer
|
|
|
|
/// A utility type for packing integers into a bit-level byte buffer.
|
|
private struct BitWriter {
|
|
|
|
private var bytes: [UInt8] = []
|
|
private var currentByte: UInt8 = 0
|
|
private var bitPosition: Int = 0 // 0 = MSB of current byte
|
|
|
|
/// Writes `bitCount` bits from the MSB of `value`.
|
|
mutating func write(_ value: Int, bitCount: Int) {
|
|
for i in stride(from: bitCount - 1, through: 0, by: -1) {
|
|
let bit: UInt8 = (value >> i) & 1 == 1 ? 1 : 0
|
|
currentByte |= bit << (7 - bitPosition)
|
|
bitPosition += 1
|
|
if bitPosition == 8 {
|
|
bytes.append(currentByte)
|
|
currentByte = 0
|
|
bitPosition = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Flushes any remaining partial byte and returns the accumulated data.
|
|
func toData() -> Data {
|
|
var result = bytes
|
|
if bitPosition > 0 {
|
|
result.append(currentByte)
|
|
}
|
|
return Data(result)
|
|
}
|
|
}
|