ProtoCore v0.0.1
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
base64.cpp
Go to the documentation of this file.
1// Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file base64.cpp
6 * @brief Base64 encoder/decoder implementation.
7 *
8 * **Encode** is a single portable software codec (RFC 4648) on every target: it is byte-identical to
9 * mbedTLS's but ~20x faster on the ESP32-S3 (docs/FEATURE_PERFORMANCE.md section 2), and it only ever
10 * encodes public data here (the SHA-1 in the WebSocket accept), so a data-dependent table lookup is fine.
11 *
12 * **Decode** is one portable **constant-time** codec on every target (no mbedTLS delegation, no separate
13 * native path). It is the only base64 path that touches a secret - the Basic-auth credential (RFC 7617) and
14 * the JWT / JWS segments (RFC 7515, via base64url) - so the character -> value mapping is evaluated with
15 * branchless arithmetic range masks: no data-dependent branch and no data-indexed table, so neither the
16 * timing nor the cache footprint depends on the secret bytes. Padding / length handling may branch (the
17 * plaintext length mod 3 is public, not a secret). This both removes the mbedTLS base64 dependency and
18 * closes the timing gap on the base64url (JWT) path, which was previously a plain branchy software decoder.
19 */
20
21#include "base64.h"
22
23#include "protocore_config.h" // PC_BASE64_SWAR (scalar vs SWAR constant-time decode; default SWAR)
24#include <stddef.h>
25#include <string.h> // strnlen
26
27static const char B64_TABLE[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
28
29void pc_base64_encode(const uint8_t *src, size_t src_len, char *dst)
30{
31 size_t i = 0;
32 size_t j = 0;
33
34 // Full groups: pack 3 input bytes into a 24-bit value, emit as 4 x 6-bit
35 // characters (most-significant 6 bits first).
36 while (i + 2 < src_len)
37 {
38 uint32_t v = ((uint32_t)src[i] << 16) | ((uint32_t)src[i + 1] << 8) | (uint32_t)src[i + 2];
39 dst[j++] = B64_TABLE[(v >> 18) & 0x3F];
40 dst[j++] = B64_TABLE[(v >> 12) & 0x3F];
41 dst[j++] = B64_TABLE[(v >> 6) & 0x3F];
42 dst[j++] = B64_TABLE[(v) & 0x3F];
43 i += 3;
44 }
45
46 // Tail of 1 or 2 leftover bytes: emit 2 or 3 characters, '='-padded to 4.
47 if (i < src_len)
48 {
49 uint32_t v = (uint32_t)src[i] << 16;
50 if (i + 1 < src_len)
51 {
52 v |= (uint32_t)src[i + 1] << 8;
53 }
54
55 dst[j++] = B64_TABLE[(v >> 18) & 0x3F];
56 dst[j++] = B64_TABLE[(v >> 12) & 0x3F];
57 dst[j++] = (i + 1 < src_len) ? B64_TABLE[(v >> 6) & 0x3F] : '='; // 3rd char only if 2 input bytes
58 dst[j++] = '=';
59 }
60
61 dst[j] = '\0';
62}
63
64// ---------------------------------------------------------------------------
65// Constant-time decode (RFC 4648). Decode is the one base64 path that touches a secret - the Basic-auth
66// credential (RFC 7617) and, via base64url below, the JWT / JWS segments (RFC 7515) - so the
67// character -> value mapping must not leak the bytes through a data-dependent branch or a data-indexed
68// table lookup (either makes the timing / cache footprint depend on the secret). Every alphabet range is
69// evaluated with branchless arithmetic masks, so a given input length always costs the same regardless of
70// the byte values. One portable codec on every target (no mbedTLS delegation, no separate native path).
71// Padding / length handling may branch: the plaintext length mod 3 is public (visible on the wire), not a
72// secret byte value. Verified byte-exact vs RFC 4648 vectors + the reference decoder over a random corpus;
73// timing-invariance measured on the ESP32-S3 (CCOUNT does not vary with the input bytes).
74// ---------------------------------------------------------------------------
75
76// x, lo, hi are byte values (< 256), so a byte-domain subtraction's bit 31 is the borrow flag. Each helper
77// returns an all-ones (0xFFFFFFFF) or all-zero mask with no branch and no memory access.
78static inline uint32_t ct_ge(uint32_t x, uint32_t lo)
79{
80 return ((x - lo) >> 31) - 1u; // x >= lo <=> no borrow <=> bit 31 clear
81}
82static inline uint32_t ct_le(uint32_t x, uint32_t hi)
83{
84 return ((hi - x) >> 31) - 1u;
85}
86static inline uint32_t ct_eq(uint32_t x, uint32_t y)
87{
88 return ct_ge(x, y) & ct_le(x, y);
89}
90static inline uint32_t ct_is_zero(uint32_t x)
91{
92 return 0u - ((x - 1u) >> 31); // x==0 -> (0-1)>>31 == 1 -> all-ones; x>=1 -> 0
93}
94
95// Map one character to (6-bit value + 1), or 0 if it is not in the alphabet. The +1 bias distinguishes 'A'
96// (value 0) from "no match" (0) without a branch. Exactly one range matches, so the ORed masked terms
97// collapse to that term. `urlsafe` picks the '-' '_' alphabet (RFC 4648 sec 5) over '+' '/'; it is a public
98// per-call constant, never a secret, so selecting on it leaks nothing.
99static inline uint32_t ct_b64_val_plus1(uint32_t c, int urlsafe)
100{
101 uint32_t plus = urlsafe ? (uint32_t)'-' : (uint32_t)'+';
102 uint32_t slash = urlsafe ? (uint32_t)'_' : (uint32_t)'/';
103 uint32_t v = 0;
104 v |= ct_ge(c, 'A') & ct_le(c, 'Z') & (c - 'A' + 1u); // 1..26
105 v |= ct_ge(c, 'a') & ct_le(c, 'z') & (c - 'a' + 27u); // 27..52
106 v |= ct_ge(c, '0') & ct_le(c, '9') & (c - '0' + 53u); // 53..62
107 v |= ct_eq(c, plus) & 63u; // value 62 -> +1 = 63
108 v |= ct_eq(c, slash) & 64u; // value 63 -> +1 = 64
109 return v;
110}
111
112#if PC_BASE64_SWAR
113// ---------------------------------------------------------------------------
114// SWAR variant: classify 4 characters per 32-bit word instead of one at a time (opt-in, PC_BASE64_SWAR).
115// Every base64 character is < 0x80, so a byte lane never sets its own high bit; the guard-bit subtraction
116// (a | 0x80.. then subtract) keeps borrows from crossing lanes, so the range masks stay data-independent -
117// same constant-time property as the scalar path, four lanes at once. Whether this actually wins is a HW
118// measurement, not an assumption (decode is once-per-request); see docs/FEATURE_PERFORMANCE.md.
119// ---------------------------------------------------------------------------
120#define B64_ONES 0x01010101u
121#define B64_HIGH 0x80808080u
122
123static inline uint32_t swar_ge(uint32_t a, uint32_t v) // per lane 0x80 if a >= v, else 0
124{
125 return ((a | B64_HIGH) - v * B64_ONES) & B64_HIGH;
126}
127static inline uint32_t swar_le(uint32_t a, uint32_t v) // per lane 0x80 if a <= v, else 0
128{
129 return ((v * B64_ONES | B64_HIGH) - a) & B64_HIGH;
130}
131static inline uint32_t swar_spread(uint32_t m) // 0x80/lane -> 0xFF/lane, no cross-lane carry
132{
133 return m + (m - (m >> 7));
134}
135static inline uint32_t swar_sub7(uint32_t a, uint32_t lo) // per lane (a - lo) in the low 7 bits (guarded)
136{
137 return ((a | B64_HIGH) - lo * B64_ONES) & 0x7F7F7F7Fu;
138}
139
140// Decode 4 packed characters (c0 in the low byte) to 4 packed 6-bit values; *ok gets 0xFF in each valid lane.
141static inline uint32_t swar_quad(uint32_t a, uint32_t *ok)
142{
143 uint32_t mAZ = swar_spread(swar_ge(a, 'A') & swar_le(a, 'Z'));
144 uint32_t maz = swar_spread(swar_ge(a, 'a') & swar_le(a, 'z'));
145 uint32_t m09 = swar_spread(swar_ge(a, '0') & swar_le(a, '9'));
146 uint32_t mpl = swar_spread(swar_ge(a, '+') & swar_le(a, '+'));
147 uint32_t msl = swar_spread(swar_ge(a, '/') & swar_le(a, '/'));
148 uint32_t val = (mAZ & (swar_sub7(a, 'A') + 0u * B64_ONES)) | (maz & (swar_sub7(a, 'a') + 26u * B64_ONES)) |
149 (m09 & (swar_sub7(a, '0') + 52u * B64_ONES)) | (mpl & (62u * B64_ONES)) | (msl & (63u * B64_ONES));
150 *ok = mAZ | maz | m09 | mpl | msl;
151 return val;
152}
153
154size_t pc_base64_decode(const char *src, uint8_t *dst, size_t dst_cap)
155{
156 size_t src_len = strnlen(src, ((dst_cap + 2) / 3) * 4 + 4);
157 if (src_len == 0 || (src_len & 3u) != 0)
158 {
159 return 0;
160 }
161 size_t out = 0;
162 uint32_t bad = 0;
163 size_t nquads = src_len / 4;
164 for (size_t q = 0; q < nquads; q++)
165 {
166 size_t i = q * 4;
167 uint32_t c0 = (uint8_t)src[i];
168 uint32_t c1 = (uint8_t)src[i + 1];
169 uint32_t c2 = (uint8_t)src[i + 2];
170 uint32_t c3 = (uint8_t)src[i + 3];
171 int is_last = (q + 1 == nquads);
172 int p2 = is_last && (c2 == '=');
173 int p3 = is_last && (c3 == '=');
174 if (p2 && !p3)
175 {
176 return 0;
177 }
178 // A padded final quad: swap each '=' for 'A' (value 0, always valid) so the lane classifies, then
179 // drop the matching output byte. Position is public length info, so this branch leaks no secret.
180 uint32_t word = c0 | (c1 << 8) | ((p2 ? (uint32_t)'A' : c2) << 16) | ((p3 ? (uint32_t)'A' : c3) << 24);
181 uint32_t ok;
182 uint32_t val = swar_quad(word, &ok);
183 bad |= ~ok; // any invalid lane sets high bits here
184 uint32_t a = val & 0xFF;
185 uint32_t b = (val >> 8) & 0xFF;
186 uint32_t c = (val >> 16) & 0xFF;
187 uint32_t d = (val >> 24) & 0xFF;
188 if (out >= dst_cap)
189 {
190 return 0;
191 }
192 dst[out++] = (uint8_t)((a << 2) | (b >> 4));
193 if (!p2)
194 {
195 if (out >= dst_cap)
196 {
197 return 0;
198 }
199 dst[out++] = (uint8_t)((b << 4) | (c >> 2));
200 }
201 if (!p3)
202 {
203 if (out >= dst_cap)
204 {
205 return 0;
206 }
207 dst[out++] = (uint8_t)((c << 6) | d);
208 }
209 }
210 if (bad)
211 {
212 return 0;
213 }
214 return out;
215}
216#else
217size_t pc_base64_decode(const char *src, uint8_t *dst, size_t dst_cap)
218{
219 // Bounded length (a missing NUL cannot run past what dst_cap could ever hold). Canonical base64 is
220 // whole 4-character quads.
221 size_t src_len = strnlen(src, ((dst_cap + 2) / 3) * 4 + 4);
222 if (src_len == 0 || (src_len & 3u) != 0)
223 {
224 return 0;
225 }
226
227 size_t out = 0;
228 uint32_t bad = 0; // OR of per-character invalidity masks; a single check at the end (no early leak)
229
230 for (size_t i = 0; i < src_len; i += 4)
231 {
232 uint32_t c0 = (uint8_t)src[i + 0];
233 uint32_t c1 = (uint8_t)src[i + 1];
234 uint32_t c2 = (uint8_t)src[i + 2];
235 uint32_t c3 = (uint8_t)src[i + 3];
236
237 // '=' padding is legal only as the last 1-2 characters of the final quad. Position is public length
238 // information, so this branch reveals nothing about the secret bytes.
239 int is_last = (i + 4 == src_len);
240 int p2 = is_last && (c2 == '=');
241 int p3 = is_last && (c3 == '=');
242 if (p2 && !p3)
243 {
244 return 0; // "xx=y" - a lone pad must sit in the 4th position, not the 3rd
245 }
246
247 uint32_t v0 = ct_b64_val_plus1(c0, 0);
248 uint32_t v1 = ct_b64_val_plus1(c1, 0);
249 uint32_t v2 = ct_b64_val_plus1(c2, 0);
250 uint32_t v3 = ct_b64_val_plus1(c3, 0);
251
252 // c0/c1 are always alphabet chars; c2/c3 may be padding (only in the final quad).
253 bad |= ct_is_zero(v0);
254 bad |= ct_is_zero(v1);
255 bad |= p2 ? 0u : ct_is_zero(v2);
256 bad |= p3 ? 0u : ct_is_zero(v3);
257
258 uint32_t a = (v0 - 1u) & 0x3Fu;
259 uint32_t b = (v1 - 1u) & 0x3Fu;
260 uint32_t c = p2 ? 0u : ((v2 - 1u) & 0x3Fu);
261 uint32_t d = p3 ? 0u : ((v3 - 1u) & 0x3Fu);
262
263 // Reassemble the 24-bit group; padding trims the tail. Every write is bounded by dst_cap.
264 if (out >= dst_cap)
265 {
266 return 0;
267 }
268 dst[out++] = (uint8_t)((a << 2) | (b >> 4));
269 if (!p2)
270 {
271 if (out >= dst_cap)
272 {
273 return 0;
274 }
275 dst[out++] = (uint8_t)((b << 4) | (c >> 2));
276 }
277 if (!p3)
278 {
279 if (out >= dst_cap)
280 {
281 return 0;
282 }
283 dst[out++] = (uint8_t)((c << 6) | d);
284 }
285 }
286
287 if (bad)
288 {
289 return 0;
290 }
291 return out;
292}
293#endif // PC_BASE64_SWAR
294
295// ---------------------------------------------------------------------------
296// Base64url (RFC 4648 section 5): '-' / '_' replace '+' / '/', no '=' padding.
297// Shared by JWT (HS256) and OIDC (RS256) so the alphabet lives in one place.
298// Platform-independent: encode builds on pc_base64_encode then rewrites the two
299// differing characters in place; decode is a direct streaming decoder that
300// accepts the URL and standard alphabets and stops at '=', so it needs no temp
301// buffer or re-padding.
302// ---------------------------------------------------------------------------
303
304size_t pc_base64url_encode(const uint8_t *src, size_t src_len, char *dst)
305{
306 pc_base64_encode(src, src_len, dst); // standard base64, '='-padded, NUL-terminated
307 size_t n = 0;
308 for (size_t i = 0; dst[i]; i++)
309 {
310 char c = dst[i];
311 if (c == '=')
312 {
313 break; // base64url carries no padding
314 }
315 if (c == '+')
316 {
317 dst[i] = '-';
318 }
319 else if (c == '/')
320 {
321 dst[i] = '_';
322 }
323 n = i + 1;
324 }
325 dst[n] = '\0';
326 return n;
327}
328
329// Constant-time base64url decode (RFC 4648 sec 5, '-'/'_'), used by JWT / JWS (RFC 7515) and OIDC - a
330// secret path, so it shares the branchless classifier above (the standard '+'/'/' are rejected here). The
331// only difference from pc_base64_decode is framing: base64url carries no padding, and the final group may
332// be 2 or 3 characters (an unbounded streaming decode rather than whole quads).
333size_t pc_base64url_decode(const char *src, size_t src_len, uint8_t *dst, size_t dst_cap)
334{
335 size_t o = 0;
336 uint32_t acc = 0;
337 int bits = 0;
338 uint32_t bad = 0; // branchless invalidity accumulator; one check at the end (no per-character leak)
339 for (size_t i = 0; i < src_len; i++)
340 {
341 uint32_t ch = (uint8_t)src[i];
342 if (ch == '=')
343 {
344 break; // optional padding ends the input (public length information)
345 }
346 uint32_t vp = ct_b64_val_plus1(ch, 1);
347 bad |= ct_is_zero(vp);
348 acc = (acc << 6) | ((vp - 1u) & 0x3Fu);
349 bits += 6;
350 if (bits >= 8)
351 {
352 bits -= 8;
353 if (o >= dst_cap)
354 {
355 return 0;
356 }
357 dst[o++] = (uint8_t)((acc >> bits) & 0xFF);
358 }
359 }
360 if (bad)
361 {
362 return 0;
363 }
364 return o;
365}
size_t pc_base64url_decode(const char *src, size_t src_len, uint8_t *dst, size_t dst_cap)
Decode src_len characters of base64url (RFC 4648 section 5, '-'/'_' alphabet; an '=' ends the input).
Definition base64.cpp:333
size_t pc_base64url_encode(const uint8_t *src, size_t src_len, char *dst)
Encode src_len bytes as base64url (RFC 4648 section 5): '-' / '_' in place of '+' / '/',...
Definition base64.cpp:304
size_t pc_base64_decode(const char *src, uint8_t *dst, size_t dst_cap)
Decode a null-terminated Base64 string.
Definition base64.cpp:217
void pc_base64_encode(const uint8_t *src, size_t src_len, char *dst)
Encode src_len bytes of src as Base64.
Definition base64.cpp:29
Base64 encoder/decoder.
User-facing configuration for ProtoCore.