feat: initial public release
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.
This commit is contained in:
196
sdks/ios/ConsentOS/Sources/ConsentOSCore/TCFStringEncoder.swift
Normal file
196
sdks/ios/ConsentOS/Sources/ConsentOSCore/TCFStringEncoder.swift
Normal file
@@ -0,0 +1,196 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user