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`.
This commit is contained in:
@@ -0,0 +1,993 @@
|
||||
// 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");
|
||||
}
|
||||
Reference in New Issue
Block a user