From 8bd08f4f582e8d37de5f0b537cbba67753567f63 Mon Sep 17 00:00:00 2001 From: "julian.speith" Date: Wed, 11 Nov 2026 14:02:12 +0100 Subject: [PATCH 1/4] Fix missing flip-flop edges in DANA's successor cache identify_successors_predecessors shares its per-net caches across the traversals of all target gates, but a traversal does not push a net it has visited before. With reconvergent fanout, the nets on the second path to such a net never got its targets, so their cache entries stayed incomplete, and every later traversal that hit them lost those edges. On two synthesized AES netlists (about 850 flip-flops, 20k edges), 178 and 202 edges were missing, which split a 128-bit register into 120 + 8 bits in the steered analysis. Hand the cache entry of a visited net to the nets of the current path, unless it lies on the path itself (a combinational loop, still being explored). The same applies to the predecessor cache of known net groups. The flip-flop graph now matches the one of NetlistTraversalDecorator::get_next_sequential_gates_map on both netlists. Co-Authored-By: Claude Opus 5.5 --- .../src/pre_processing/pre_processing.cpp | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/plugins/dataflow_analysis/src/pre_processing/pre_processing.cpp b/plugins/dataflow_analysis/src/pre_processing/pre_processing.cpp index 0a1daa8e7dc..6f05eee5f8c 100644 --- a/plugins/dataflow_analysis/src/pre_processing/pre_processing.cpp +++ b/plugins/dataflow_analysis/src/pre_processing/pre_processing.cpp @@ -107,6 +107,48 @@ namespace hal // find successors std::unordered_map, std::unordered_set>> suc_cache; std::unordered_map> pred_cache; + + // The caches are shared by the traversals of all target gates, so an entry is trusted to be + // complete. A traversal does not push a net that it has visited before, though, so whatever such + // a net leads to has to be handed to the nets of the current path explicitly. Otherwise, the + // entries of these nets miss it, and so does every later traversal that finds them in the cache. + // A visited net that is not on the current path has been explored completely; one on the path + // closes a combinational loop and is still being explored, so its entry is not complete yet. + const auto on_path = [](const Net* net, const Net* current, const std::vector& previous) { + return net == current || std::find(previous.cbegin(), previous.cend(), net) != previous.cend(); + }; + const auto pass_on_successors = [&suc_cache, &on_path](const Net* visited_net, const Net* current, const std::vector& previous) { + const auto it = suc_cache.find(visited_net); + if (it == suc_cache.end() || on_path(visited_net, current, previous)) + { + return; + } + // references to the elements of an unordered_map stay valid when inserting rehashes it + const auto& [cached_gates, cached_net_groups] = it->second; + for (const Net* n : previous) + { + auto& entry = suc_cache[n]; + std::get<0>(entry).insert(cached_gates.cbegin(), cached_gates.cend()); + std::get<1>(entry).insert(cached_net_groups.cbegin(), cached_net_groups.cend()); + } + auto& entry = suc_cache[current]; + std::get<0>(entry).insert(cached_gates.cbegin(), cached_gates.cend()); + std::get<1>(entry).insert(cached_net_groups.cbegin(), cached_net_groups.cend()); + }; + const auto pass_on_predecessors = [&pred_cache, &on_path](const Net* visited_net, const Net* current, const std::vector& previous) { + const auto it = pred_cache.find(visited_net); + if (it == pred_cache.end() || on_path(visited_net, current, previous)) + { + return; + } + const auto& cached_net_groups = it->second; + for (const Net* n : previous) + { + pred_cache[n].insert(cached_net_groups.cbegin(), cached_net_groups.cend()); + } + pred_cache[current].insert(cached_net_groups.cbegin(), cached_net_groups.cend()); + }; + for (const auto& gate : netlist_abstr.target_gates) { cnt++; @@ -203,6 +245,10 @@ namespace hal stack.push_back(n); added = true; } + else + { + pass_on_successors(n, current, previous); + } } } } @@ -279,6 +325,10 @@ namespace hal stack.push_back(n); added = true; } + else + { + pass_on_predecessors(n, current, previous); + } } } } From ec8f9f84d1ddc80d5fb8c5ff5b8f53f5a2f284c2 Mon Sep 17 00:00:00 2001 From: "julian.speith" Date: Wed, 11 Nov 2026 14:02:12 +0100 Subject: [PATCH 2/4] Fix HAWKEYE S-box identification with surplus outputs and for the inversion class lookup_sbox reduced surplus outputs by a linear elimination that reused pivot outputs for later rows, so it miscounted the rank (too high in about a fifth of random tests). It also treated a complemented copy of an output as independent, although identify_sbox holds key inputs at 0, which turns a key addition (XOR/XNOR) into exactly such a copy. Use a proper echelon-form elimination with the constant function in the basis first, and keep the outputs as they are (the database does not care about output constants). On a synthesized AES netlist, this identifies all 16 S-boxes instead of 6. SBoxDatabase::lookup gave up as soon as the canonical form search ran out of budget, assuming that this happens for every output constant alike. It does not: a table that maps 0 to 0 takes a search of its own, which exhausts the budget for the inversion class, i.e., for every AES-equivalent table with T(0) = 0. Skip that constant and go on with the others in lookup, and leave the entry out in add instead of failing, so that SBoxDatabase().add("AES", ...) works again. Co-Authored-By: Claude Opus 5.5 --- plugins/hawkeye/src/cipher_candidate.cpp | 60 ++++++++++++++---------- plugins/hawkeye/src/sbox_database.cpp | 18 ++++++- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/plugins/hawkeye/src/cipher_candidate.cpp b/plugins/hawkeye/src/cipher_candidate.cpp index 86a522268cd..44c158feadb 100644 --- a/plugins/hawkeye/src/cipher_candidate.cpp +++ b/plugins/hawkeye/src/cipher_candidate.cpp @@ -72,8 +72,8 @@ namespace hal * the S-box that it describes and look that up in the database. * * Yields an empty name if the rows do not describe a usable S-box at all, that is, if there are more - * outputs than inputs and they cannot be reduced to as many linearly independent ones as there are - * inputs, or if the S-box is not bijective. Neither is an error: it only means that this guess at the + * outputs than inputs and they cannot be reduced to as many ones that are independent up to constants as + * there are inputs, or if the S-box is not bijective. Neither is an error: it only means that this guess at the * outputs of an S-box was a wrong one. */ Result lookup_sbox(const std::vector>& rows, const u32 num_inputs, const SBoxDatabase& db) @@ -102,44 +102,54 @@ namespace hal values.push_back(u64_res.get()); } - // More outputs than inputs means that some of them are linear combinations of the others, so reduce - // to a linearly independent set by Gaussian elimination over GF(2) and keep only those outputs. Every - // output is one vector over the rows of the table, which is why the elimination runs over the rows. + // More outputs than inputs means that some of them depend on the others, so keep only an independent + // subset of them. Every output is one vector over the rows of the table. An output may also be the + // complement of a combination of others, e.g., an XNOR, an inverter, or a key addition whose key bit + // identify_sbox holds at 0, so the outputs have to be independent modulo the constant function, not + // just linearly: the constant goes into the basis first. The database does not care about a constant + // XORed onto the outputs, so the outputs that are kept stay as they are. if (num_outputs != num_inputs) { - std::vector> mat(num_outputs); - for (u32 out = 0; out < num_outputs; out++) - { - for (u32 row = 0; row < num_rows; row++) - { - mat[out][row] = (values[row] >> out) & 1; - } - } - - for (u32 row = 0; row < num_rows; row++) - { - u32 pivot = 0; - for (u32 out = 0; out < num_outputs; out++) + // The basis is kept in echelon form: no basis vector has a bit set at the pivot row of an earlier + // one, so a single pass over the basis reduces a vector completely. + std::vector> basis; + std::vector pivots; + const auto add_if_independent = [&basis, &pivots, num_rows](std::bitset vec) { + for (u32 i = 0; i < basis.size(); i++) { - if (mat[out][row]) + if (vec[pivots[i]]) { - pivot = out; - break; + vec ^= basis[i]; } } - for (u32 out = pivot + 1; out < num_outputs; out++) + for (u32 row = 0; row < num_rows; row++) { - if (mat[out][row]) + if (vec[row]) { - mat[out] ^= mat[pivot]; + basis.push_back(vec); + pivots.push_back(row); + return true; } } + return false; + }; + + std::bitset constant; + for (u32 row = 0; row < num_rows; row++) + { + constant[row] = 1; } + add_if_independent(constant); std::vector independent; for (u32 out = 0; out < num_outputs; out++) { - if (mat[out].any()) + std::bitset vec; + for (u32 row = 0; row < num_rows; row++) + { + vec[row] = (values[row] >> out) & 1; + } + if (add_if_independent(vec)) { independent.push_back(out); } diff --git a/plugins/hawkeye/src/sbox_database.cpp b/plugins/hawkeye/src/sbox_database.cpp index a29005f2f26..40caed4372f 100644 --- a/plugins/hawkeye/src/sbox_database.cpp +++ b/plugins/hawkeye/src/sbox_database.cpp @@ -337,6 +337,13 @@ namespace hal auto lin_rep = compute_linear_representative_bounded(sbox_alpha, budget); if (budget == 0) { + if (sbox_alpha.front() == 0) + { + // A table that maps 0 to 0 takes a search of its own, which can exhaust the budget for this + // one alpha only, e.g., for AES and alpha = 0x63 (inversion in GF(2^8)). Leave the entry out: + // lookup skips the corresponding beta as well and finds such an S-box through the others. + continue; + } // storing a representative from a truncated search would silently break every lookup against this // entry, and a real S-box is never degenerate enough to exhaust the search in the first place return ERR("cannot add S-box '" + name + "' to the database: the canonical form search was abandoned, the S-box is too close to linear"); @@ -498,8 +505,15 @@ namespace hal auto lin_rep = compute_linear_representative_bounded(sbox_beta, budget); if (budget == 0) { - // XORing a constant onto the outputs does not change how close to linear the table is, so if the - // search degenerates for one beta it degenerates for all of them, and no real S-box ever does + if (sbox_beta.front() == 0) + { + // A table that maps 0 to 0 takes a search of its own (see compute_linear_representative_bounded), + // which can exhaust the budget for this one beta only, e.g., for AES XORed with 0x63, which is + // linearly equivalent to inversion in GF(2^8). The other betas still find such an S-box. + continue; + } + // apart from that, XORing a constant onto the outputs does not change how close to linear the + // table is, so if the search degenerates for one beta it degenerates for all of them log_info("hawkeye", "giving up the S-box lookup, as the table is too close to linear to be a real S-box."); break; } From e258778c7c6687f9a0aab72403a72bd97e3e9de3 Mon Sep 17 00:00:00 2001 From: "julian.speith" Date: Wed, 11 Nov 2026 14:02:12 +0100 Subject: [PATCH 3/4] Require at least 3 input bits for S-boxes found by splitting a component locate_sboxes accepted every split of a component into equal groups of more than one flip-flop, while whole components need at least 3. When synthesis starts the S-boxes with 2-to-4 predecoders on pairs of input bits, the round function splits into groups of 2 first, which produced 64 bogus 2-bit S-boxes (384 of 816 located ones) on an AES netlist. Use one minimum for both cases. Co-Authored-By: Claude Opus 5.5 --- plugins/hawkeye/src/cipher_candidate.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/plugins/hawkeye/src/cipher_candidate.cpp b/plugins/hawkeye/src/cipher_candidate.cpp index 44c158feadb..6d374630a85 100644 --- a/plugins/hawkeye/src/cipher_candidate.cpp +++ b/plugins/hawkeye/src/cipher_candidate.cpp @@ -50,6 +50,12 @@ namespace hal /** An S-box is looked up as a vector of `u8`, so it reads and writes at most 8 bits. */ constexpr u32 MAX_SBOX_BITS = 8; + /** + * An S-box reads at least 3 bits. Fewer make for no useful S-box, and synthesis often starts an S-box with + * 2-to-4 predecoders on pairs of its input bits, which would otherwise pass for many small S-boxes. + */ + constexpr u32 MIN_SBOX_BITS = 3; + /** * The most control inputs an S-box may read for identification to try every assignment of them. The * assignment loop is exponential in this, so every additional bit doubles what an S-box that matches @@ -425,7 +431,7 @@ namespace hal const std::vector component = sorted_by_id(component_gates); const u32 number_input_ffs = component_input_ffs.size(); - if (number_input_ffs < 3) + if (number_input_ffs < MIN_SBOX_BITS) { // too small for an S-box continue; @@ -529,8 +535,8 @@ namespace hal } // the S-boxes of a round function are alike, so only accept a split into more than one group of - // equal size, each reading more than one flip-flop - if (group_sizes.size() == 1 && input_groups.size() > 1 && input_groups.front().size() > 1) + // equal size, each large enough for an S-box + if (group_sizes.size() == 1 && input_groups.size() > 1 && input_groups.front().size() >= MIN_SBOX_BITS) { input_groupings.push_back(std::move(input_groups)); } From bbad8ce8c409b0ff684c915f0895a45f44e01188 Mon Sep 17 00:00:00 2001 From: "julian.speith" Date: Wed, 11 Nov 2026 15:29:11 +0100 Subject: [PATCH 4/4] Reject S-boxes by their differential spectrum before the canonical form search SBoxDatabase::lookup computes a canonical form for every output constant beta, which takes about 20 seconds for an 8-bit table that matches no entry. With the corrected output reduction, such tables now reach the lookup more often, e.g., two 4-bit S-boxes side by side that HAWKEYE locates as one 8-bit S-box in LED, which made the ASIC LED benchmark go from 2 to 53 seconds. Affine equivalence and XORing constants only permute the entries of the difference distribution table, so compare the spectrum of the table with those of the entries of the database first and give up right away if none matches. Rejecting a non-matching 8-bit table now takes 0.03 seconds; every representative stored in the shipped database is still found. Co-Authored-By: Claude Opus 5.5 --- plugins/hawkeye/src/sbox_database.cpp | 40 +++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/plugins/hawkeye/src/sbox_database.cpp b/plugins/hawkeye/src/sbox_database.cpp index 40caed4372f..833e10dd4b8 100644 --- a/plugins/hawkeye/src/sbox_database.cpp +++ b/plugins/hawkeye/src/sbox_database.cpp @@ -6,6 +6,7 @@ #include "rapidjson/stringbuffer.h" #include "rapidjson/writer.h" +#include #include #include #include @@ -296,6 +297,32 @@ namespace hal constexpr u64 LINEAR_REPRESENTATIVE_BUDGET = 250000; std::vector compute_linear_representative_bounded(const std::vector& sbox, u64& budget); + + /** + * How often every value occurs in the difference distribution table of an S-box, over all nonzero input + * differences: entry c counts the pairs of input and output difference that c inputs map onto each other. + * Linear and affine equivalence and XORing constants onto inputs or outputs only permute the entries of the + * table, so two S-boxes that the database considers equal have the same spectrum (but not vice versa). + */ + std::vector differential_spectrum(const std::vector& sbox) + { + const u32 len = sbox.size(); + std::vector spectrum(len + 1, 0); + std::vector row(256); + for (u32 a = 1; a < len; a++) + { + std::fill(row.begin(), row.end(), 0); + for (u32 x = 0; x < len; x++) + { + row[sbox[x] ^ sbox[x ^ a]]++; + } + for (const u32 count : row) + { + spectrum[count]++; + } + } + return spectrum; + } } // namespace SBoxDatabase::SBoxDatabase(const std::map>& sboxes) @@ -490,6 +517,16 @@ namespace hal { return ERR("no S-box of matching bit-size of " + std::to_string(bit_size) + " bits contained in database"); } + const auto& matching_size_data = std::get<1>(*size_it); + + // The canonical form search below runs once per beta and costs a lot for 8-bit tables (about 20 seconds + // for one that matches nothing), so first rule out the tables that no entry can match at all: every entry + // of the same equivalence class has the same differential spectrum. + const auto spectrum = differential_spectrum(sbox); + if (std::none_of(matching_size_data.cbegin(), matching_size_data.cend(), [&spectrum](const auto& entry) { return differential_spectrum(entry.first) == spectrum; })) + { + return ERR("no match found within database"); + } // beta has to count beyond the largest table index, so it must be wider than a table entry: a u8 stays // below a size of 256 forever, which made this loop endless for every 8-bit S-box not in the database @@ -518,8 +555,7 @@ namespace hal break; } - const auto& matching_size_data = std::get<1>(*size_it); - const auto rep_it = matching_size_data.find(lin_rep); + const auto rep_it = matching_size_data.find(lin_rep); if (rep_it != matching_size_data.end()) { return OK(rep_it->second.front().first);