wolfCrypt SRAM PUF: health test the raw readout before accepting it

pull/11188/head
David Garske 2026-08-11 07:30:09 -07:00 committed by Daniele Lacamera
parent 89541d1110
commit c5c63c0ba5
10 changed files with 701 additions and 9 deletions

View File

@ -41,6 +41,15 @@ jobs:
- "--enable-puf=small --enable-puf-test CPPFLAGS=-DWC_PUF_NUM_CODEWORDS=3"
# Opt-in compact helper layout (parity bits only).
- "--enable-puf=strong --enable-puf-test CPPFLAGS=-DWC_PUF_HELPER_COMPACT"
# Non-default readout health-test band. puf_test() derives the
# expected verdict from the configured band, and two of its bias
# fixtures (~25% and ~72% ones) sit between this band and the
# 35/65 default, so they are rejected there and accepted here:
# the entry exercises the tunable path rather than just building
# it. WOLFSSL_PUF_TEST_BAND_WIDE makes puf_test() pin these exact
# percentages, so a misspelled or mistyped band -D is a build
# failure here instead of a green run against some other band.
- "--enable-puf --enable-puf-test CPPFLAGS=\"-DWC_PUF_HW_MIN_PCT=20 -DWC_PUF_HW_MAX_PCT=80 -DWOLFSSL_PUF_TEST_BAND_WIDE\""
steps:
- uses: actions/checkout@v5
name: Checkout wolfSSL
@ -78,12 +87,16 @@ jobs:
run: |
cmake -S . -B build_puf -DWOLFSSL_PUF=yes \
-DWOLFSSL_PUF_PROFILE=strong \
-DWOLFSSL_PUF_NUM_CODEWORDS=32
-DWOLFSSL_PUF_NUM_CODEWORDS=32 \
-DWOLFSSL_PUF_HW_MIN_PCT=20 \
-DWOLFSSL_PUF_HW_MAX_PCT=80
- name: Assert profile reached options.h
run: |
grep -q '^#define WC_PUF_BCH_T 13$' build_puf/wolfssl/options.h
grep -q '^#define WC_PUF_NUM_CODEWORDS 32$' build_puf/wolfssl/options.h
grep -q '^#define WC_PUF_HW_MIN_PCT 20$' build_puf/wolfssl/options.h
grep -q '^#define WC_PUF_HW_MAX_PCT 80$' build_puf/wolfssl/options.h
grep -q '^#define WOLFSSL_PUF$' build_puf/wolfssl/options.h
- name: Build

View File

@ -2352,6 +2352,14 @@ if(WOLFSSL_PUF)
set(WOLFSSL_PUF_NUM_CODEWORDS "" CACHE STRING
"Number of PUF BCH codewords (1-4095, default: 16)")
# Readout health-test band, same cache-name reasoning as above. An
# application that measures a region with wc_PufCheckSram needs the same
# band as the library, so route it through options.h rather than CFLAGS.
set(WOLFSSL_PUF_HW_MIN_PCT "" CACHE STRING
"PUF readout minimum Hamming weight, percent (1-98, default: 35)")
set(WOLFSSL_PUF_HW_MAX_PCT "" CACHE STRING
"PUF readout maximum Hamming weight, percent (2-99, default: 65)")
list(APPEND WOLFSSL_DEFINITIONS
"-DWOLFSSL_PUF"
"-DWOLFSSL_PUF_SRAM"
@ -2366,6 +2374,23 @@ if(WOLFSSL_PUF)
list(APPEND WOLFSSL_DEFINITIONS
"-DWC_PUF_NUM_CODEWORDS=${WOLFSSL_PUF_NUM_CODEWORDS}")
endif()
foreach(pufBand MIN MAX)
if(WOLFSSL_PUF_HW_${pufBand}_PCT)
if(NOT WOLFSSL_PUF_HW_${pufBand}_PCT MATCHES "^[0-9]+$" OR
WOLFSSL_PUF_HW_${pufBand}_PCT LESS 1 OR
WOLFSSL_PUF_HW_${pufBand}_PCT GREATER 99)
message(FATAL_ERROR
"WOLFSSL_PUF_HW_${pufBand}_PCT must be an integer from 1 to 99")
endif()
list(APPEND WOLFSSL_DEFINITIONS
"-DWC_PUF_HW_${pufBand}_PCT=${WOLFSSL_PUF_HW_${pufBand}_PCT}")
endif()
endforeach()
if(WOLFSSL_PUF_HW_MIN_PCT AND WOLFSSL_PUF_HW_MAX_PCT AND
NOT WOLFSSL_PUF_HW_MIN_PCT LESS WOLFSSL_PUF_HW_MAX_PCT)
message(FATAL_ERROR
"WOLFSSL_PUF_HW_MIN_PCT must be less than WOLFSSL_PUF_HW_MAX_PCT")
endif()
if(WOLFSSL_PUF_PROFILE STREQUAL "small")
list(APPEND WOLFSSL_DEFINITIONS "-DWC_PUF_BCH_T=7")
elseif(WOLFSSL_PUF_PROFILE STREQUAL "strong")

View File

@ -2,6 +2,28 @@
## Behavioral Changes
* **Behavioral change (`wc_PufReadSram` health tests the raw SRAM readout)**:
the raw readout is now health tested before the context accepts it, and a
readout that cannot be SRAM power-on noise is rejected with `PUF_READ_E`
instead of deriving a key from it. Rejected are a 128-bit block that is all
zero or all ones, a block that repeats the one before it, and a total
Hamming weight outside `WC_PUF_HW_MIN_PCT`..`WC_PUF_HW_MAX_PCT` (35% to 65%
by default). A rejected readout leaves the context unusable, so
`wc_PufEnroll()` and `wc_PufReconstruct()` refuse to run on it, while output
already derived from a readout that did pass stays available. This turns a
previously silent failure - a degenerate region derives a key that is
identical on every device and computable offline - into a visible one. It
catches degenerate shapes only: ordinary firmware content left in the region
is device-identical yet neither constant nor strongly biased, so sampling
from reset, before .bss/.data init, remains a requirement rather than
something the test can enforce. An integration whose silicon is strongly
biased, or which was sampling its region too late, will now see `PUF_READ_E`
where it previously saw success. Measure a candidate region with the new
`wc_PufCheckSram()` and widen the band with `WC_PUF_HW_MIN_PCT` /
`WC_PUF_HW_MAX_PCT` if the silicon warrants it. The helper-data format and
the derived key are unchanged, so helper data enrolled by wolfSSL 5.9.2
stays valid.
* **Behavioral change (`wolfSSL_shutdown` when no close_notify can be sent)**:
when the connection is already closed or reset and no close_notify was ever
sent, the shutdown exchange can never complete. That case now returns

View File

@ -20,7 +20,9 @@ or contact fips@wolfssl.com.
wolfCrypt also includes support for deriving device-unique keys from hardware entropy
(`--enable-puf[=small|balanced|strong|strongest]`, selecting the BCH error-correction
strength). An example exists at
strength). Each raw SRAM readout is health tested before use, so a degenerate readout -
all zero, all ones, a repeating block, or an implausible bit bias - cannot silently
produce a device-independent key. An example exists at
[SRAM PUF](https://github.com/wolfSSL/wolfssl-examples/tree/master/puf).
## Why Choose wolfSSL?

View File

@ -137,6 +137,10 @@ extern "C" {
#cmakedefine WC_PUF_BCH_T @WC_PUF_BCH_T@
#undef WC_PUF_NUM_CODEWORDS
#cmakedefine WC_PUF_NUM_CODEWORDS @WC_PUF_NUM_CODEWORDS@
#undef WC_PUF_HW_MIN_PCT
#cmakedefine WC_PUF_HW_MIN_PCT @WC_PUF_HW_MIN_PCT@
#undef WC_PUF_HW_MAX_PCT
#cmakedefine WC_PUF_HW_MAX_PCT @WC_PUF_HW_MAX_PCT@
#undef HAVE_HPKE
#cmakedefine HAVE_HPKE
#undef HAVE_KEYING_MATERIAL

View File

@ -32,9 +32,11 @@ int wc_PufInit(wc_PufCtx* ctx);
\brief SRAMPUFsramAddrNOLOAD
wc_PufCheckSram()退(.bssC)PUF_READ_Ewc_PufEnroll()wc_PufReconstruct()使退
\return 0
\return BAD_FUNC_ARG ctxsramAddrNULL
\return PUF_READ_E sramSzWC_PUF_RAW_BYTES
\return PUF_READ_E sramSzWC_PUF_RAW_BYTES
\param ctx wc_PufCtx
\param sramAddr SRAM
@ -48,11 +50,40 @@ int wc_PufInit(wc_PufCtx* ctx);
\endcode
\sa wc_PufInit
\sa wc_PufCheckSram
\sa wc_PufEnroll
\sa wc_PufReconstruct
*/
int wc_PufReadSram(wc_PufCtx* ctx, const byte* sramAddr, word32 sramSz);
/*!
\ingroup PUF
\brief SRAMSRAM01128WC_PUF_HW_MIN_PCTWC_PUF_HW_MAX_PCT(WC_PUF_RAW_BITS35%65%)
wc_PufReadSram()SRAMonesCountsramAddrNULLsramSz
\return 0 PUF
\return BAD_FUNC_ARG sramAddrNULL
\return PUF_READ_E sramSzWC_PUF_RAW_BYTES
\param sramAddr SRAM
\param sramSz SRAM(WC_PUF_RAW_BYTES)
\param onesCount WC_PUF_RAW_BYTES1(WC_PUF_RAW_BITS)sramAddrNULLsramSzPUF
_Example_
\code
word32 ones = 0;
ret = wc_PufCheckSram((const byte*)puf_sram, sizeof(puf_sram), &ones);
printf("PUF SRAM bias %u/%u ones, ret %d\n",
(unsigned)ones, (unsigned)WC_PUF_RAW_BITS, ret);
\endcode
\sa wc_PufReadSram
\sa wc_PufEnroll
*/
int wc_PufCheckSram(const byte* sramAddr, word32 sramSz, word32* onesCount);
/*!
\ingroup PUF

View File

@ -17,6 +17,21 @@
the helper data at enrollment and pass it to wc_PufReconstructEx, or
compare it against wc_PufGetProfileId(), to catch a build mismatch.
Every readout handed to wc_PufReadSram() is health tested first (see
wc_PufCheckSram). A degenerate readout - all zero, all ones, or a repeating
block - would otherwise pass cleanly through encoding, masking, decoding
and HKDF and derive a key that is the same on every device.
WC_PUF_HW_MIN_PCT and WC_PUF_HW_MAX_PCT (default 35 and 65) set the
Hamming-weight band the readout must fall inside.
The test rejects degenerate readouts, not every already-written region:
ordinary firmware content - .data copied from flash, a string table, a
previous boot stage - is identical on every device yet neither constant nor
strongly biased, so it can pass. Sampling the region from reset, before
.bss/.data initialization and before it is used as stack or heap, remains a
requirement of correct NOLOAD placement rather than something this test can
enforce.
For a complete bare-metal example (tested on NUCLEO-H563ZI), see
https://github.com/wolfSSL/wolfssl-examples/tree/master/puf
*/
@ -52,9 +67,18 @@ int wc_PufInit(wc_PufCtx* ctx);
required size, WC_PUF_RAW_BYTES, scales with WC_PUF_NUM_CODEWORDS
(256 bytes at the default 16 codewords).
The readout is health tested with wc_PufCheckSram() before it is accepted.
A degenerate readout - typically a region already cleared by .bss init, the
common bring-up mistake of sampling after C runtime startup - is rejected
with PUF_READ_E, and the context is left unusable by wc_PufEnroll() and
wc_PufReconstruct(). The test catches degenerate shapes, not every
already-written region, so it does not remove the requirement to sample
from reset.
\return 0 on success
\return BAD_FUNC_ARG if ctx or sramAddr is NULL
\return PUF_READ_E if sramSz < WC_PUF_RAW_BYTES
\return PUF_READ_E if sramSz < WC_PUF_RAW_BYTES, or if the readout fails
the health test
\param ctx pointer to wc_PufCtx structure
\param sramAddr pointer to raw SRAM memory region
@ -68,11 +92,54 @@ int wc_PufInit(wc_PufCtx* ctx);
\endcode
\sa wc_PufInit
\sa wc_PufCheckSram
\sa wc_PufEnroll
\sa wc_PufReconstruct
*/
int wc_PufReadSram(wc_PufCtx* ctx, const byte* sramAddr, word32 sramSz);
/*!
\ingroup PUF
\brief Health test a candidate raw SRAM readout without loading it into a
context. Rejects a readout that cannot be SRAM power-on noise: any 128-bit
block that is all zero or all ones, any block that repeats the block before
it, or a total Hamming weight outside the WC_PUF_HW_MIN_PCT to
WC_PUF_HW_MAX_PCT band (default 35% to 65% of WC_PUF_RAW_BITS).
wc_PufReadSram() applies this test to every readout, so calling it directly
is only needed to qualify a candidate SRAM region during board bring-up, or
to report why a read was refused. onesCount is optional and is written
whenever the size check passes - including when the readout is then
rejected, so the measured bias of a rejected region is still available. It
is left untouched when sramAddr is NULL or sramSz is short.
\return 0 if the readout is plausible PUF material
\return BAD_FUNC_ARG if sramAddr is NULL
\return PUF_READ_E if sramSz < WC_PUF_RAW_BYTES, or if the readout fails
any of the checks
\param sramAddr pointer to raw SRAM memory region
\param sramSz size of SRAM buffer (must be >= WC_PUF_RAW_BYTES)
\param onesCount optional; receives the number of one bits in the first
WC_PUF_RAW_BYTES of the readout, out of WC_PUF_RAW_BITS. Written whenever
sramAddr is non-NULL and sramSz is large enough, whatever the verdict. It
is a property of the raw PUF material, so treat it as a bring-up
measurement and do not report it from production firmware
_Example_
\code
word32 ones = 0;
ret = wc_PufCheckSram((const byte*)puf_sram, sizeof(puf_sram), &ones);
printf("PUF SRAM bias %u/%u ones, ret %d\n",
(unsigned)ones, (unsigned)WC_PUF_RAW_BITS, ret);
\endcode
\sa wc_PufReadSram
\sa wc_PufEnroll
*/
int wc_PufCheckSram(const byte* sramAddr, word32 sramSz, word32* onesCount);
/*!
\ingroup PUF

View File

@ -510,6 +510,107 @@ static void storeCodeword(byte* helper, int bitOffset, const byte* cw)
}
/* Population count. Nibble table, not a builtin: bare-metal C89 toolchains. */
static word32 pufBitCount(const byte* buf, word32 sz)
{
static const byte nibbleBits[16] =
{ 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4 };
word32 i;
word32 count = 0;
for (i = 0; i < sz; i++) {
count += nibbleBits[buf[i] & 0x0F];
count += nibbleBits[(buf[i] >> 4) & 0x0F];
}
return count;
}
/* Startup health test on a candidate raw SRAM readout. Constant input is
* self-consistent through encoding, masking, decoding and HKDF, so a
* degenerate readout would yield a key identical on every device. Reject it
* here, at the only production boundary raw PUF material enters through
* (wc_PufSetTestData deliberately bypasses it under WOLFSSL_PUF_TEST, so the
* test vectors it injects are not filtered).
*
* Three O(WC_PUF_RAW_BYTES) checks: no block all zero or all ones, no block
* repeating the one before it, and total Hamming weight inside the
* WC_PUF_HW_MIN_PCT..WC_PUF_HW_MAX_PCT band. The first two false-reject real
* SRAM with probability ~2^-127 per block; the band passes biased silicon.
*
* onesCount is optional. It is written whenever the size check passes -
* including when the readout is then rejected, so bring-up code can report the
* measured bias of a region it just had refused - and left untouched when
* sramAddr is NULL or sramSz is short, because there is nothing to measure. */
int wc_PufCheckSram(const byte* sramAddr, word32 sramSz, word32* onesCount)
{
const byte* slice;
word32 ones;
word32 i;
int j;
int allZero;
int allOnes;
WOLFSSL_ENTER("wc_PufCheckSram");
if (sramAddr == NULL)
return BAD_FUNC_ARG;
if (sramSz < WC_PUF_RAW_BYTES) {
/* every other PUF_READ_E from here names its cause, and a region
* sized for the wrong WC_PUF_NUM_CODEWORDS is the likeliest one */
WOLFSSL_MSG("PUF: SRAM readout smaller than WC_PUF_RAW_BYTES");
return PUF_READ_E;
}
ones = pufBitCount(sramAddr, WC_PUF_RAW_BYTES);
if (onesCount != NULL)
*onesCount = ones;
/* one codeword per stride, pinned by the guard in puf.h, so a stride is
* one block here */
for (i = 0; i < (word32)WC_PUF_NUM_CODEWORDS; i++) {
slice = sramAddr + (i * WC_PUF_RAW_STRIDE_BYTES);
allZero = 1;
allOnes = 1;
for (j = 0; j < WC_PUF_RAW_STRIDE_BYTES; j++) {
if (slice[j] != 0x00)
allZero = 0;
if (slice[j] != 0xFF)
allOnes = 0;
}
if (allZero) {
WOLFSSL_MSG("PUF: all-zero block in SRAM readout");
return PUF_READ_E;
}
if (allOnes) {
WOLFSSL_MSG("PUF: all-ones block in SRAM readout");
return PUF_READ_E;
}
/* Previous block only: that covers what this test exists for - a
* uniform fill, a memset, a re-read of one address. Longer-period
* structure is not chased; a cheap startup test cannot tell it from
* noise. */
if (i > 0 && XMEMCMP(slice, slice - WC_PUF_RAW_STRIDE_BYTES,
WC_PUF_RAW_STRIDE_BYTES) == 0) {
WOLFSSL_MSG("PUF: repeated block in SRAM readout");
return PUF_READ_E;
}
}
if (ones * 100U < (word32)WC_PUF_RAW_BITS * WC_PUF_HW_MIN_PCT) {
WOLFSSL_MSG("PUF: SRAM readout has too few one bits");
return PUF_READ_E;
}
if (ones * 100U > (word32)WC_PUF_RAW_BITS * WC_PUF_HW_MAX_PCT) {
WOLFSSL_MSG("PUF: SRAM readout has too many one bits");
return PUF_READ_E;
}
return 0;
}
int wc_PufInit(wc_PufCtx* ctx)
{
WOLFSSL_ENTER("wc_PufInit");
@ -524,12 +625,28 @@ int wc_PufInit(wc_PufCtx* ctx)
int wc_PufReadSram(wc_PufCtx* ctx, const byte* sramAddr, word32 sramSz)
{
int ret;
WOLFSSL_ENTER("wc_PufReadSram");
if (ctx == NULL || sramAddr == NULL)
if (ctx == NULL)
return BAD_FUNC_ARG;
if (sramSz < WC_PUF_RAW_BYTES)
/* Any failed read invalidates the readout the context was holding, so the
* flag goes down before every path that can fail, bad arguments included.
* Otherwise a read that returns an error still leaves wc_PufEnroll and
* wc_PufReconstruct running on whatever an earlier call had accepted. */
ctx->flags &= (word32)~WC_PUF_FLAG_SRAM_SET;
if (sramAddr == NULL)
return BAD_FUNC_ARG;
/* must be checked here too: it guards the full-size XMEMCPY below, and
* this is the path an integrator hits, so it names its cause as well */
if (sramSz < WC_PUF_RAW_BYTES) {
WOLFSSL_MSG("PUF: SRAM readout smaller than WC_PUF_RAW_BYTES");
return PUF_READ_E;
}
#ifdef WOLFSSL_PUF_TEST
if (ctx->testDataSet) {
@ -539,7 +656,27 @@ int wc_PufReadSram(wc_PufCtx* ctx, const byte* sramAddr, word32 sramSz)
}
#endif
/* Health test the copy that will actually be used, not the caller's
* buffer: the region is volatile by construction, so the bytes read for
* the copy are not guaranteed to be the bytes the test saw.
*
* WC_PUF_FLAG_SRAM_SET is already down (above), so the context does not
* look like it holds a validated readout for the window in which rawSram
* holds unvalidated bytes; it goes back up only once the test passes. A
* rejected readout is scrubbed rather than left resident, and wc_PufEnroll
* and wc_PufReconstruct then refuse to run on the context. Output already
* derived from a readout that did pass - identity, helper data, a derived
* key - is unaffected and stays available. */
XMEMCPY(ctx->rawSram, sramAddr, WC_PUF_RAW_BYTES);
ret = wc_PufCheckSram(ctx->rawSram, WC_PUF_RAW_BYTES, NULL);
if (ret != 0) {
/* only this path scrubs: it is the one that copied unvalidated bytes
* in. The paths that fail before the copy leave the previously
* accepted readout in place, unreachable behind the cleared flag. */
ForceZero(ctx->rawSram, WC_PUF_RAW_BYTES);
return ret;
}
ctx->flags |= WC_PUF_FLAG_SRAM_SET;
return 0;
}
@ -578,7 +715,8 @@ int wc_PufEnroll(wc_PufCtx* ctx)
for (i = 0; i < WC_PUF_NUM_CODEWORDS; i++) {
/* extract k message bits from raw SRAM */
int bitOff = i * 128; /* 128-bit stride per codeword (n=127 fits) */
/* one codeword per raw stride; see WC_PUF_RAW_STRIDE_BITS in puf.h */
int bitOff = i * WC_PUF_RAW_STRIDE_BITS;
int j;
XMEMSET(msg, 0, sizeof(msg));
for (j = 0; j < WC_PUF_BCH_K; j++) {
@ -665,7 +803,7 @@ int wc_PufReconstructEx(wc_PufCtx* ctx, const byte* helperData,
XMEMSET(ctx->stableBits, 0, WC_PUF_STABLE_BYTES);
for (i = 0; i < WC_PUF_NUM_CODEWORDS; i++) {
int bitOff = i * 128;
int bitOff = i * WC_PUF_RAW_STRIDE_BITS;
int j;
/* get raw SRAM bits for this codeword */

View File

@ -25043,6 +25043,27 @@ static void puf_fill_sram(byte* sram, word32 sz)
}
}
/* Build a raw SRAM image with exactly 'ones' one bits. The bits are split
* evenly over the blocks and each block starts its run at a different offset,
* so no block is constant and no block repeats the one before it: only the
* Hamming-weight band can reject the result. */
static void puf_fill_weight(byte* sram, word32 ones)
{
word32 blk, want, k, pos;
XMEMSET(sram, 0x00, WC_PUF_RAW_BYTES);
for (blk = 0; blk < (word32)WC_PUF_NUM_CODEWORDS; blk++) {
want = ones / (word32)WC_PUF_NUM_CODEWORDS;
if (blk < (ones % (word32)WC_PUF_NUM_CODEWORDS))
want++;
for (k = 0; k < want; k++) {
pos = (blk + k) % (WC_PUF_CW_BYTES * 8);
sram[(blk * WC_PUF_CW_BYTES) + (pos / 8)] |=
(byte)(1 << (7 - (pos % 8)));
}
}
}
/* Flip 'count' distinct bits inside one 128-bit codeword block of the raw
* SRAM image, staying within the used n=127 bits (bit 127 is unused). Bits
* are spread 7 apart so up to t+1 flips land in distinct positions < 127. */
@ -25063,13 +25084,35 @@ WOLFSSL_TEST_SUBROUTINE wc_test_ret_t puf_test(void)
{
#if defined(WOLFSSL_PUF_TEST) && defined(HAVE_HKDF) && \
(!defined(NO_SHA256) || defined(WOLFSSL_SHA3))
/* Set by the CI entry that widens the health-test band. It asserts the band
* really is wider than the default, so a -D that never reached the compiler (a
* typo in either macro name) is a build failure here rather than a job that
* silently retests the default band. */
#ifdef WOLFSSL_PUF_TEST_BAND_WIDE
/* The .github/workflows/puf.yml entry that sets this also passes
* -DWC_PUF_HW_MIN_PCT=20 -DWC_PUF_HW_MAX_PCT=80. Pin those exact values:
* a misspelled or mistyped band -D then fails the build instead of
* quietly retesting whatever band did reach the compiler. */
#if WC_PUF_HW_MIN_PCT != 20 || WC_PUF_HW_MAX_PCT != 80
#error "WOLFSSL_PUF_TEST_BAND_WIDE set, 20/80 band did not arrive"
#endif
#endif
wc_test_ret_t ret = 0;
wc_PufCtx ctx;
byte key1[WC_PUF_KEY_SZ];
byte key2[WC_PUF_KEY_SZ];
byte id1[WC_PUF_ID_SZ];
byte id2[WC_PUF_ID_SZ];
byte id3[WC_PUF_ID_SZ];
int block, nblocks;
int cw, cwByte;
int bias, inBand;
word32 ones, onesLow, onesHigh;
/* Test 10's four bias fixtures, by fill byte. 0xFE (~82% ones), 0x01 (~13%)
* sit outside any legal band; 0xFC (~72%) and 0x03 (~25%) sit between the
* 35..65 default band and a widened one, so the verdict on them depends on
* how the band was configured. */
static const byte biasFill[4] = { 0xFE, 0x01, 0xFC, 0x03 };
/* deterministic test SRAM, sized to the selected profile. These buffers
* scale with WC_PUF_NUM_CODEWORDS, so keep them off the stack under
@ -25078,6 +25121,8 @@ WOLFSSL_TEST_SUBROUTINE wc_test_ret_t puf_test(void)
/* noisy SRAM: same as testSram but with a few flipped bits */
WOLFSSL_SMALL_STACK_STATIC byte noisySram[WC_PUF_RAW_BYTES];
WOLFSSL_SMALL_STACK_STATIC byte helperBuf[WC_PUF_HELPER_BYTES];
/* rewritten per case by Test 10 */
WOLFSSL_SMALL_STACK_STATIC byte badSram[WC_PUF_RAW_BYTES];
const byte info[] = "puf-test-context";
WOLFSSL_ENTER("puf_test");
@ -25380,6 +25425,292 @@ WOLFSSL_TEST_SUBROUTINE wc_test_ret_t puf_test(void)
return WC_TEST_RET_ENC_NC;
}
/* ---- Test 10: raw readout health test ---- *
* Each buffer trips exactly one check. None of these may call
* wc_PufSetTestData first: that sets ctx.testDataSet, which
* short-circuits wc_PufReadSram before the health test can run. */
/* all zero: the "SRAM already cleared by .bss init" mistake */
XMEMSET(badSram, 0x00, sizeof(badSram));
ret = wc_PufInit(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
if (wc_PufReadSram(&ctx, badSram, sizeof(badSram))
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
/* refused, so the context must be usable by neither half of the contract:
* helperBuf still holds the valid Test 2 helper data, and reconstruct
* reaches its WC_PUF_FLAG_SRAM_SET guard after the profile and length
* guards, so both arms need pinning */
if (wc_PufEnroll(&ctx) != WC_NO_ERR_TRACE(PUF_ENROLL_E))
return WC_TEST_RET_ENC_NC;
if (wc_PufReconstruct(&ctx, helperBuf, WC_PUF_HELPER_BYTES)
!= WC_NO_ERR_TRACE(PUF_RECONSTRUCT_E))
return WC_TEST_RET_ENC_NC;
/* the measurement is reported once the size check has passed, even when
* the health test then rejects the readout */
ones = 0xFFFFFFFFU;
if (wc_PufCheckSram(badSram, sizeof(badSram), &ones)
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
if (ones != 0)
return WC_TEST_RET_ENC_NC;
/* all ones */
XMEMSET(badSram, 0xFF, sizeof(badSram));
ret = wc_PufInit(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
if (wc_PufReadSram(&ctx, badSram, sizeof(badSram))
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
#if WC_PUF_NUM_CODEWORDS > 1
/* every block identical, but neither constant nor biased: only the
* repeated-block check can reject this */
for (cw = 0; cw < WC_PUF_NUM_CODEWORDS; cw++) {
XMEMCPY(badSram + (cw * WC_PUF_CW_BYTES), testSram, WC_PUF_CW_BYTES);
}
ret = wc_PufInit(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
if (wc_PufReadSram(&ctx, badSram, sizeof(badSram))
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
#endif
#if WC_PUF_NUM_CODEWORDS > 1
/* The block checks must reach every block, not just the first. Repeating
* the previous block into the *last* one changes the total weight by at
* most a bit, so the repeat check at the end of the loop is what rejects
* this, not the band. */
puf_fill_weight(badSram, (((word32)WC_PUF_RAW_BITS * WC_PUF_HW_MIN_PCT
+ (word32)WC_PUF_RAW_BITS * WC_PUF_HW_MAX_PCT)
/ 200U));
XMEMCPY(badSram + ((WC_PUF_NUM_CODEWORDS - 1) * WC_PUF_CW_BYTES),
badSram + ((WC_PUF_NUM_CODEWORDS - 2) * WC_PUF_CW_BYTES),
WC_PUF_CW_BYTES);
if (wc_PufCheckSram(badSram, WC_PUF_RAW_BYTES, NULL)
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
#endif
#if WC_PUF_NUM_CODEWORDS > 2
/* and a constant block in the middle of the buffer */
puf_fill_weight(badSram, (((word32)WC_PUF_RAW_BITS * WC_PUF_HW_MIN_PCT
+ (word32)WC_PUF_RAW_BITS * WC_PUF_HW_MAX_PCT)
/ 200U));
XMEMSET(badSram + WC_PUF_CW_BYTES, 0x00, WC_PUF_CW_BYTES);
if (wc_PufCheckSram(badSram, WC_PUF_RAW_BYTES, NULL)
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
#endif
/* Hamming-weight band, over the biasFill fixtures. The last byte of each
* block keeps the blocks distinct, so neither block check can fire first
* and only the band decides. The expected verdict is derived from the
* configured band rather than fixed, so a build that widens the band
* reaches a different outcome here instead of retesting the default one:
* 0xFC and 0x03 land between the 35..65 default and a 20..80 band. */
for (bias = 0; bias < (int)(sizeof(biasFill) / sizeof(biasFill[0]));
bias++) {
for (cw = 0; cw < WC_PUF_NUM_CODEWORDS; cw++) {
for (cwByte = 0; cwByte < WC_PUF_CW_BYTES; cwByte++) {
badSram[(cw * WC_PUF_CW_BYTES) + cwByte] = biasFill[bias];
}
badSram[(cw * WC_PUF_CW_BYTES) + WC_PUF_CW_BYTES - 1] = (byte)cw;
}
ones = 0;
ret = wc_PufCheckSram(badSram, sizeof(badSram), &ones);
inBand = (ones * 100U >= (word32)WC_PUF_RAW_BITS * WC_PUF_HW_MIN_PCT &&
ones * 100U <= (word32)WC_PUF_RAW_BITS * WC_PUF_HW_MAX_PCT);
if (ret != (inBand ? 0 : WC_NO_ERR_TRACE(PUF_READ_E)))
return WC_TEST_RET_ENC_NC;
/* the read path must reach the same verdict, and only a readout the
* band accepted may go on to enroll */
ret = wc_PufInit(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
ret = wc_PufReadSram(&ctx, badSram, sizeof(badSram));
if (ret != (inBand ? 0 : WC_NO_ERR_TRACE(PUF_READ_E)))
return WC_TEST_RET_ENC_NC;
if (inBand) {
ret = wc_PufEnroll(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
}
else if (wc_PufEnroll(&ctx) != WC_NO_ERR_TRACE(PUF_ENROLL_E)) {
return WC_TEST_RET_ENC_NC;
}
}
/* Band edges, with the verdicts hard-coded rather than derived from the
* band expression: the largest weight inside the band must be accepted and
* one bit more rejected, and likewise at the low edge, so the inclusive
* direction of both comparisons is pinned independently of how
* wc_PufCheckSram spells them. */
onesLow = (((word32)WC_PUF_RAW_BITS * WC_PUF_HW_MIN_PCT) + 99U) / 100U;
onesHigh = ((word32)WC_PUF_RAW_BITS * WC_PUF_HW_MAX_PCT) / 100U;
puf_fill_weight(badSram, onesLow);
ones = 0;
ret = wc_PufCheckSram(badSram, WC_PUF_RAW_BYTES, &ones);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
if (ones != onesLow)
return WC_TEST_RET_ENC_NC;
puf_fill_weight(badSram, onesLow - 1);
if (wc_PufCheckSram(badSram, WC_PUF_RAW_BYTES, NULL)
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
puf_fill_weight(badSram, onesHigh);
ones = 0;
ret = wc_PufCheckSram(badSram, WC_PUF_RAW_BYTES, &ones);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
if (ones != onesHigh)
return WC_TEST_RET_ENC_NC;
puf_fill_weight(badSram, onesHigh + 1);
if (wc_PufCheckSram(badSram, WC_PUF_RAW_BYTES, NULL)
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
/* accessor bad args. onesCount is written only once the size check has
* passed, so both of these must leave the caller's value untouched. */
ones = 0xFFFFFFFFU;
if (wc_PufCheckSram(NULL, WC_PUF_RAW_BYTES, &ones)
!= WC_NO_ERR_TRACE(BAD_FUNC_ARG))
return WC_TEST_RET_ENC_NC;
if (ones != 0xFFFFFFFFU)
return WC_TEST_RET_ENC_NC;
if (wc_PufCheckSram(testSram, WC_PUF_RAW_BYTES - 1, &ones)
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
if (ones != 0xFFFFFFFFU)
return WC_TEST_RET_ENC_NC;
/* and the same two with onesCount omitted */
if (wc_PufCheckSram(NULL, WC_PUF_RAW_BYTES, NULL)
!= WC_NO_ERR_TRACE(BAD_FUNC_ARG))
return WC_TEST_RET_ENC_NC;
if (wc_PufCheckSram(testSram, WC_PUF_RAW_BYTES - 1, NULL)
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
/* the same guard on the read path: it is all that stands between a short
* caller buffer and the full-size XMEMCPY into ctx->rawSram */
ret = wc_PufInit(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
if (wc_PufReadSram(&ctx, testSram, WC_PUF_RAW_BYTES - 1)
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
/* A plausible readout passes, and its bias is inside the band. testSram
* has a fixed ~50% bias, so a build that deliberately shifts the band off
* 50% rejects it and is behaving correctly; skip the accept path there
* rather than fail. The band-edge cases above cover the accept path for
* any legal band. */
ones = 0;
ret = wc_PufCheckSram(testSram, sizeof(testSram), &ones);
inBand = (ones * 100U >= (word32)WC_PUF_RAW_BITS * WC_PUF_HW_MIN_PCT &&
ones * 100U <= (word32)WC_PUF_RAW_BITS * WC_PUF_HW_MAX_PCT);
/* judged on the measured weight, so a regression in one of the block
* checks fails here instead of silently skipping the accept path */
if (ret != (inBand ? 0 : WC_NO_ERR_TRACE(PUF_READ_E)))
return WC_TEST_RET_ENC_NC;
/* A rejected read must invalidate a context that a previous read had
* already validated: the flag is otherwise sticky, and enroll would then
* run on the scrubbed rawSram and derive the same key on every device -
* exactly what the health test exists to prevent. */
/* seed from a mid-band weight rather than testSram, so the case works
* whatever band the build configured */
puf_fill_weight(badSram, (onesLow + onesHigh) / 2U);
ret = wc_PufInit(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
ret = wc_PufReadSram(&ctx, badSram, sizeof(badSram));
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
ret = wc_PufEnroll(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
ret = wc_PufGetIdentity(&ctx, id3, sizeof(id3));
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
XMEMSET(badSram, 0x00, sizeof(badSram));
if (wc_PufReadSram(&ctx, badSram, sizeof(badSram))
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
if (wc_PufEnroll(&ctx) != WC_NO_ERR_TRACE(PUF_ENROLL_E))
return WC_TEST_RET_ENC_NC;
if (wc_PufReconstruct(&ctx, helperBuf, WC_PUF_HELPER_BYTES)
!= WC_NO_ERR_TRACE(PUF_RECONSTRUCT_E))
return WC_TEST_RET_ENC_NC;
/* only the readout is invalidated: output already derived from the
* readout that did pass stays available */
ret = wc_PufGetIdentity(&ctx, id2, sizeof(id2));
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
if (XMEMCMP(id2, id3, WC_PUF_ID_SZ) != 0)
return WC_TEST_RET_ENC_NC;
/* the short-size path fails before any copy, so it has to invalidate too
* rather than leave the earlier readout enrollable */
puf_fill_weight(badSram, (onesLow + onesHigh) / 2U);
ret = wc_PufInit(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
ret = wc_PufReadSram(&ctx, badSram, sizeof(badSram));
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
if (wc_PufReadSram(&ctx, badSram, WC_PUF_RAW_BYTES - 1)
!= WC_NO_ERR_TRACE(PUF_READ_E))
return WC_TEST_RET_ENC_NC;
if (wc_PufEnroll(&ctx) != WC_NO_ERR_TRACE(PUF_ENROLL_E))
return WC_TEST_RET_ENC_NC;
/* wc_PufSetTestData must keep bypassing the health test: the KAT vectors
* it injects have to reach the pipeline unfiltered, and a readout this
* degenerate proves the short-circuit in wc_PufReadSram is what lets them
* through. Nothing here derives a key from it. */
XMEMSET(badSram, 0x00, sizeof(badSram));
ret = wc_PufInit(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
ret = wc_PufSetTestData(&ctx, badSram, sizeof(badSram));
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
ret = wc_PufReadSram(&ctx, badSram, sizeof(badSram));
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
/* and still travels the full read -> enroll path on a context that never
* saw wc_PufSetTestData, reproducing the Test 2 identity */
if (inBand) {
ret = wc_PufInit(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
ret = wc_PufReadSram(&ctx, testSram, sizeof(testSram));
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
ret = wc_PufEnroll(&ctx);
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
ret = wc_PufGetIdentity(&ctx, id2, sizeof(id2));
if (ret != 0)
return WC_TEST_RET_ENC_EC(ret);
if (XMEMCMP(id1, id2, WC_PUF_ID_SZ) != 0)
return WC_TEST_RET_ENC_NC;
}
return 0;
#else
return 0;

View File

@ -42,6 +42,14 @@
data, shrinking it to 39-72% of the default size. It changes the stored
format, so the default keeps the layout used by wolfSSL 5.9.2.
Every readout passed to wc_PufReadSram() is health tested first (see
wc_PufCheckSram, and WC_PUF_HW_MIN_PCT / WC_PUF_HW_MAX_PCT for the
Hamming-weight band it must fall inside). A degenerate readout - all zero,
all ones, or a repeating block - is self-consistent through the whole
pipeline and would yield a key identical on every device, so it is
rejected with PUF_READ_E. The SRAM region must therefore be sampled from
reset, before .bss/.data init and before it is used as stack or heap.
Build: ./configure --enable-puf[=small|balanced|strong|strongest]
(auto-enables HKDF). CMake: -DWOLFSSL_PUF=yes with
-DWOLFSSL_PUF_PROFILE=small|balanced|strong|strongest and
@ -95,6 +103,36 @@
#define WC_PUF_BCH_T 10
#endif
/* Startup health-test band for the raw readout, as a percentage of
* WC_PUF_RAW_BITS. A readout whose Hamming weight falls outside this band is
* not plausible SRAM power-on noise (a cleared or written region, a stuck
* peripheral window), so wc_PufReadSram rejects it rather than deriving a
* device-independent key from it. Widen only if the target's SRAM is known to
* be strongly biased and that bias has been measured with wc_PufCheckSram.
* Note the band is a percentage while the spread of a healthy readout narrows
* as sqrt(WC_PUF_RAW_BITS): the default is ~13 sigma wide at 16 codewords but
* only ~3.4 sigma at 1, so a build with one or two codewords should widen it
* rather than accept the occasional false reject at boot.
* The band does not affect the helper-data format or the derived key, so it
* is deliberately not part of WC_PUF_PROFILE_ID. Set it through the build so
* an application sees the same band as the library: autotools records a
* CPPFLAGS -D in wolfssl/options.h, and CMake has
* -DWOLFSSL_PUF_HW_MIN_PCT / -DWOLFSSL_PUF_HW_MAX_PCT. A library-only
* user_settings.h does not carry across, so in that case take the verdict
* from wc_PufCheckSram() rather than recomputing the band. */
#ifndef WC_PUF_HW_MIN_PCT
#define WC_PUF_HW_MIN_PCT 35
#endif
#ifndef WC_PUF_HW_MAX_PCT
#define WC_PUF_HW_MAX_PCT 65
#endif
#if WC_PUF_HW_MIN_PCT >= WC_PUF_HW_MAX_PCT
#error "WC_PUF_HW_MIN_PCT must be less than WC_PUF_HW_MAX_PCT"
#endif
#if WC_PUF_HW_MIN_PCT < 1 || WC_PUF_HW_MAX_PCT > 99
#error "WC_PUF_HW_MIN_PCT/WC_PUF_HW_MAX_PCT must be within 1..99"
#endif
/* Fixed field: GF(2^7), codeword length n = 127 */
#define WC_PUF_BCH_M 7 /* GF(2^7) */
#define WC_PUF_BCH_N 127 /* codeword length */
@ -135,9 +173,28 @@
#define WC_PUF_PARITY_BYTES ((WC_PUF_BCH_DEG + 7) / 8)
/* Raw SRAM readout: 128-bit stride per codeword (n=127 fits in 128 bits) */
#define WC_PUF_RAW_BITS (WC_PUF_NUM_CODEWORDS * 128)
#define WC_PUF_RAW_STRIDE_BITS 128
#define WC_PUF_RAW_STRIDE_BYTES (WC_PUF_RAW_STRIDE_BITS / 8)
#define WC_PUF_RAW_BITS (WC_PUF_NUM_CODEWORDS * WC_PUF_RAW_STRIDE_BITS)
#define WC_PUF_RAW_BYTES (WC_PUF_RAW_BITS / 8)
/* One codeword per stride, so the readout health test can walk the buffer in
* WC_PUF_CW_BYTES blocks. Every shipped profile pins n = 127, which is what
* makes the two equal. Divergence in either direction is a build failure: a
* larger codeword would read past the caller's region, a smaller one would
* leave the tail of every block untested. */
#if WC_PUF_CW_BYTES != WC_PUF_RAW_STRIDE_BYTES
#error "WC_PUF_CW_BYTES must equal the raw readout stride"
#endif
/* The band test compares ones * 100 against WC_PUF_RAW_BITS * pct in word32
* arithmetic. The codeword cap above keeps both sides far inside that range
* (4095 codewords -> 524160 bits -> 52416000), but pin the bound so raising
* the cap fails the build here rather than silently wrapping the check. */
#if WC_PUF_RAW_BITS > (0xFFFFFFFFU / 100U)
#error "WC_PUF_RAW_BITS too large for the health-test band arithmetic"
#endif
/* Reconstructed stable bits: k message bits per codeword, bit-packed */
#define WC_PUF_STABLE_BITS (WC_PUF_NUM_CODEWORDS * WC_PUF_BCH_K)
#define WC_PUF_STABLE_BYTES ((WC_PUF_STABLE_BITS + 7) / 8)
@ -217,6 +274,8 @@ typedef struct wc_PufCtx {
WOLFSSL_API int wc_PufInit(wc_PufCtx* ctx);
WOLFSSL_API int wc_PufReadSram(wc_PufCtx* ctx, const byte* sramAddr,
word32 sramSz);
WOLFSSL_API int wc_PufCheckSram(const byte* sramAddr, word32 sramSz,
word32* onesCount);
WOLFSSL_API int wc_PufEnroll(wc_PufCtx* ctx);
WOLFSSL_API int wc_PufReconstruct(wc_PufCtx* ctx, const byte* helperData,
word32 helperSz);