Files
keyrelay/tests/test_tls_pem.cpp
ducoterra e0780f58e0 phase: 02_tls_control_channel
**Verified / implemented (phase 02 final pass)**
- All 6 tasks already in `complete/` → verification pass. Device measurement **found a real defect (README bug 11)**: the cert carried its SAN *value* under the OID written from the ASCII text `"2.5.29.17"` instead of DER content bytes `55 1D 11` (`openssl x509 -text` showed `1.10.46.53…`, hex `30 2f 06 09 32 2e 35…`), i.e. **no SAN** → hostname validation impossible while every host check stayed green (they walked the value, never the label).
- Fixed in the pure module: `kSanOidTlv`/`sanOidDer()`, `sanExtensionFullDer()` (pinned to the 43 bytes OpenSSL writes for these names), `derCarriesSan()` (OID *and* value); `tls.cpp` uses them + 3 `static_assert`s; `cert` now warns and names the remedy. 33 new checks in `test_tls_pem.cpp`, incl. the pre-fix extension byte-for-byte (`derContains` yes / `derCarriesSan` no).
- Corrected a false finding: the earlier "fingerprint rotated once" was an **OpenSSL `-ext` artifact** (`C8:76…` vs the real `61:54:19:30…`, same DER). Re-measured: serial `cert` == live handshake DER hash; page 51 626 B, `sha256 dd16e195…f04e9` == locally assembled page.

**Tests / lint (100 % green)**
- `bash .agents/validate.sh` → **GATE_EXIT=0**: `RESULT: all suites passed` in the plain *and* `SANITIZE=1` run, **4826 checks / 0 failures** each; `bridge`/`console`/`rollback` **SUCCESS**; `page bytes: 51626`; `README/AGENTS claims: OK`; `repo hygiene: OK (71 tracked files)`.
- Footprint: bridge **RAM 30.4 % (99 616 B) / Flash 39.6 % (1 246 145 B)** vs baseline 26.7 %/87 592 & 36.1 %/1 135 293 → **+12 024 B RAM / +110 852 B flash**; TLS alone +6 160 B / +100 008 B (rollback image byte-identical); session heap 187 936 → ~145.5 k (1 session) → ~107.9 k (2).
- `ruff check --select F,E9 tools/ tests/` → All checks passed; `bash -n` clean (gate, runner, probe); `-Wall -Wextra` warning-free.

**Completion criteria**: 1 ✓ (gate green + RAM/Flash + session heap quoted); 2 ✓ as recorded (boot prints no fingerprint on the NVS-load path, `cert` prints on demand, identity survived reflashes, nothing about the key is printed anywhere — `nvs erase`→first-boot leg **not re-run**); 3 ✓ re-measured (51 626 == gate; `:80` → 301, no page bytes); 4 partial — 426/401/404 + pinning handshake measured, but arming/typing needs the operator's passphrase (`dflt:0`) and the target is this machine, so no input was sent; 5 ✓ (no `.pem/.key/.crt` tracked or on disk; hygiene block ran); 6 ✓ (`[env:rollback]` built every run + README section); 7 ✓.

**Documentation**: README — check count 4793→4826 (intro + Quick start), `test_tls_pem` 829→862 with new pins, footprint/TLS-cost rows re-measured, "Ten bugs"→"Eleven bugs" + new entry + both anchors, `cert` row, Troubleshooting names-warning row, `tls_pem.h` architecture entry, nine/ten pure-module wording. AGENTS.md (staleness only): "Ten bugs"→"Eleven bugs" ×2, 4826, and the certificate-names row extended with the DER-OID rule. `.agents/HW_VERIFICATION.md` — 426 body/headers re-measured, fingerprint row correction + `-ext` rule, new SAN row (`failed → fixed in tree, device leg needs nvs erase`) with closing commands; phase report written to `.agents/reports/02_tls_control_channel/02_tls_control_channel__00_phase.a1.md`.

**Deviations / live-infra**: made **no** reflash and **no** `nvs erase` (erase also destroys the token and Wi-Fi credentials; flashing puts a live HID keyboard into ROM download mode on this machine). Live steps were read-only `curl`/`openssl` plus one capped `console.py cert` (opening the CDC resets the board — permitted, recorded). No phase/PLAN files touched; everything left in the working tree for the harness to commit.

**Next pending phase**: `03_session_takeover` — `01_pending_claim_socket.md`.
2026-11-09 05:51:57 -05:00

994 lines
44 KiB
C++

// tests for the pure half of the TLS identity: src/tls_pem.h
//
// This suite includes tls_pem.h and nothing that drags in a crypto library. If it ever
// includes src/tls.h the host build breaks -- which is the point: that is the rule that
// keeps the identical code running here and on the board (AGENTS.md "Editing rules").
//
// What is under test is the part a handshake cannot diagnose. A certificate that fails
// to parse says "malformed"; it does not say "your PEM had 63 characters on line two",
// "your fingerprint was truncated to 63 hex digits" or "your SAN wrote the IP address as
// text". Each check below is named after the failure it would catch.
#include <cstring>
#include <string>
#include <vector>
#include "sha1.h" // base64Encode: a second, independent base64 to cross-check against
#include "test.h"
#include "tls_pem.h"
using namespace kr;
using namespace kr::tls; // the module under test; kr alone does not reach into it
namespace {
// A pattern that cannot appear in any output, so "writes nothing" is an observation
// rather than an assumption.
constexpr char kSentinel = char(0x5A);
void prime(char *buf, size_t cap) {
memset(buf, kSentinel, cap);
buf[cap - 1] = '\0';
}
bool untouched(const char *buf, size_t len) {
for (size_t i = 0; i < len; i++) {
if (buf[i] != kSentinel) return false;
}
return true;
}
std::string hexOf(const uint8_t *p, size_t len) {
static const char kHex[] = "0123456789abcdef";
std::string s;
for (size_t i = 0; i < len; i++) {
s += kHex[p[i] >> 4];
s += kHex[p[i] & 0xF];
}
return s;
}
// The lines strictly between the BEGIN line and the END line. An armour block with no
// body has none, and a helper that reported the END marker as a body line would quietly
// turn the empty-input case into a passing test of nothing.
std::vector<std::string> bodyLines(const char *pem) {
std::vector<std::string> all;
const std::string s(pem);
size_t start = 0;
while (true) {
const size_t q = s.find('\n', start);
if (q == std::string::npos) {
all.push_back(s.substr(start));
break;
}
all.push_back(s.substr(start, q - start));
start = q + 1;
}
if (!all.empty() && all.back().empty()) all.pop_back(); // the final terminator
if (all.size() <= 2) return std::vector<std::string>();
return std::vector<std::string>(all.begin() + 1, all.end() - 1);
}
// A DER-shaped blob: every byte value walks somewhere and it is not all zeros, so a
// base64 bug that only shows up on particular bit patterns cannot hide here.
void fillBlob(uint8_t *buf, size_t len) {
for (size_t i = 0; i < len; i++) {
buf[i] = static_cast<uint8_t>((i * 37 + 11) & 0xFF);
}
}
// The sizes that straddle the 64-character armour line: 48 DER bytes are exactly 64
// base64 characters, so 45/46/47 land on the last partial quantum of a full line and
// 49/50/51 spill onto the next one. 704 is the worst case the generator can hand over.
const size_t kLineBoundarySizes[] = {0, 1, 2, 3, 4, 45, 46, 47, 48, 49,
50, 51, 94, 95, 96, 97, 142, 143, 144, 145,
146, 299, 300, 301, 400, 704};
std::string toCrlf(const char *src) {
std::string out;
for (const char *p = src; *p; p++) {
if (*p == '\n') out += "\r\n";
out += *p;
}
return out;
}
} // namespace
int main() {
char pem[2048];
uint8_t der[1024];
uint8_t back[1024];
CASE("wrapPem: one known vector, byte for byte");
{
// RFC 4648's own example, "foobar" -> "Zm9vYmFy". If the alphabet table is off by
// one character anywhere, this is the check that says so.
const uint8_t in[] = {'f', 'o', 'o', 'b', 'a', 'r'};
prime(pem, sizeof(pem));
const size_t n = wrapPem("CERTIFICATE", in, sizeof(in), pem, sizeof(pem));
CHECK_STR(pem, "-----BEGIN CERTIFICATE-----\nZm9vYmFy\n-----END CERTIFICATE-----\n");
CHECK_EQ(n, strlen(pem));
CHECK_EQ(n, 63); // 28 head + 9 body (8 characters and their newline) + 26 tail
// An empty input is the two armour lines and nothing between them.
prime(pem, sizeof(pem));
const size_t z = wrapPem("CERTIFICATE", nullptr, 0, pem, sizeof(pem));
CHECK_STR(pem, "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n");
CHECK_EQ(z, strlen(pem));
// The label a private key is armoured under is a wire format, not decoration.
prime(pem, sizeof(pem));
wrapPem(kPemKeyLabel, in, sizeof(in), pem, sizeof(pem));
CHECK_STR(pem,
"-----BEGIN EC PRIVATE KEY-----\nZm9vYmFy\n-----END EC PRIVATE KEY-----\n");
}
CASE("wrapPem: 64 characters a line, boundary by boundary");
{
for (size_t s = 0; s < sizeof(kLineBoundarySizes) / sizeof(kLineBoundarySizes[0]); s++) {
const size_t len = kLineBoundarySizes[s];
fillBlob(der, len);
prime(pem, sizeof(pem));
const size_t n = wrapPem("CERTIFICATE", der, len, pem, sizeof(pem));
CHECK(n > 0);
CHECK_EQ(n, strlen(pem)); // no NUL in the middle, nothing after the end
// Every body line is exactly 64 characters except possibly the last, and a body
// exists exactly when there are bytes to carry.
const std::vector<std::string> lines = bodyLines(pem);
const size_t b64Len = ((len + 2) / 3) * 4;
const size_t wantLines = b64Len == 0 ? 0 : (b64Len + 63) / 64;
CHECK_EQ(lines.size(), wantLines);
for (size_t i = 0; i < lines.size(); i++) {
const bool lastLine = i + 1 == lines.size();
CHECK(lines[i].size() <= 64);
CHECK(lines[i].size() >= 1);
if (!lastLine) CHECK_EQ(lines[i].size(), 64);
}
// Cross-checked against sha1.h's base64, which the WebSocket handshake already
// trusts: two implementations written for different purposes that agree at every
// length cannot both be wrong about the alphabet.
char ref[1400];
const size_t refLen = base64Encode(der, len, ref, sizeof(ref));
CHECK_EQ(refLen, b64Len);
std::string stripped;
for (const std::string &l : lines) stripped += l;
CHECK(stripped == std::string(ref, refLen));
// And back again, unchanged.
size_t got = 0;
CHECK(pemFind("CERTIFICATE", pem, back, sizeof(back), &got));
CHECK_EQ(got, len);
CHECK(len == 0 || memcmp(der, back, len) == 0);
}
}
CASE("wrapPem: a padded final group on a line of its own still gets its newline");
{
// 1, 2 and everything 1 or 2 mod 48: the base64 body ends with a padded group that
// starts a fresh line, because 48 DER bytes fill exactly 64 characters. A writer that
// counts only whole groups leaves that line unterminated, and the END marker glues
// itself onto the body -- pemFind still decodes it, so the round trip passes and the
// block is nonetheless not what any other tool on earth writes. This was a real bug
// here; the independent-base64 cross-check above is what caught it.
const size_t paddedGroupSizes[] = {1, 2, 49, 50, 97, 98, 145, 146};
for (size_t s = 0; s < sizeof(paddedGroupSizes) / sizeof(paddedGroupSizes[0]); s++) {
const size_t len = paddedGroupSizes[s];
fillBlob(der, len);
prime(pem, sizeof(pem));
const size_t n = wrapPem("CERTIFICATE", der, len, pem, sizeof(pem));
CHECK(n > 0);
const char *endMark = strstr(pem, "-----END");
CHECK(endMark != nullptr);
CHECK(endMark > pem + 1);
CHECK_EQ(endMark[-1], '\n'); // the body's last line is terminated
CHECK_EQ(endMark[-2], '='); // ...and it really was the padded group
// The body is exactly ceil(b64/64) lines, the last one 4 characters long.
const std::vector<std::string> lines = bodyLines(pem);
const size_t b64Len = ((len + 2) / 3) * 4;
CHECK_EQ(lines.size(), (b64Len + 63) / 64);
CHECK_EQ(lines.back().size(), 4);
CHECK_EQ(lines.back()[3], '=');
}
}
CASE("wrapPem: refuses a buffer one byte short, and writes nothing at all");
{
fillBlob(der, 100);
prime(pem, sizeof(pem));
const size_t exact = wrapPem("CERTIFICATE", der, 100, pem, sizeof(pem));
CHECK_EQ(exact, strlen(pem));
// A buffer the size of the text: the NUL is the byte that does not fit, and a
// caller who counted characters and not the terminator is the bug this catches.
prime(pem, sizeof(pem));
CHECK_EQ(wrapPem("CERTIFICATE", der, 100, pem, exact), 0);
CHECK(untouched(pem, exact));
CHECK_EQ(wrapPem("CERTIFICATE", der, 100, pem, exact + 1), exact);
// Same for the empty-body case, which has a size of its own.
prime(pem, sizeof(pem));
const size_t emptyNeed = wrapPem("CERTIFICATE", nullptr, 0, pem, sizeof(pem));
prime(pem, sizeof(pem));
CHECK_EQ(wrapPem("CERTIFICATE", nullptr, 0, pem, emptyNeed), 0);
CHECK(untouched(pem, emptyNeed));
CHECK_EQ(wrapPem("CERTIFICATE", nullptr, 0, pem, emptyNeed + 1), emptyNeed);
}
CASE("wrapPem: refuses input it cannot name");
{
fillBlob(der, 8);
prime(pem, sizeof(pem));
CHECK_EQ(wrapPem(nullptr, der, 8, pem, sizeof(pem)), 0);
CHECK_EQ(wrapPem("", der, 8, pem, sizeof(pem)), 0);
CHECK_EQ(wrapPem("CERTIFICATE", nullptr, 8, pem, sizeof(pem)), 0); // bytes, no bytes
CHECK_EQ(wrapPem("CERTIFICATE", der, 8, nullptr, sizeof(pem)), 0);
// A label over the bound is a caller bug; exactly at the bound still works, so the
// refusal is about the bound and not about caution.
char longLabel[kPemLabelMax + 2];
memset(longLabel, 'X', sizeof(longLabel) - 1);
longLabel[sizeof(longLabel) - 1] = '\0';
CHECK_EQ(wrapPem(longLabel, der, 8, pem, sizeof(pem)), 0);
longLabel[kPemLabelMax - 1] = '\0';
CHECK(wrapPem(longLabel, der, 8, pem, sizeof(pem)) > 0);
}
CASE("pemFind: reads back what a real tool wrote");
{
fillBlob(der, 47);
prime(pem, sizeof(pem));
CHECK(wrapPem("CERTIFICATE", der, 47, pem, sizeof(pem)) > 0);
size_t got = 0;
// LF, as wrapPem writes it.
CHECK(pemFind("CERTIFICATE", pem, back, sizeof(back), &got));
CHECK_EQ(got, 47);
CHECK(memcmp(der, back, 47) == 0);
// CRLF: whatever passed through a Windows editor, and the shape a copy-pasted
// certificate usually arrives in.
const std::string crlf = toCrlf(pem);
CHECK(pemFind("CERTIFICATE", crlf.c_str(), back, sizeof(back), &got));
CHECK_EQ(got, 47);
CHECK(memcmp(der, back, 47) == 0);
// No trailing newline after the END marker.
std::string trimmed(pem, strlen(pem) - 1);
CHECK(pemFind("CERTIFICATE", trimmed.c_str(), back, sizeof(back), &got));
CHECK_EQ(got, 47);
// Spaces and tabs inside the body: "AAAA" is three zero bytes, "BBBB" is 04 10 41.
const std::string spaced =
"-----BEGIN CERTIFICATE-----\nAAAA \t\nBBBB\n-----END CERTIFICATE-----\n";
CHECK(pemFind("CERTIFICATE", spaced.c_str(), back, sizeof(back), &got));
CHECK_EQ(got, 6);
CHECK(memcmp(back, "\x00\x00\x00\x04\x10\x41", 6) == 0);
// An empty body is zero bytes -- not a failure, and not garbage.
CHECK(pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK_EQ(got, 0);
// Two blocks in one haystack: the first of that label wins.
fillBlob(der, 3);
char second[256];
wrapPem("CERTIFICATE", der, 3, second, sizeof(second));
const std::string both = std::string(pem) + second;
CHECK(pemFind("CERTIFICATE", both.c_str(), back, sizeof(back), &got));
CHECK_EQ(got, 47);
// ...and a block stored under another label is not this one.
CHECK(!pemFind("EC PRIVATE KEY", both.c_str(), back, sizeof(back), &got));
// The label matches exactly: a request for CERTIFICATE must not pick up a
// CERTIFICATE REQUEST, or a key would be found where a certificate belongs.
CHECK(!pemFind("CERTIFICATE",
"-----BEGIN CERTIFICATE REQUEST-----\nAAAA\n-----END CERTIFICATE REQUEST-----\n",
back, sizeof(back), &got));
}
CASE("pemFind: rejects a malformed block instead of decoding almost the right thing");
{
size_t got = 99;
const char *good = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----\n";
CHECK(!pemFind("CERTIFICATE", "", back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", "not a pem at all", back, sizeof(back), &got));
// BEGIN without END: a record torn by power loss in the middle of the write.
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAAAA\n", back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", "-----END CERTIFICATE-----\n", back, sizeof(back), &got));
// One character torn out of the body, so the length is no longer a multiple of four.
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAAA\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
// A character from the armour inside the body -- what a block pasted inside itself
// looks like, and what a buffer that held two writes looks like after a realloc.
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAA-A\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAA<>\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
// Padding is only legal in the last two positions of the last group.
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nA=AA\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\n=AAA\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE",
"-----BEGIN CERTIFICATE-----\nAAAA=A\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nAAAA====AAAA\n"
"-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
// Legal padding, for contrast: "QQ==" is one byte, "QUI=" is two.
// "QQ==" is "A" and "QUI=" is "AB" -- RFC 4648's own padding examples.
CHECK(pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nQQ==\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK_EQ(got, 1);
CHECK_EQ(back[0], 'A');
CHECK(pemFind("CERTIFICATE", "-----BEGIN CERTIFICATE-----\nQUI=\n-----END CERTIFICATE-----\n",
back, sizeof(back), &got));
CHECK_EQ(got, 2);
CHECK_EQ(back[0], 'A');
CHECK_EQ(back[1], 'B');
// Does not fit: a refusal with nothing written, never a truncated decode.
prime(reinterpret_cast<char *>(back), 8);
CHECK(!pemFind("CERTIFICATE", good, back, 2, &got));
CHECK(untouched(reinterpret_cast<const char *>(back), 7));
CHECK(pemFind("CERTIFICATE", good, back, 3, &got));
CHECK_EQ(got, 3);
// Degenerate arguments.
CHECK(!pemFind(nullptr, good, back, sizeof(back), &got));
CHECK(!pemFind("", good, back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", nullptr, back, sizeof(back), &got));
CHECK(!pemFind("CERTIFICATE", good, nullptr, sizeof(back), &got));
char longLabel[kPemLabelMax + 2];
memset(longLabel, 'X', sizeof(longLabel) - 1);
longLabel[sizeof(longLabel) - 1] = '\0';
CHECK(!pemFind(longLabel, good, back, sizeof(back), &got));
// A caller that does not want the length still gets the right bytes.
CHECK(pemFind("CERTIFICATE", good, back, sizeof(back)));
CHECK_EQ(back[0], 0);
}
CASE("fingerprintHex: the published SHA-256 of \"abc\"");
{
// FIPS 180-4. The digest arrives as bytes -- the board hashes with the platform
// library -- so what is under test is the rendering the operator reads, which is
// where a fingerprint goes wrong in practice: one nibble dropped, and the pinned
// value never matches while every handshake succeeds.
const uint8_t abc[32] = {
0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40,
0xde, 0x5d, 0xae, 0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17,
0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61, 0xf2, 0x00, 0x15, 0xad};
char hex[128];
prime(hex, sizeof(hex));
CHECK(fingerprintHex(abc, sizeof(abc), hex, 65));
CHECK_STR(hex, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
CHECK_EQ(strlen(hex), 64);
CHECK_EQ(kFingerprintCap, 65);
CHECK_EQ(fingerprintHexCap(32), 65);
// Exactly one byte short: no output, not a 63-character fingerprint.
prime(hex, sizeof(hex));
CHECK(!fingerprintHex(abc, sizeof(abc), hex, 64));
CHECK(untouched(hex, 64));
// Lowercase always: the operator copies this out of `openssl x509 -fingerprint`.
const uint8_t high[1] = {0xFF};
CHECK(fingerprintHex(high, 1, hex, sizeof(hex)));
CHECK_STR(hex, "ff");
// A zero-length digest is an empty string, and it still needs room for the NUL.
CHECK(fingerprintHex(high, 0, hex, 1));
CHECK_STR(hex, "");
prime(hex, sizeof(hex));
CHECK(!fingerprintHex(high, 0, hex, 0));
CHECK(!fingerprintHex(nullptr, 32, hex, sizeof(hex)));
CHECK(!fingerprintHex(abc, 32, nullptr, sizeof(hex)));
}
CASE("the names in the certificate are the names in config.h");
{
// kCertCommonName is the string the operator types into a browser. If it ever stops
// being kHostname + ".local", the certificate and the mDNS name diverge and every
// connection starts with a warning -- so this pins the config value, not just its
// formatting.
const std::string derived = std::string(kHostname) + ".local";
CHECK_STR(kCertCommonName, derived.c_str());
char subject[64];
prime(subject, sizeof(subject));
const size_t n = subjectLine(subject, sizeof(subject));
CHECK_STR(subject, "CN=keyrelay.local");
CHECK_EQ(n, strlen(subject));
CHECK_EQ(n, 17);
// No comma: the certificate writer parses this as a comma-separated name list.
CHECK(strchr(subject, ',') == nullptr);
prime(subject, sizeof(subject));
CHECK_EQ(subjectLine(subject, n), 0);
CHECK(untouched(subject, n));
CHECK_EQ(subjectLine(nullptr, sizeof(subject)), 0);
char san[128];
prime(san, sizeof(san));
const size_t s = sanList(san, sizeof(san));
CHECK_STR(san, "DNS:keyrelay.local, DNS:keyrelay, IP:192.168.4.1");
CHECK_EQ(s, strlen(san));
// Every name the README tells the operator to expect, taken from config.h rather
// than restated here.
CHECK(strstr(san, kCertCommonName) != nullptr);
CHECK(strstr(san, kHostname) != nullptr);
CHECK(strstr(san, kApGw) != nullptr);
// The subject and the list agree on the common name, because a human is shown one
// and the browser checks the other.
CHECK(strstr(san, kCertCommonName) == san + strlen("DNS:"));
prime(san, sizeof(san));
CHECK_EQ(sanList(san, s), 0);
CHECK(untouched(san, s));
CHECK_EQ(sanList(san, 0), 0);
CHECK_EQ(sanList(nullptr, sizeof(san)), 0);
size_t count = 0;
const SanEntry *entries = sanEntries(&count);
CHECK_EQ(count, 3);
CHECK(entries[0].type == SanType::Dns);
CHECK_STR(entries[0].value, kCertCommonName);
CHECK(entries[1].type == SanType::Dns);
CHECK_STR(entries[1].value, kHostname);
CHECK(entries[2].type == SanType::Ip);
CHECK_STR(entries[2].value, kApGw);
CHECK(sanEntries(nullptr) == entries); // one table, whoever asks
CHECK_STR(sanTypeName(SanType::Dns), "DNS:");
CHECK_STR(sanTypeName(SanType::Ip), "IP:");
}
CASE("kPemMax holds the largest block the generator can hand it");
{
// config.h sizes kPemMax from a measured certificate of ~430 DER bytes armoured to
// ~603; the DER scratch in tls.cpp is 704 bytes, so 704 is the worst case anything
// can ask to be armoured. wrapPem refuses rather than truncates, so if kPemMax ever
// falls below that, the board fails to get an identity at boot -- loudly, which is
// the point, but only if a test says so first.
fillBlob(der, 704);
const size_t n = wrapPem(kPemCertLabel, der, 704, pem, kPemMax);
CHECK(n > 0);
CHECK(n == strlen(pem));
CHECK(n < kPemMax);
CHECK_EQ(kPemMax, 1024); // the number in config.h, pinned so the headroom is a fact
}
CASE("sanExtensionDer: the GeneralNames a verifier actually parses");
{
uint8_t out[64];
const size_t n = sanExtensionDer(out, sizeof(out));
const std::string got = hexOf(out, n);
// SEQUENCE(32) { [2] "keyrelay.local", [2] "keyrelay", [7] C0 A8 04 01 }
CHECK_EQ(n, 34);
CHECK_STR(got.c_str(),
"3020820e6b657972656c61792e6c6f63616c82086b657972656c61798704c0a80401");
// Cross-checked, not asserted: a certificate signed with
// openssl req -x509 -key <P-256 key> -subj /CN=keyrelay.local
// -sha256 -addext "subjectAltName=DNS:keyrelay.local,DNS:keyrelay,IP:192.168.4.1"
// contains these exact 34 bytes, and `openssl asn1parse -inform DER` reads them back
// as SEQUENCE { cont[2] 14, cont[2] 8, cont[7] 4 }. This module's encoder and
// OpenSSL's agree byte for byte, which is what turns "the browser will accept it"
// into a measured claim instead of a hope.
CHECK_EQ(sanExtensionDer(out, n - 1), 0);
CHECK_EQ(sanExtensionDer(out, 1), 0);
CHECK_EQ(sanExtensionDer(nullptr, sizeof(out)), 0);
CHECK_EQ(sanExtensionDer(out, n), n); // an exact fit is a fit
}
CASE("kSanOidTlv: the extension's label, as DER content bytes and not as dotted text");
{
// RFC 5280 4.2.1.6: subjectAltName is 2.5.29.17, whose OBJECT IDENTIFIER is
// 06 03 55 1D 11 -- first arc 40*2 + 5 = 0x55, then 29 and 17. These five bytes are
// what makes the 34 above a SAN; without them the certificate carries a name list no
// client will read, and nothing on the board says so.
CHECK_EQ(kSanOidTlvLen, 5);
CHECK_EQ(kSanOidTlv[0], 0x06); // UNIVERSAL 6, OBJECT IDENTIFIER
CHECK_EQ(kSanOidTlv[1], 0x03); // three content bytes
CHECK_EQ(kSanOidTlv[2], 0x55); // 2.5
CHECK_EQ(kSanOidTlv[3], 0x1D); // .29
CHECK_EQ(kSanOidTlv[4], 0x11); // .17
size_t n = 0;
const uint8_t *oid = sanOidDer(&n);
CHECK_EQ(n, 3);
CHECK_EQ(oid[0], 0x55);
CHECK_EQ(oid[1], 0x1D);
CHECK_EQ(oid[2], 0x11);
CHECK_EQ(sanOidDer(nullptr), oid); // the count is optional, the bytes are not
// The failure this pins is the one that actually happened: the dotted string handed to
// the writer as if it were the encoding. Nine ASCII bytes are a legal OID --
// 1.10.46.53.46.50.57.46.49.55, as `openssl x509 -text` printed on a real board -- so
// the library accepts them, the certificate signs, the handshake completes and only the
// hostname check disagrees, in a sentence about the name. No byte of the text form may
// appear in what this module hands over.
for (size_t i = 0; i < n; i++) {
CHECK(oid[i] != '.' && !(oid[i] >= '0' && oid[i] <= '9'));
}
CHECK(!derContains(kSanOidTlv, kSanOidTlvLen, reinterpret_cast<const uint8_t *>("2.5"), 3));
// And the cross-check, the same way the 34 value bytes were: OpenSSL 4.0.3 writes
// 30 29 06 03 55 1d 11 04 22 <the 34 bytes>
// for `-addext "subjectAltName=DNS:keyrelay.local,DNS:keyrelay,IP:192.168.4.1"`, which
// is the sanExtensionFullDer() case below, byte for byte.
}
CASE("ASN.1 lengths: short form, long form, never either by accident");
{
CHECK_EQ(asn1LengthBytes(0), 1);
CHECK_EQ(asn1LengthBytes(127), 1);
CHECK_EQ(asn1LengthBytes(128), 2);
CHECK_EQ(asn1LengthBytes(255), 2);
CHECK_EQ(asn1LengthBytes(256), 3);
CHECK_EQ(asn1LengthBytes(65535), 3);
CHECK_EQ(asn1LengthBytes(65536), 4);
uint8_t len[8];
CHECK_EQ(asn1WriteLength(0, len), 1);
CHECK_EQ(len[0], 0x00);
CHECK_EQ(asn1WriteLength(127, len), 1);
CHECK_EQ(len[0], 0x7F);
CHECK_EQ(asn1WriteLength(128, len), 2);
CHECK_EQ(len[0], 0x81);
CHECK_EQ(len[1], 0x80);
CHECK_EQ(asn1WriteLength(300, len), 3); // 0x82 + two value bytes
CHECK_EQ(len[0], 0x82);
CHECK_EQ(len[1], 0x01);
CHECK_EQ(len[2], 0x2C); // 300 == 0x012C
CHECK_EQ(asn1WriteLength(65536, len), 4);
CHECK_EQ(len[0], 0x83);
CHECK_EQ(len[1], 0x01);
CHECK_EQ(len[2], 0x00);
CHECK_EQ(len[3], 0x00);
}
CASE("ipv4Bytes: a dotted quad or nothing");
{
uint8_t ip[4];
CHECK(ipv4Bytes("192.168.4.1", ip));
CHECK_EQ(ip[0], 192);
CHECK_EQ(ip[1], 168);
CHECK_EQ(ip[2], 4);
CHECK_EQ(ip[3], 1);
CHECK(ipv4Bytes("0.0.0.0", ip));
CHECK(ipv4Bytes("255.255.255.255", ip));
CHECK(!ipv4Bytes("256.1.1.1", ip));
CHECK(!ipv4Bytes("1.2.3.256", ip));
CHECK(!ipv4Bytes("1.2.3", ip)); // an address a resolver would silently expand
CHECK(!ipv4Bytes("1.2.3.", ip));
CHECK(!ipv4Bytes(".1.2.3", ip));
CHECK(!ipv4Bytes("1.2.3.4.5", ip));
CHECK(!ipv4Bytes("1.2.3.4 ", ip));
CHECK(!ipv4Bytes(" 1.2.3.4", ip));
CHECK(!ipv4Bytes("a.b.c.d", ip));
CHECK(!ipv4Bytes("", ip));
CHECK(!ipv4Bytes("1.2.3.999", ip));
CHECK(!ipv4Bytes(nullptr, ip));
CHECK(!ipv4Bytes("1.2.3.4", nullptr));
// The address the certificate actually carries has to parse, or nothing else here
// matters: this is the one value that goes on the wire.
CHECK(ipv4Bytes(kApGw, ip));
}
CASE("build date: __DATE__ parsed with the field widths it really has");
{
int y = 0;
int mo = 0;
int d = 0;
// Single-digit days are space padded to two characters: the classic mis-parse.
CHECK(parseBuildDate("Nov 8 2026", y, mo, d));
CHECK_EQ(y, 2026);
CHECK_EQ(mo, 11);
CHECK_EQ(d, 8);
CHECK(parseBuildDate("Jan 1 1970", y, mo, d));
CHECK_EQ(y, 1970);
CHECK_EQ(mo, 1);
CHECK_EQ(d, 1);
CHECK(parseBuildDate("Dec 31 2049", y, mo, d));
CHECK_EQ(y, 2049);
CHECK_EQ(mo, 12);
CHECK_EQ(d, 31);
CHECK(parseBuildDate("Feb 29 2028", y, mo, d)); // a leap day is a legal build date
CHECK_EQ(d, 29);
CHECK(!parseBuildDate("Nuv 8 2026", y, mo, d)); // month letters transposed
CHECK(!parseBuildDate("Foo 8 2026", y, mo, d));
CHECK(!parseBuildDate("Nov 8 26", y, mo, d)); // two-digit year
CHECK(!parseBuildDate("Nov 8 20261", y, mo, d)); // trailing junk
CHECK(!parseBuildDate("Nov 0 2026", y, mo, d));
CHECK(!parseBuildDate("Nov 32 2026", y, mo, d));
CHECK(!parseBuildDate("Nov", y, mo, d));
CHECK(!parseBuildDate("", y, mo, d));
CHECK(!parseBuildDate(nullptr, y, mo, d));
// The build this binary came from has to parse, or there is no identity at all.
CHECK(parseBuildDate(__DATE__, y, mo, d));
// And the window still has to fit UTCTime (1950..2049). This is a real, dated
// expiry: a build from 2040 onwards cannot issue a ten-year UTCTime certificate and
// would fail at boot with a build-date error. Better a red test in 2039 than a board
// that refuses to come up in 2040.
CHECK(y + kCertValidityYears <= 2049);
CHECK(y >= 2020);
CHECK_EQ(kCertValidityYears, 10);
}
CASE("calendar arithmetic: month ends, year ends, February");
{
CHECK(leapYear(2028));
CHECK(!leapYear(2026));
CHECK(!leapYear(1900));
CHECK(leapYear(2000));
CHECK_EQ(daysInMonth(2028, 2), 29);
CHECK_EQ(daysInMonth(2026, 2), 28);
CHECK_EQ(daysInMonth(2026, 1), 31);
CHECK_EQ(daysInMonth(2026, 4), 30);
CHECK_EQ(daysInMonth(2026, 0), 0);
CHECK_EQ(daysInMonth(2026, 13), 0);
int y, mo, d;
y = 2026; mo = 1; d = 1;
addDays(y, mo, d, -1);
CHECK_EQ(y, 2025); CHECK_EQ(mo, 12); CHECK_EQ(d, 31); // back over a year end
y = 2028; mo = 3; d = 1;
addDays(y, mo, d, -1);
CHECK_EQ(y, 2028); CHECK_EQ(mo, 2); CHECK_EQ(d, 29); // back into a leap day
y = 2026; mo = 12; d = 31;
addDays(y, mo, d, 1);
CHECK_EQ(y, 2027); CHECK_EQ(mo, 1); CHECK_EQ(d, 1);
y = 2026; mo = 1; d = 31;
addDays(y, mo, d, 1);
CHECK_EQ(y, 2026); CHECK_EQ(mo, 2); CHECK_EQ(d, 1);
y = 2026; mo = 6; d = 15;
addDays(y, mo, d, 0);
CHECK_EQ(y, 2026); CHECK_EQ(mo, 6); CHECK_EQ(d, 15);
// Ten years from a leap day lands on the last day that exists, never on a date the
// certificate writer would reject at build time.
y = 2028; mo = 2; d = 29;
addYears(y, mo, d, 1);
CHECK_EQ(y, 2029); CHECK_EQ(mo, 2); CHECK_EQ(d, 28);
y = 2028; mo = 2; d = 29;
addYears(y, mo, d, 4);
CHECK_EQ(y, 2032); CHECK_EQ(mo, 2); CHECK_EQ(d, 29); // 2032 is a leap year
y = 2026; mo = 1; d = 31;
addYears(y, mo, d, kCertValidityYears);
CHECK_EQ(y, 2036); CHECK_EQ(mo, 1); CHECK_EQ(d, 31);
y = 2026; mo = 11; d = 8;
addYears(y, mo, d, 0);
CHECK_EQ(y, 2026); CHECK_EQ(mo, 11); CHECK_EQ(d, 8);
}
CASE("utcTime: the string the certificate writer parses, or a refusal");
{
char t[32];
prime(t, sizeof(t));
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 0, 0, 0), 14);
CHECK_STR(t, "20261108000000");
CHECK_EQ(utcTime(t, sizeof(t), 2049, 12, 31, 23, 59, 59), 14);
CHECK_STR(t, "20491231235959");
CHECK_EQ(utcTime(t, sizeof(t), 1950, 1, 1, 0, 0, 0), 14);
CHECK_STR(t, "19500101000000");
CHECK_EQ(utcTime(t, sizeof(t), 2028, 2, 29, 12, 34, 56), 14);
CHECK_STR(t, "20280229123456");
// Exactly enough room, and one byte short of it.
CHECK_EQ(utcTime(t, 15, 2026, 11, 8, 0, 0, 0), 14);
prime(t, sizeof(t));
CHECK_EQ(utcTime(t, 14, 2026, 11, 8, 0, 0, 0), 0);
CHECK(untouched(t, 14));
// Outside UTCTime's window: GeneralizedTime is a different encoding, and quietly
// emitting one is how every handshake starts failing in 2050.
CHECK_EQ(utcTime(t, sizeof(t), 1949, 12, 31, 0, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2050, 1, 1, 0, 0, 0), 0);
// Components out of range, including the date that does not exist.
CHECK_EQ(utcTime(t, sizeof(t), 2026, 0, 1, 0, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 13, 1, 0, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 0, 0, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 31, 0, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 2, 29, 0, 0, 0), 0); // 2026 is not a leap year
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 24, 0, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 0, 60, 0), 0);
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 0, 0, 61), 0);
CHECK_EQ(utcTime(nullptr, sizeof(t), 2026, 11, 8, 0, 0, 0), 0);
// :60 is the leap-second slot RFC 5280 allows; accepting it is not a bug.
CHECK_EQ(utcTime(t, sizeof(t), 2026, 11, 8, 23, 59, 60), 14);
}
CASE("the boot path's own sequence, with a blob where the key would be");
{
// What task 02 does at boot: produce a certificate DER, armour it, store the text,
// read it back out of storage, hand the bytes to the library. Every step here is the
// real code; only key generation is replaced by a blob, because the host has no
// crypto library and must not gain one.
fillBlob(der, 430);
const size_t n = wrapPem(kPemCertLabel, der, 430, pem, kPemMax);
CHECK(n > 0);
CHECK(n == strlen(pem));
// The NVS record is read back as a C string: the armour must carry no NUL of its own
// and must end with the newline the writer was asked to leave in.
CHECK(pem[n] == '\0');
CHECK(pem[n - 1] == '\n');
uint8_t parsed[1024];
size_t parsedLen = 0;
CHECK(pemFind(kPemCertLabel, pem, parsed, sizeof(parsed), &parsedLen));
CHECK_EQ(parsedLen, 430);
CHECK(memcmp(parsed, der, 430) == 0);
// A record whose tail was lost: refused, not decoded into something shorter.
const std::string torn(pem, n - 30);
CHECK(!pemFind(kPemCertLabel, torn.c_str(), parsed, sizeof(parsed), &parsedLen));
// The private key travels through the same two functions under its own label, and a
// reader looking for the certificate must not find it.
fillBlob(der, 121);
char keyPem[512];
CHECK(wrapPem(kPemKeyLabel, der, 121, keyPem, sizeof(keyPem)) > 0);
CHECK(!pemFind(kPemCertLabel, keyPem, parsed, sizeof(parsed), &parsedLen));
CHECK(pemFind(kPemKeyLabel, keyPem, parsed, sizeof(parsed), &parsedLen));
CHECK_EQ(parsedLen, 121);
CHECK(memcmp(parsed, der, 121) == 0);
}
CASE("fingerprintPrefix: the status frame's label, or a refusal");
{
// The status frame carries the leading bytes of the fingerprint so the page can show the
// same label `cert` prints. Half a stored fingerprint must not turn into a shorter label
// that still looks like a label: the whole job of the value is "same board as before".
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
char out[80]; // a whole 64-character fingerprint plus the NUL
prime(out, sizeof(out));
CHECK(fingerprintPrefix(fp, 16, out, sizeof(out)));
CHECK_STR(out, "ba7816bf8f01cfea");
CHECK(fingerprintPrefix(fp, 64, out, sizeof(out)));
CHECK_STR(out, fp);
// Room is exact -- 16 characters need 17 bytes -- and one byte short writes nothing
// rather than a 15-character label somebody believes.
CHECK(fingerprintPrefix(fp, 16, out, 17));
prime(out, sizeof(out));
CHECK(!fingerprintPrefix(fp, 16, out, 16));
CHECK(untouched(out, 16));
// Not hex, or too short for the prefix asked for: a torn NVS record is a refusal, never
// a prefix of garbage. Upper case is still hex -- refusing it would make a pin somebody
// typed in upper case look like a different board.
CHECK(!fingerprintPrefix("ba78zzzzzzzzzzzzzz", 16, out, sizeof(out)));
CHECK(!fingerprintPrefix("ba78", 16, out, sizeof(out)));
CHECK(!fingerprintPrefix("", 16, out, sizeof(out)));
CHECK(fingerprintPrefix("BA7816BF8F01CFEA", 16, out, sizeof(out)));
CHECK_STR(out, "BA7816BF8F01CFEA");
CHECK(!fingerprintPrefix(nullptr, 16, out, sizeof(out)));
CHECK(!fingerprintPrefix(fp, 16, nullptr, sizeof(out)));
// A zero-length prefix is a legitimate empty string, and still needs room for the NUL.
CHECK(fingerprintPrefix(fp, 0, out, 1));
CHECK_STR(out, "");
}
CASE("fingerprintPairs: two lines the CDC transmit ring cannot swallow");
{
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
char line[128];
prime(line, sizeof(line));
CHECK_EQ(fingerprintPairs(fp, 0, 16, line, sizeof(line)), 47);
CHECK_STR(line, "ba:78:16:bf:8f:01:cf:ea:41:41:40:de:5d:ae:22:23");
CHECK_EQ(fingerprintPairs(fp, 16, 16, line, sizeof(line)), 47);
CHECK_STR(line, "b0:03:61:a3:96:17:7a:9c:b4:10:ff:61:f2:00:15:ad");
// The two lines the console prints are 47 characters each, which with the 8-space indent
// stays under the 128-byte TinyUSB CDC transmit ring. That is the entire reason the print
// is wrapped: an unwrapped 95-character line with the global transmit timeout at 0 is
// dropped mid-value, which is README bug 1 wearing a fingerprint.
CHECK(47 + 8 < 128);
// Nothing is lost on the way: take the colons out of both lines and it is the digest.
char first[64], second[64];
CHECK_EQ(fingerprintPairs(fp, 0, 16, first, sizeof(first)), 47);
CHECK_EQ(fingerprintPairs(fp, 16, 16, second, sizeof(second)), 47);
std::string rejoined;
for (const char *p = first; *p; p++) {
if (*p != ':') rejoined += *p;
}
for (const char *p = second; *p; p++) {
if (*p != ':') rejoined += *p;
}
CHECK_EQ(rejoined.size(), 64);
CHECK_STR(rejoined.c_str(), fp);
// One byte short of the 48 it needs is a refusal that writes nothing; so is a source that
// stops being hex partway, which must not leave half a fingerprint in the buffer.
prime(line, sizeof(line));
CHECK_EQ(fingerprintPairs(fp, 0, 16, line, 47), 0);
CHECK(untouched(line, 47));
std::string bad(fp);
bad[4] = 'z';
prime(line, sizeof(line));
CHECK_EQ(fingerprintPairs(bad.c_str(), 0, 16, line, sizeof(line)), 0);
CHECK(untouched(line, 47));
// An empty range, or one that runs off the end of a 32-byte digest: refused, because
// printing past the digest is how a fingerprint grows characters it never had.
CHECK_EQ(fingerprintPairs(fp, 0, 0, line, sizeof(line)), 0);
CHECK_EQ(fingerprintPairs(fp, 32, 1, line, sizeof(line)), 0);
CHECK_EQ(fingerprintPairs(fp, 31, 2, line, sizeof(line)), 0);
CHECK_EQ(fingerprintPairs(fp, 31, 1, line, sizeof(line)), 2); // the last pair alone
CHECK_STR(line, "ad");
CHECK_EQ(fingerprintPairs(nullptr, 0, 16, line, sizeof(line)), 0);
CHECK_EQ(fingerprintPairs(fp, 0, 16, nullptr, sizeof(line)), 0);
}
CASE("derContains: the certificate decides whether the printed names are true");
{
// `cert` prints the name list from sanEntries(); this is the check that the stored
// certificate really carries those bytes, so the console can say "and the certificate
// agrees" rather than implying it. The needle is the exact extension value, so a name
// changed by one character is a refusal, not a near match.
uint8_t san[kSanDerMax];
const size_t sanLen = sanExtensionDer(san, sizeof(san));
CHECK_EQ(sanLen, 34);
std::vector<uint8_t> cert;
for (size_t i = 0; i < 120; i++) cert.push_back(static_cast<uint8_t>(0xA0 ^ (i & 0x3F)));
cert.insert(cert.end(), san, san + sanLen);
for (size_t i = 0; i < 200; i++) cert.push_back(static_cast<uint8_t>(i * 7));
CHECK(derContains(cert.data(), cert.size(), san, sanLen));
// An empty needle is true of every buffer in existence, so it is a refusal instead: the
// answer has to be about these bytes.
CHECK(!derContains(cert.data(), cert.size(), san, 0));
CHECK(!derContains(cert.data(), 10, san, sanLen));
CHECK(!derContains(nullptr, cert.size(), san, sanLen));
CHECK(!derContains(cert.data(), cert.size(), nullptr, sanLen));
// One byte different in the needle and the certificate no longer carries it...
uint8_t torn[kSanDerMax];
memcpy(torn, san, sanLen);
torn[sanLen - 1] ^= 0x01;
CHECK(!derContains(cert.data(), cert.size(), torn, sanLen));
// ... and one byte different inside the certificate's own SAN, either.
std::vector<uint8_t> other = cert;
other[120 + 1] ^= 0x01; // the first byte of the first DNS name
CHECK(!derContains(other.data(), other.size(), san, sanLen));
// A one-byte haystack is not a special case that only matches at offset 0 by luck.
const uint8_t one = 0x30;
CHECK(derContains(&one, 1, &one, 1));
CHECK(!derContains(&one, 1, san, sanLen));
// Found at the very end of a buffer too -- an off-by-one in the loop bound shows up here
// and nowhere else, because the SAN is never at the end of a real certificate.
std::vector<uint8_t> tailCert(64, 0x5A);
tailCert.insert(tailCert.end(), san, san + sanLen);
CHECK(derContains(tailCert.data(), tailCert.size(), san, sanLen));
}
CASE("sanExtensionFullDer / derCarriesSan: the extension a client sees, or none");
{
uint8_t full[kSanExtensionFullDerMax];
prime(reinterpret_cast<char *>(full), sizeof(full));
const size_t n = sanExtensionFullDer(full, sizeof(full));
// SEQUENCE(41) { OID(3) 55 1D 11, OCTET STRING(34) <GeneralNames> }, non-critical so no
// BOOLEAN -- and these 43 bytes are lifted from a certificate OpenSSL issued for the
// same three names, not derived from this encoder's own output.
CHECK_EQ(n, 43);
CHECK_STR(hexOf(full, n).c_str(),
"30290603551d1104223020820e6b657972656c61792e6c6f63616c82086b657972"
"656c61798704c0a80401");
CHECK_EQ(sanExtensionFullDer(full, n), n); // exact fit
prime(reinterpret_cast<char *>(full), sizeof(full));
CHECK_EQ(sanExtensionFullDer(full, n - 1), 0); // one short writes nothing...
CHECK(untouched(reinterpret_cast<const char *>(full), n - 1));
CHECK_EQ(sanExtensionFullDer(nullptr, sizeof(full)), 0);
uint8_t san[kSanDerMax];
const size_t sanLen = sanExtensionDer(san, sizeof(san));
CHECK_EQ(sanLen, 34);
// A certificate that carries the extension this build writes: yes.
std::vector<uint8_t> cert(90, 0x3C);
sanExtensionFullDer(full, sizeof(full));
cert.insert(cert.end(), full, full + n);
cert.insert(cert.end(), {0x03, 0x47, 0x00});
CHECK(derCarriesSan(cert.data(), cert.size()));
CHECK(derCarriesSan(cert.data(), 90 + n)); // the extension ends exactly here
CHECK(!derCarriesSan(cert.data(), 90 + n - 1)); // one byte short of carrying it
// The certificate a board actually issued before this was fixed -- the right 34 value
// bytes, wrapped the right way, under the OID 1.10.46.53.46.50.57.46.49.55 (the ASCII
// of "2.5.29.17"). It has no subjectAlternativeName as far as any client is concerned,
// and this is where that becomes an observation instead of a surprise. Note what the
// weaker question answers here: the value really is in the certificate.
static const uint8_t kPreFix[] = {
0x30, 0x2F, 0x06, 0x09, '2', '.', '5', '.', '2', '9', '.', '1',
'7', 0x04, 0x22,
};
std::vector<uint8_t> legacy;
legacy.insert(legacy.end(), kPreFix, kPreFix + sizeof(kPreFix));
legacy.insert(legacy.end(), san, san + sanLen);
legacy.insert(legacy.end(), 40, 0x11);
CHECK(derContains(legacy.data(), legacy.size(), san, sanLen)); // the easy question: yes
CHECK(!derCarriesSan(legacy.data(), legacy.size())); // the real question: no
// One byte of the OID and it is a different extension: a name list under a label nobody
// asked for is the same failure in a different costume.
std::vector<uint8_t> shifted = cert;
const size_t oidAt = 90 + 4; // SEQUENCE(2) + tag + length of the OID
CHECK_EQ(shifted[oidAt], 0x55);
shifted[oidAt] = 0x54;
CHECK(!derCarriesSan(shifted.data(), shifted.size()));
// The value alone, or the OID alone, is not the extension.
std::vector<uint8_t> onlyValue(64, 0x07);
onlyValue.insert(onlyValue.end(), san, san + sanLen);
CHECK(!derCarriesSan(onlyValue.data(), onlyValue.size()));
std::vector<uint8_t> onlyOid(64, 0x07);
onlyOid.insert(onlyOid.end(), kSanOidTlv, kSanOidTlv + kSanOidTlvLen);
CHECK(!derCarriesSan(onlyOid.data(), onlyOid.size()));
// Nothing to search, and nothing to search with.
CHECK(!derCarriesSan(nullptr, 100));
CHECK(!derCarriesSan(cert.data(), 0));
}
CASE("dateTimeText: a validity bound as a person reads it");
{
char t[32];
prime(t, sizeof(t));
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 0, 0, 0), 23);
CHECK_STR(t, "2026-11-08 00:00:00 UTC");
CHECK_EQ(dateTimeText(t, sizeof(t), 2036, 11, 7, 23, 59, 59), 23);
CHECK_STR(t, "2036-11-07 23:59:59 UTC");
// Exactly enough room, and one byte short of it.
CHECK_EQ(dateTimeText(t, 24, 2026, 11, 8, 0, 0, 0), 23);
prime(t, sizeof(t));
CHECK_EQ(dateTimeText(t, 23, 2026, 11, 8, 0, 0, 0), 0);
CHECK(untouched(t, 23));
// The same component checks utcTime() applies before a certificate is written, including
// the February that does not exist -- a date this tree would never have encoded cannot be
// printed as if it had been.
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 2, 29, 0, 0, 0), 0);
CHECK_EQ(dateTimeText(t, sizeof(t), 2028, 2, 29, 0, 0, 0), 23);
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 13, 1, 0, 0, 0), 0);
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 0, 1, 0, 0, 0), 0);
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 31, 0, 0, 0), 0);
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 24, 0, 0), 0);
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 0, 60, 0), 0);
// :60 is RFC 5280's leap-second slot, accepted here exactly as utcTime() accepts it.
CHECK_EQ(dateTimeText(t, sizeof(t), 2026, 11, 8, 23, 59, 60), 23);
// NOT utcTime's 1950..2049 window: reading a date is not the same permission as writing
// one, and a certificate outliving UTCTime is the writer's problem, not the reader's.
CHECK_EQ(dateTimeText(t, sizeof(t), 2050, 1, 1, 0, 0, 0), 23);
CHECK_EQ(dateTimeText(t, sizeof(t), 1949, 12, 31, 0, 0, 0), 23);
CHECK_EQ(dateTimeText(nullptr, sizeof(t), 2026, 11, 8, 0, 0, 0), 0);
}
return report("test_tls_pem");
}