Repository navigation
0.5.7
A release about measuring what the 0.5.6 README could only describe. Every claim in the new "Security posture" table is a check that runs in CI.
- Constant-time verification, every primitive.
inst/ctcheck/runs ML-KEM, ML-DSA, SLH-DSA, HQC (v5 and round 4), XMSS, the CTR_DRBG, HMAC, PBKDF2, keyed BLAKE2b, SHA-2, SHA-3 and the digest comparison under valgrind memcheck with their secrets marked undefined (ctgrind), with GCC and Clang; a branch or memory address that depends on a secret fails theconstant-timeworkflow. The run found and this release fixes: ML-KEM'sByteEncodebranched on every bit it packed (the secret key included); the hex encoder used a table indexed by the secret nibble (every HMAC, PBKDF2 and key output); ML-DSA's norm check returned at the first offending coefficient; PBKDF2 round-tripped each MAC through hex and branched on the characters; XMSS measured its hex-encoded seeds withstrlen(). The values the specifications publish (rejected samples, the challenge, the hints, R) are declassified at one named line each. - Secrets are wiped. The same harness scans the dead stack after each operation for copies of the seed, signing key or password. Every scheme left at least one; all temporaries now clear on every return path (
rmbl_ct::Guard), and the scan is part of the gate. - Wycheproof. 1,247 Project Wycheproof vectors (ECDSA P-256/SHA-256, P-384/SHA-384, RSASSA-PKCS1-v1_5 2048/SHA-256) run in the test suite. They found that the DER parser accepted BER (long-form lengths below 128, lengths with leading zeros, non-minimal tags, end-of-contents octets), that
ECDSA-Sig-Valuewas accepted with extra elements, padded or negative integers, and that a PKCS#1 block was checked field by field rather than against RFC 8017's exact DigestInfo bytes. All three are fixed; every valid vector verifies, every invalid and every legacy "acceptable" one is refused. - Fuzzing.
inst/fuzz/holds libFuzzer targets (ASan + UBSan) for the DER parser and RSA arithmetic, the decimal-to-double conversion (compared bit for bit with the C library), and the SIU report parsers, with seed corpora; thefuzzworkflow runs them on every change and for longer weekly. SECURITY.md: threat model, what each check catches and does not, reporting. The README's "Security posture" is now that table.rmbl_hmac_sha256_raw()joins the C API (the hex variant is a wrapper).
The second review of 0.5.6 (2026-10-06) asked for each fix's neighbours. Every item below has a test intests/testthat/test-review2.R.- Every
.Callentry point has the exception barrier, not two of 110:src/rmbl_barrier.cpp, generated frominit.cbyinst/scripts/gen_barrier.R, wraps each entry so a C++ exception is an R error raised after every destructor has run. SevenNULLs tohawkes_rescaled, a 2 GBshake, mismatchedtheil_senlengths (a 40 MB out-of-bounds read), a 4 TBsen_slopes, a 2^31 reservoir and an INT_MAX PBKDF2 are all plain errors now; the quadratic loops and the RSA arithmetic check for Ctrl-C; the RSA public exponent is capped at 64 bytes (1 KB took 1.2 s, 1 MB would have taken 20 minutes, from a certificate);shakeoutput and PBKDF2 iterations are capped; every string argument goes through onermbl_str0(); the DER tree is converted underR_UnwindProtect, so an allocation failure cannot skip its destructor either. - SSRF gate rewritten, in the transport.
src/rmbl_fetch.cppparses the authority as a URL parser does (#@and?@no longer hide a host, any userinfo is refused), reads IPv4 literals withinet_aton's grammar (127.1,0177.0.0.1,2130706433,0x7f000001), IPv6 withinet_pton(mapped, NAT64, link-local, unique-local), refuses local names by suffix (localhost,.local,.internal,.localdomain,.home.arpa), resolves every hostname and tests every address, pins the connection to those addresses (CURLOPT_RESOLVE, so DNS rebinding cannot answer differently), follows redirects itself one checked hop at a time (anhttpsfetch cannot be bounced tohttpor to a private host), and caps a chunked download.bricklayer_fetch(),.rmbl_read_json(), the CRL/OCSP fetch, the Wayback resolver and the hosted-model client all go through it..rmbl_check_public_url()calls the same C check. verify_capsule():provenance_fileis contained like every other path (a symlinkeddata_provenance.jsonverified tampered data as intact);data_not_syntheticis a required row that always exists and reads the sidecar, the manifest and the provenance; amanifest.jsonin the capsule is checked by default andmanifest_consistent/script_sha256are required whenever they apply; asha256orfilenamethat is not a single string, an unreadable file or a directory are failed rows, not R errors.manifest_digest()is injective again: the canonical encoder writes an empty key as""(0.5.6 renamed it to its index, so{"":"X"}and{"1":"X"}shared a digest and one attestation verified both). A list with a names attribute is an object and is sorted, empty names included: a manifest built with unnamed elements in a named list gets a different digest from 0.5.5's, which never canonicalised such a level.make_manifest()writesmeta$syntheticonly when it isTRUE, so a real-data manifest keeps its pre-0.5.6 digest, andmake_synthetic_csv()sets the flag in-process.- JSON: string assembly is linear (250,000 escapes took 16 minutes); the reformatter refuses NUL and lone surrogates as the parser does; parser and encoder share one 100-level cap (the encoder used to hit the C stack first);
bricklayer_json_base64_dec()refuses characters outside the alphabet instead of deleting them; whole numbers beyond 2^53 warn on the decimal branch too;bigint_warn = FALSEandduplicate_keys = "keep"are what the package's own API readers use; the gzip inflate cap is 64 MiB. capsule_drift(): a character column of dates is compared as dates (two disjoint years drift); a column with more thanidentifier_levelsdistinct values, or one per five rows, is an identifier -- itsunseen_shareis reported anddriftedisNA(the 0.5.6 PSI over the union of categories fired on every id and date column); PSI for the rest is over the reference's categories with unseen mass pooled. The result gainstype,unseen_shareandnotecolumns.drift_homogeneity()returnsp_value = NAwithmethod = "inapplicable: ..."when the categories outnumber half the observations (every table with those margins has the same statistic), draws its Monte Carlo p-value under a localseedand restores the caller's RNG, and likedrift_chisq()refuses negative, NA and infinite counts. All chi-square sites reportlog_p_valuenext top_value: a double has no number for exp(-2536), and the log says how far below zero the tail is.cramers_v()drops an unused level (it returned NaN) and takes aseedtoo.parse_bands(): trailing units with punctuation ("18 to 24 (years)", "5 to 9 years, inclusive") parse again; cue words are matched as words ("overnight" is not "over") in the leading slot as well as the tail ("under 18 to 24" is NA, not 18-24); a label with two numbers that is not a range is NA ("more than 10 - 20"); thousands separators are removed only from correctly grouped numbers ("12,34,567+" is NA); a label with a decimal point is on a decimal scale; the dash family is normalised byte-wise, so an en dash parses in a C locale.band_values()guards the open-lower floor as it guards the cap, and its default floor ismin(0, upper), so "under 0" has its midpoint inside the band.trend_test()reportsslope_ci_clamped = TRUEexactly when a rank is at an end, warns whenexact = TRUEcannot be honoured (n > 8), and memoises the tied exact null;count_trend()refuses an all-zero series;step_change()andcount_trend()refuse infinite values astrend_test()does;morans_i()refuses negative weights and a constant variable;gini(),top_share(),lorenz()andhill_tail_index()refuse empty input;hurwitz_zeta()returns the Bernoulli values at non-positive integers (zeta(0, 1) = -1/2); the Benford first digit of a subnormal is read from the shortest round-tripping decimal (1e-310 is 1, not 9).- Result changes from 0.5.6 that NEWS did not state:
morans_i()excludes self-neighbours from a weight matrix;make_manifest()no longer changes the digest of a real-data manifest (0.5.6 did); the OTIS full-verification workflow runs on demand only.
The coverage pass that followed (every branch below was untested at 96.2%) found five defects that no test had reached; the CRAN checks and the entry-point sweep found two more. - Revocation checking worked only on paper.
revocation_fetch()ran every certificate through the single-file-path guard, so a parsed path was refused before anything was fetched; the CRL/OCSP fetch refused plainhttp, which is what RFC 5280 distribution points and RFC 6960 responders use (the answers are signed, the transport adds nothing); and a delegated OCSP responder's certificate was never found, because the search for the[0]certificates element landed onnextUpdateinside the SingleResponse first and the signed answer was reported as unverifiable. All three are fixed; the client is now tested end to end against recorded OpenSSL responses (good, revoked, unknown, SHA-384 without an embedded certificate, a delegated responder, an ECDSA responder,tryLater, and every malformed shape), with the two network calls behind one-line seams. The fixtures are documented intests/testthat/x509-fixtures.txt. top_share()summed from the start for every fraction: a million fractions over a million values was 10^12 additions with no interrupt check (the entry-point sweep found it as the one hang). It is one prefix sum now, interruptible.manifest_restore_seed()padded a record naming only the main generator kind withNA, whichRNGkind()rejects; the recorded names are passed as they are.- The JSON simplifier's date-list test used
is.numeric(), which isFALSEfor aPOSIXct, so a list of times was never folded back into one vector. capsule_drift()'s date detection errored on a column of timestamps with one unreadable value ("2020-01-01 25:99") instead of treating the column as text.rmbl_barrier.cppre-raised an interrupt throughRf_onintr(), which is not part of R's API (CRAN refuses it). It now signals R's owninterruptcondition and invokes theabortrestart, sotryCatch(interrupt = )sees it exactly as before.- The two helper scripts that shipped in the tarball were Python (
inst/scripts/gen_barrier.py,inst/fuzz/mkcorpus.py); they are R now (gen_barrier.R,mkcorpus.R), with the same output byte for byte.inst/ctcheck/andinst/fuzz/each carry a README saying what they are and how to run them. Theagent_bundle()example switches the hosted tier off for its run rather than reaching the network when a key is stored. - Dead code found by the same pass is gone: the unused
wots_gen_pk()/wots_sign()in the SLH-DSA body,rmbl_mad_constant(), three unreachable guards in.rmbl_conc_input()/cramers_v()/drift_chisq(), and a duplicated line in the JSON object writer. The LinkingTo consumer test now calls the Weibull, Lomax and gamma Hawkes kernels and the NaN/short-input paths of the statistics kernels.