From 2d02c3c66abf5c67ca231de85313b5455da4baa1 Mon Sep 17 00:00:00 2001 From: Emma Stensland Date: Tue, 2 Jun 2026 12:47:50 -0600 Subject: [PATCH] F-5024 improved rand parsing --- src/tools/clu_funcs.c | 6 + src/tools/clu_rand.c | 249 +++++++++++++++++++++++++--- tests/rand/rand-test.py | 358 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 571 insertions(+), 42 deletions(-) diff --git a/src/tools/clu_funcs.c b/src/tools/clu_funcs.c index 603bfccf..af6710bb 100644 --- a/src/tools/clu_funcs.c +++ b/src/tools/clu_funcs.c @@ -1675,6 +1675,12 @@ int wolfCLU_GetOpt(int argc, char** argv, const char *options, /* if index matches *opt_index at first position or if index is found */ if (index == *opt_index+1 || (*opt_index !=0 && index > 0)) { if (long_options[i].has_arg == 1) { + /* required_argument binds the value as the token directly + * following the option. The positional rescan in + * clu_rand.c (wolfCLU_Rand) re-derives this same binding by + * hand; any change here to how/when optarg is bound (e.g. + * adding --opt=value handling, optional_argument support, or + * argv permutation) must be reflected there too. */ optarg=argv[index+1]; } return long_options[i].val; diff --git a/src/tools/clu_rand.c b/src/tools/clu_rand.c index 100d8f19..9bd72946 100644 --- a/src/tools/clu_rand.c +++ b/src/tools/clu_rand.c @@ -23,6 +23,12 @@ #include #include +/* Fallback for RNG configs that leave RNG_MAX_BLOCK_LEN undefined. If a build's + * real per-call limit is smaller, wc_RNG_GenerateBlock fails cleanly. */ +#ifndef RNG_MAX_BLOCK_LEN + #define RNG_MAX_BLOCK_LEN (0x10000) +#endif + static const struct option rand_options[] = { {"-out", required_argument, 0, WOLFCLU_OUTFILE}, {"-base64", no_argument, 0, WOLFCLU_BASE64 }, @@ -39,6 +45,26 @@ static void wolfCLU_RandHelp(void) WOLFCLU_LOG(WOLFCLU_L0, "\t-hex output the results in hex encoding"); } +/* Look up token in rand_options[]. On a match, set *dupOpt if already in seen[], + * then mark it seen. Returns the table index, or -1 if not a known option. + * Shared by the skipNext and main scan paths so duplicate detection stays in + * sync. seen[] must have one slot per rand_options[] entry. */ +static int wolfCLU_RandMarkOption(const char* token, int* seen, + const char** dupOpt) +{ + int j; + for (j = 0; rand_options[j].name != NULL; j++) { + if (XSTRCMP(token, rand_options[j].name) == 0) { + if (seen[j]) { + *dupOpt = token; + } + seen[j] = 1; + return j; + } + } + return -1; +} + int wolfCLU_Rand(int argc, char** argv) { @@ -51,24 +77,19 @@ int wolfCLU_Rand(int argc, char** argv) int option; int longIndex = 1; WOLFSSL_BIO *bioOut = NULL; +#ifndef WOLFCLU_NO_FILESYSTEM + /* deferred: opened only after the count validates */ + char *outFile = NULL; +#endif byte *buf = NULL; - /* last parameter is the rand bytes output size. Match -h/-help exactly - * so other -h-prefixed flags (e.g. -hex) aren't swallowed as a help - * request. */ + /* Match -h/-help exactly so -h-prefixed flags (e.g. -hex) aren't taken as + * help. */ if (XSTRCMP("-h", argv[argc-1]) == 0 || XSTRCMP("-help", argv[argc-1]) == 0) { wolfCLU_RandHelp(); return WOLFCLU_SUCCESS; } - else { - size = XATOI(argv[argc-1]); - if (size <= 0) { - wolfCLU_LogError("Unable to convert %s to a number", - argv[argc-1]); - ret = WOLFCLU_FATAL_ERROR; - } - } opterr = 0; /* do not display unrecognized options */ optind = 0; /* start at indent 0 */ @@ -85,15 +106,19 @@ int wolfCLU_Rand(int argc, char** argv) case WOLFCLU_OUTFILE: #ifdef WOLFCLU_NO_FILESYSTEM - WOLFCLU_LOG(WOLFCLU_E0, "No filesystem support. Unable to open input file"); + WOLFCLU_LOG(WOLFCLU_E0, "No filesystem support. Unable to open output file"); ret = WOLFCLU_FATAL_ERROR; #else - bioOut = wolfSSL_BIO_new_file(optarg, "wb"); - if (bioOut == NULL) { - wolfCLU_LogError("Unable to open output file %s", - optarg); + /* optarg is NULL when -out is the final token with no filename. + * Reject it: the deferred open would otherwise skip and dump + * bytes to stdout instead of erroring. */ + if (optarg == NULL) { + wolfCLU_LogError("Missing filename argument for -out"); ret = WOLFCLU_FATAL_ERROR; } + else { + outFile = optarg; /* defer open until the count validates */ + } #endif break; @@ -112,20 +137,181 @@ int wolfCLU_Rand(int argc, char** argv) } + /* The byte count is the single positional that is neither an option flag + * nor the value consumed by -out. A manual rescan is needed because + * wolfCLU_GetOpt's optind indexes the option table, not argv, so it never + * surfaces leftover positionals. Resolving it here makes `rand -out 32` + * treat 32 as the path and `rand -hex 16 -out f` keep 16. + * + * The walk mirrors GetOpt's binding: for a required_argument option GetOpt + * sets optarg = argv[index+1], so this scan skips the next token + * (skipNext). Only the recovered byte count matters, so the one divergence + * (a value that is itself an option name is treated here only as the bound + * value) is benign. Any has_arg arity the scan doesn't model is rejected + * loudly below, turning a silent desync into a test-visible failure. */ + if (ret == WOLFCLU_SUCCESS) { + int i; + int countIdx = -1; + int skipNext = 0; /* next token is a value bound to the prior opt */ + const char* extra = NULL; /* second positional token, if any */ + const char* badOpt = NULL; /* first unrecognized -flag, if any */ + const char* dupOpt = NULL; /* first option repeated, if any */ + /* one slot per rand_options[] entry; marks options already seen */ + int seen[sizeof(rand_options) / sizeof(rand_options[0])]; + + XMEMSET(seen, 0, sizeof(seen)); + + for (i = 2; i < argc; i++) { /* skip argv[0]=prog and argv[1]="rand" */ + int j; + int matched = 0; + + if (argv[i] == NULL) { + break; + } + + if (skipNext) { + skipNext = 0; /* this token was bound to the preceding option */ + /* A token bound as a value may itself be an option name (e.g. + * `rand -out -out 16`). The main scan never runs for a swallowed + * token, so check and mark it seen here too. This keeps + * duplicate detection symmetric whether or not a flag's first + * appearance landed in a value slot. */ + wolfCLU_RandMarkOption(argv[i], seen, &dupOpt); + if (dupOpt != NULL) { + break; + } + continue; + } + + /* -h/-help are handled globally, not listed in rand_options[] */ + if (XSTRCMP(argv[i], "-h") == 0 || XSTRCMP(argv[i], "-help") == 0) { + continue; + } + + /* Match against the option table. GetOpt rejects any option that + * appears more than once, so the value is never bound and, for -out, + * outFile stays NULL and output falls back to stdout. Catch the + * repeat here so a duplicate -out errors instead of leaking bytes. */ + j = wolfCLU_RandMarkOption(argv[i], seen, &dupOpt); + if (j >= 0) { + matched = 1; + if (dupOpt != NULL) { + break; + } + if (rand_options[j].has_arg == required_argument) { + /* mirror GetOpt: optarg = argv[index+1] */ + skipNext = 1; + } + else if (rand_options[j].has_arg != no_argument) { + /* Arity this scan doesn't model; fail loudly instead of + * mis-reading the option's value as the count. */ + wolfCLU_LogError("internal error: option %s has an " + "unsupported argument arity", argv[i]); + ret = WOLFCLU_FATAL_ERROR; + } + } + /* Redundant today: the j >= 0 block already breaks on a duplicate. + * Kept as a safety net so a future edit dropping that break can't + * let a duplicate fall through to positional handling. */ + if (ret != WOLFCLU_SUCCESS || dupOpt != NULL) { + break; + } + if (matched) { + continue; + } + + /* A leftover '-' token is an unknown flag, not a count. With + * opterr = 0 it would otherwise be miscounted as a positional. */ + if (argv[i][0] == '-') { + badOpt = argv[i]; + break; + } + + if (countIdx == -1) { + countIdx = i; + } + else { + /* a second positional is already an error; stop scanning */ + extra = argv[i]; + break; + } + } + + if (ret != WOLFCLU_SUCCESS) { + /* the arity guard above already logged and failed */ + } + else if (dupOpt != NULL) { + /* Log locally rather than rely on GetOpt's own duplicate message, + * keeping this branch self-contained and consistent with its + * siblings. A duplicated message is acceptable; a silent non-zero + * exit is not. Note GetOpt's wolfCLU_checkForArg already printed + * its own "argument found twice" line during the option pass, so + * two differently-worded lines appear for one duplicate; that + * double output is expected, not a bug. */ + wolfCLU_LogError("Option %s specified more than once", dupOpt); + ret = WOLFCLU_FATAL_ERROR; + } + else if (badOpt != NULL) { + wolfCLU_LogError("Unrecognized option %s", badOpt); + ret = WOLFCLU_FATAL_ERROR; + } + else if (countIdx == -1) { + wolfCLU_LogError("Missing argument"); + ret = WOLFCLU_FATAL_ERROR; + } + else if (extra != NULL) { + wolfCLU_LogError( + "Expected a single argument, got extra " + "token %s", extra); + ret = WOLFCLU_FATAL_ERROR; + } + else { + size = XATOI(argv[countIdx]); + if (size <= 0) { + /* Reached for "0", a non-numeric token (XATOI yields 0), or an + * all-digit token that overflows to a non-positive value. A + * bare "-5" never lands here: the rescan rejects '-'-prefixed + * tokens as unrecognized options first. */ + wolfCLU_LogError( + "Expected a positive count, got %s", + argv[countIdx]); + ret = WOLFCLU_FATAL_ERROR; + } + } + } + if (ret == WOLFCLU_SUCCESS && useBase64 && useHex) { wolfCLU_LogError("-base64 and -hex are mutually exclusive"); ret = WOLFCLU_FATAL_ERROR; } - /* Fail fast on a size that would overflow the hex buffer length - * (size * 2). Validate before the RNG allocation so an over-large - * request does not first burn an O(GB) malloc and the matching - * RNG fill. */ + /* Reject sizes that overflow the hex length (size * 2) before the RNG + * allocation, so an over-large request doesn't first burn a huge malloc + * and RNG fill. Raw output is uncapped. */ if (ret == WOLFCLU_SUCCESS && useHex && size > INT_MAX / 2) { wolfCLU_LogError("requested size too large for -hex output"); ret = WOLFCLU_FATAL_ERROR; } + /* Same concern for base64: its ~4/3 expansion (<2x) is carried back into + * the signed int `size`, so INT_MAX/2 is a safe cap. */ + if (ret == WOLFCLU_SUCCESS && useBase64 && size > INT_MAX / 2) { + wolfCLU_LogError("requested size too large for -base64 output"); + ret = WOLFCLU_FATAL_ERROR; + } + +#ifndef WOLFCLU_NO_FILESYSTEM + /* Open output ("wb") only after the count validates, so a bad/missing + * count never truncates an existing file. */ + if (ret == WOLFCLU_SUCCESS && outFile != NULL) { + bioOut = wolfSSL_BIO_new_file(outFile, "wb"); + if (bioOut == NULL) { + wolfCLU_LogError("Unable to open output file %s", outFile); + ret = WOLFCLU_FATAL_ERROR; + } + } +#endif + if (ret == WOLFCLU_SUCCESS) { buf = (byte*)XMALLOC(size, HEAP_HINT, DYNAMIC_TYPE_TMP_BUFFER); if (buf == NULL) { @@ -140,9 +326,21 @@ int wolfCLU_Rand(int argc, char** argv) ret = WOLFCLU_FATAL_ERROR; } else { - if (wc_RNG_GenerateBlock(&rng, buf, size) != 0) { - wolfCLU_LogError("Unable to generate RNG block"); - ret = WOLFCLU_FATAL_ERROR; + /* wc_RNG_GenerateBlock rejects requests larger than the DRBG + * per-call max (RNG_MAX_BLOCK_LEN), so fill in chunks. */ + int generated = 0; + while (ret == WOLFCLU_SUCCESS && generated < size) { + word32 chunk = (word32)(size - generated); + if (chunk > RNG_MAX_BLOCK_LEN) { + chunk = RNG_MAX_BLOCK_LEN; + } + if (wc_RNG_GenerateBlock(&rng, buf + generated, chunk) != 0) { + wolfCLU_LogError("Unable to generate RNG block"); + ret = WOLFCLU_FATAL_ERROR; + } + else { + generated += (int)chunk; + } } wc_FreeRng(&rng); } @@ -229,8 +427,8 @@ int wolfCLU_Rand(int argc, char** argv) ret = WOLFCLU_FATAL_ERROR; } else if (useHex && outIsStdout) { - /* Match `openssl rand -hex` and avoid the next shell prompt - * landing on the same line as the hex output. */ + /* Trailing newline so the next shell prompt isn't on the same + * line as the output. */ (void)wolfSSL_BIO_write(bioOut, "\n", 1); } } @@ -247,4 +445,3 @@ int wolfCLU_Rand(int argc, char** argv) return WOLFCLU_FATAL_ERROR; #endif } - diff --git a/tests/rand/rand-test.py b/tests/rand/rand-test.py index 68be8f52..598839cb 100644 --- a/tests/rand/rand-test.py +++ b/tests/rand/rand-test.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 """Random number generation tests for wolfCLU.""" +import base64 import os +import subprocess import sys import unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from wolfclu_test import run_wolfssl, test_main +from wolfclu_test import WOLFSSL_BIN, run_wolfssl, test_main class RandTest(unittest.TestCase): @@ -42,21 +44,49 @@ def test_output_file(self): self.assertEqual(r.returncode, 0, r.stderr) self.assertTrue(os.path.isfile(out), "entropy.txt not created") + def test_count_after_out_value(self): + """`rand -out ` binds to -out and as the + byte count, even though sits immediately after the -out value. + + Locks the positional rescan's binding contract against + wolfCLU_GetOpt (see clu_rand.c / clu_funcs.c optarg binding).""" + out = "count_after_out.bin" + self.addCleanup(lambda: os.remove(out) + if os.path.exists(out) else None) + + r = run_wolfssl("rand", "-out", out, "16") + self.assertEqual(r.returncode, 0, r.stderr) + self.assertTrue(os.path.isfile(out), "%s not created" % out) + self.assertEqual(os.path.getsize(out), 16, + "expected 16 raw bytes; count after -out value " + "must still be read as the byte count") + def test_hex_stdout_length(self): - """`-hex` to stdout produces 2 hex chars per byte plus trailing \\n.""" + """`-hex N` emits 2 lowercase hex chars per byte plus a trailing \\n.""" r = run_wolfssl("rand", "-hex", "16") self.assertEqual(r.returncode, 0, r.stderr) - # 32 hex chars + newline so the next shell prompt isn't on the same - # line (matches `openssl rand -hex` behavior). self.assertEqual(len(r.stdout), 33, "expected 32 hex chars + newline from -hex 16") self.assertEqual(r.stdout[-1], "\n", "missing trailing newline") self.assertTrue(all(c in "0123456789abcdef" for c in r.stdout[:-1]), "output is not lowercase hex") + def test_plain_raw_to_stdout(self): + """`rand N` with no flags writes exactly N raw bytes to stdout. + + No encoding, no trailing newline. Captured as raw bytes because + run_wolfssl decodes as text and would choke on non-UTF-8 output.""" + n = 16 + r = subprocess.run([WOLFSSL_BIN, "rand", str(n)], + capture_output=True, stdin=subprocess.DEVNULL, + timeout=60) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(len(r.stdout), n, + "plain `rand %d` must emit exactly %d raw bytes " + "(no encoding, no trailing newline)" % (n, n)) + def test_hex_to_file(self): - """`-hex -out file` writes a hex-only file (no trailing newline) - suitable for direct use as -inkey input.""" + """`-hex -out file` writes a hex-only file with no trailing newline.""" out = "hex_rand.hex" self.addCleanup(lambda: os.remove(out) if os.path.exists(out) else None) @@ -76,29 +106,325 @@ def test_hex_and_base64_mutually_exclusive(self): self.assertNotEqual(r.returncode, 0, "-hex with -base64 must error out") + def _assert_dup_errors_no_leak(self, args, what): + """Run `rand `, asserting it errors and emits no stdout bytes. + + stdout is captured as RAW bytes (not text=True like run_wolfssl): the + leak these tests guard against is ~16 random bytes, which are almost + always invalid UTF-8, so a reintroduced leak would raise + UnicodeDecodeError during decoding and ERROR the test before the + assertions ran. Raw capture makes a leak fail on the explicit length + assertion with a clear message instead, mirroring + test_plain_raw_to_stdout.""" + r = subprocess.run([WOLFSSL_BIN, "rand", *args], + capture_output=True, stdin=subprocess.DEVNULL, + timeout=60) + self.assertNotEqual(r.returncode, 0, "%s must error out" % what) + self.assertEqual(len(r.stdout), 0, + "%s must not leak random bytes to stdout" % what) + return r + + def test_duplicate_out_does_not_leak_to_stdout(self): + """Regression: `rand -out f1 N -out f2` must error, not leak bytes. + + wolfCLU_GetOpt rejects a repeated -out (argument found twice) and + never binds the filename, so outFile stays NULL while the positional + rescan still recovers N. That combination once dumped N random bytes + to stdout. The rescan now rejects the duplicate before any RNG output.""" + f1 = "dup_out_1.bin" + f2 = "dup_out_2.bin" + for f in (f1, f2): + self.addCleanup(lambda p=f: os.remove(p) + if os.path.exists(p) else None) + + self._assert_dup_errors_no_leak(["-out", f1, "16", "-out", f2], + "duplicate -out") + self.assertFalse(os.path.exists(f1), + "no output file should be created on duplicate -out") + self.assertFalse(os.path.exists(f2), + "no output file should be created on duplicate -out") + + def test_duplicate_out_in_value_slot_does_not_leak(self): + """Regression: `rand -out -out 16` must error, not leak bytes. + + Here the repeated -out lands in the slot the positional rescan would + swallow as the first -out's bound value (skipNext), so the seen[] + guard never ran for it. GetOpt still refused to bind optarg (argument + found twice), leaving outFile NULL, and 16 random bytes once reached + stdout with exit 0. The skipNext path now checks the swallowed token + against the option table before discarding it.""" + # Guard against a regression that bound the second -out to a "-out" or + # "16" filename: no stray artifact should appear either. + for f in ("-out", "16"): + self.addCleanup(lambda p=f: os.remove(p) + if os.path.exists(p) else None) + self._assert_dup_errors_no_leak(["-out", "-out", "16"], + "duplicate -out in value slot") + self.assertFalse(os.path.exists("-out"), + "no '-out' file should be created on duplicate -out") + self.assertFalse(os.path.exists("16"), + "no '16' file should be created on duplicate -out") + + def test_duplicate_out_trailing_repeat_rejected(self): + """`rand -out f1 16 -out`: the trailing -out repeat (count already + consumed) must error too, exercising the main-loop seen[] path.""" + f1 = "dup_out_trailing.bin" + self.addCleanup(lambda: os.remove(f1) if os.path.exists(f1) else None) + self._assert_dup_errors_no_leak(["-out", f1, "16", "-out"], + "trailing duplicate -out") + + def test_duplicate_hex_flag_rejected(self): + """`rand -hex -hex 16`: a repeated no_argument flag must error. + + GetOpt drops the duplicated -hex (useHex=0) while the rescan still + recovers count=16, which once wrote 16 raw bytes to stdout at exit 0 — + the same leak class as duplicate -out, so assert empty stdout too.""" + self._assert_dup_errors_no_leak(["-hex", "-hex", "16"], + "duplicate -hex") + + def test_duplicate_base64_flag_rejected(self): + """`rand -base64 -base64 16`: a repeated no_argument flag must error, + with no raw bytes leaking to stdout (mirrors the -hex/-out cases).""" + self._assert_dup_errors_no_leak(["-base64", "-base64", "16"], + "duplicate -base64") + + def test_duplicate_flag_first_seen_in_value_slot_rejected(self): + """`rand -out -base64 -base64 16`: duplicate detection is symmetric. + + The first -base64 is swallowed as the -out value; the rescan marks it + seen there so the second -base64 is still rejected as a duplicate, + rather than being silently dropped (which once produced exit 0 and a + file literally named '-base64').""" + for f in ("-base64",): + self.addCleanup(lambda p=f: os.remove(p) + if os.path.exists(p) else None) + self._assert_dup_errors_no_leak( + ["-out", "-base64", "-base64", "16"], + "duplicate -base64 with first occurrence in a value slot") + def test_hex_size_overflow_rejected(self): - """`-hex` must reject sizes that would overflow size*2 rather than - silently allocating an undersized buffer and writing past it.""" - # 2^30 fits in int but 2^30 * 2 == 2^31 wraps signed int. The - # binary should refuse this size, not crash or silently truncate. + """`-hex` must reject sizes that overflow size*2. + + 2^30 fits in int, but 2^30 * 2 wraps signed int. The binary must + refuse it, not allocate an undersized buffer and write past it.""" r = run_wolfssl("rand", "-hex", str(2**30)) self.assertNotEqual(r.returncode, 0, "rand -hex with overflow-prone size must error") + def test_base64_size_overflow_rejected(self): + """`-base64` must reject sizes whose ~4/3 encoded length wraps the + signed int `size`, mirroring the -hex cap. The guard fires before + the RNG allocation, so it errors without allocating ~1 GiB.""" + r = run_wolfssl("rand", "-base64", str(2**30)) + self.assertNotEqual(r.returncode, 0, + "rand -base64 with overflow-prone size must error") + def test_hex_flag_not_swallowed_by_help_check(self): """Regression: `-hex` must not match the `-h`/`-help` prefix detector. - Putting -hex *last* on the command line previously triggered the - rand help screen because the first two characters ("-h") matched. - Help text must only fire for an exact -h/-help argument.""" + Putting -hex last once triggered the help screen because "-h" + matched. Help must fire only for an exact -h/-help argument.""" r = run_wolfssl("rand", "16", "-hex") - # Either the user gets a "missing size" error (because "-hex" is - # not the documented size-as-last-arg) or actual hex output — - # but never a silent help screen with exit 0. + # Acceptable: a "missing size" error or actual hex output, but + # never a silent help screen with exit 0. out = (r.stdout or "") self.assertNotIn("wolfssl rand ", out, "rand 16 -hex must not be treated as help") + def test_large_raw_request_allowed(self): + """A large raw request must still work (no arbitrary size cap), + keeping large keyfiles/blobs supported like `openssl rand`.""" + out = "big_rand.bin" + self.addCleanup(lambda: os.remove(out) + if os.path.exists(out) else None) + + r = run_wolfssl("rand", "-out", out, "1048576") # 1 MiB + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(os.path.getsize(out), 1048576, + "expected a full 1 MiB of raw random output") + + # Size alone won't catch a chunk-fill regression that zeroed or + # repeated a chunk, so also check the bytes look random. The slices + # assume the default 64 KiB chunk; on other builds they just compare + # two arbitrary offsets, still a valid randomness check. + with open(out, "rb") as f: + data = f.read() + chunk = 65536 # default RNG_MAX_BLOCK_LEN + self.assertNotEqual(data, b"\x00" * len(data), + "output must not be all zeros") + self.assertNotEqual(data[:chunk], data[chunk:2 * chunk], + "consecutive chunks must differ (no chunk repeat)") + + def test_large_base64_request_allowed(self): + """A large -base64 request must work too: it forces the multi-chunk + fill loop and then base64-expands, guarding that interaction.""" + out = "big_rand.b64" + self.addCleanup(lambda: os.remove(out) + if os.path.exists(out) else None) + + n = 100000 + r = run_wolfssl("rand", "-base64", "-out", out, str(n)) + self.assertEqual(r.returncode, 0, r.stderr) + with open(out, "rb") as f: + data = f.read() + decoded = base64.b64decode(data) + self.assertEqual(len(decoded), n, + "base64 output must decode to the requested size") + + # n crosses one chunk boundary; verify the decoded bytes look random + # rather than zeroed or repeated. On builds with a different chunk + # size these compare two arbitrary offsets, still a valid check. + chunk = 65536 # default RNG_MAX_BLOCK_LEN + self.assertNotEqual(decoded, b"\x00" * n, + "decoded output must not be all zeros") + self.assertNotEqual(decoded[:n - chunk], decoded[chunk:], + "the two chunks must differ (no chunk repeat)") + + def test_chunk_boundary_exact_and_plus_one(self): + """Pin the single/multi-chunk transition in the fill loop. + + Exactly RNG_MAX_BLOCK_LEN (65536) must take one chunk; +1 (65537) + must take a second 1-byte chunk. Locks the `>` vs `>=` boundary that + the large-request tests only catch indirectly. Assumes the default + 64 KiB chunk; other builds still produce the full size.""" + chunk = 65536 # default RNG_MAX_BLOCK_LEN + for n in (chunk, chunk + 1): + out = "chunk_boundary_%d.bin" % n + self.addCleanup(lambda p=out: os.remove(p) + if os.path.exists(p) else None) + r = run_wolfssl("rand", "-out", out, str(n)) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(os.path.getsize(out), n, + "expected exactly %d raw bytes" % n) + with open(out, "rb") as f: + data = f.read() + self.assertNotEqual(data, b"\x00" * n, + "output must not be all zeros") + + def test_count_before_out_with_flag(self): + """`rand -hex 16 -out f` must keep 16 as the count even though it + sits ahead of the -out pair: 16 bytes -> 32 hex chars.""" + out = "count_before_out.hex" + self.addCleanup(lambda: os.remove(out) + if os.path.exists(out) else None) + + r = run_wolfssl("rand", "-hex", "16", "-out", out) + self.assertEqual(r.returncode, 0, r.stderr) + self.assertEqual(os.path.getsize(out), 32, + "16 byte count before -out must yield 32 hex chars") + + def test_flag_as_out_value_is_consistent(self): + """`rand -out -hex 16`: -hex is BOTH bound as the -out filename and + matched as the -hex flag (pre-existing GetOpt whole-argv scan), while + the positional rescan treats it only as the bound value. Both agree on + count=16 and a file named '-hex', so the outcome is self-consistent: + 16 random bytes hex-encoded (32 chars) to a file literally named '-hex'. + + Locks the documented parser/rescan divergence (clu_rand.c) as benign; + a future GetOpt binding change that broke the agreement would fail + here instead of silently mis-reading the byte count.""" + out = "-hex" + self.addCleanup(lambda: os.remove(out) + if os.path.exists(out) else None) + + r = run_wolfssl("rand", "-out", "-hex", "16") + self.assertEqual(r.returncode, 0, r.stderr) + self.assertTrue(os.path.isfile(out), + "file named '-hex' must be created (it is the -out " + "value)") + self.assertEqual(os.path.getsize(out), 32, + "count 16 with -hex must yield 32 hex chars, proving " + "parser and rescan agree on the byte count") + with open(out, "rb") as f: + data = f.read() + self.assertTrue(all(chr(b) in "0123456789abcdef" for b in data), + "the -hex flag must still take effect (lowercase hex)") + + def test_missing_count_errors(self): + """`-out` with no byte count must error, not size from the path.""" + out = "missing_count.bin" + self.addCleanup(lambda: os.remove(out) + if os.path.exists(out) else None) + + r = run_wolfssl("rand", "-out", out) + self.assertNotEqual(r.returncode, 0, + "rand -out with no count must error") + + def test_numeric_out_path_is_not_count(self): + """`rand -out 32` must treat 32 as the path, not the count: it errors + (no count given) and never creates a file named '32'.""" + self.addCleanup(lambda: os.remove("32") + if os.path.exists("32") else None) + + r = run_wolfssl("rand", "-out", "32") + self.assertNotEqual(r.returncode, 0, + "rand -out 32 must error: 32 is the path, no num") + self.assertFalse(os.path.exists("32"), + "file '32' must not be created on a count error") + + def test_extra_positional_errors(self): + """More than one positional count must error, not silently pick one.""" + r = run_wolfssl("rand", "16", "32") + self.assertNotEqual(r.returncode, 0, + "rand with two positional counts must error") + + def test_dangling_out_flag_with_count_errors(self): + """`-out` as the final token with no filename must error, not fall + through to stdout. Regressed once: the NULL filename skipped the open + and random bytes hit the terminal with exit 0. Must fail with no + stdout even when a valid count is present (`rand 16 -out`).""" + for args in (["-out"], ["16", "-out"], ["-hex", "16", "-out"]): + with self.subTest(args=args): + r = run_wolfssl("rand", *args) + self.assertNotEqual(r.returncode, 0, + "rand %s (no filename) must error" + % " ".join(args)) + self.assertEqual(r.stdout, "", + "no random bytes may reach stdout on error: " + "%r" % (args,)) + + def test_unknown_flag_errors(self): + """An unrecognized flag must be rejected, not ignored nor treated as + an extra positional. A valid count is present, so only -foo fails.""" + r = run_wolfssl("rand", "-foo", "16") + self.assertNotEqual(r.returncode, 0, + "rand with an unrecognized flag must error") + + def test_bad_count_does_not_truncate_existing_out_file(self): + """A bad/missing count must not truncate an existing -out file. + + The output BIO is opened only after the count validates, so every + pre-open error path must leave the file intact. Each form below + reaches that guard differently: count-scan failures, the size-overflow + guards, and the -hex/-base64 mutual-exclusion guard.""" + out = "preexisting.key" + self.addCleanup(lambda: os.remove(out) + if os.path.exists(out) else None) + + original = b"SECRET-KEY-MATERIAL\n" + + big = str(2**30) # size*2 / base64 expansion wraps signed int -> error + bad_arg_forms = ( + ["-out", out], # missing count + ["-out", out, "abc"], # non-numeric count + ["-out", out, "0"], # zero count + ["-out", out, "16", "32"], # extra positional + ["-hex", "-out", out, big], # hex size overflow + ["-base64", "-out", out, big], # base64 size overflow + ["-hex", "-base64", "-out", out, "8"], # mutually exclusive + ) + for args in bad_arg_forms: + with self.subTest(args=args): + with open(out, "wb") as f: + f.write(original) + r = run_wolfssl("rand", *args) + self.assertNotEqual(r.returncode, 0, + "bad count must error: %r" % (args,)) + with open(out, "rb") as f: + self.assertEqual(f.read(), original, + "existing -out file must be untouched " + "on error: %r" % (args,)) + if __name__ == "__main__": test_main()