Repository navigation
Bug: SHoC: derived proof reveals salts of non-disclosed claims, enabling offline guessing attacks (+ 2 algorithm inconsistencies) #370
Description
Summary
The current SHoC algorithms place the complete salts array into the derived proof
(SerializeDerived-SHoC)
and require it for verification
(VerifyDerived-SHoC,
toVerify = proofHash ‖ mandatoryHash ‖ salts ‖ saltedHashes).
The explainer text acknowledges this ("the complete list of salts and hashes must be
conveyed to both holder and verifier") but only lists size and total-count leakage
as downsides.
For a non-disclosed claim, the verifier knows salt_i and
saltedHashes[i] = H(salt_i ‖ claim_i), which enables a per-target offline
dictionary attack: hash each candidate value against the known salt and compare.
Cost = |candidate domain| hashes — trivial for low-entropy claims (birth year,
country code, boolean attributes, postal codes). This is not a rainbow-table
attack (salts are per-claim random), but it is practically equivalent.
SIC, BBS, and SD-JWT do not have this issue: they reveal no commitment (or no
salt) for non-disclosed claims. As drafted, SHoC is strictly worse than SD-JWT
in this respect.
Root cause
A single signature covers the concatenation salts ‖ saltedHashes, so the verifier
must reconstruct that exact byte string to check the signature — hence all salts
must be shipped. Signing the salts adds no security: the holder already receives
all of them, and each disclosed salt is bound to its signed hash by the check
H(salt ‖ claim) == saltedHashes[pos] (preimage-resistant).
Proposed fix
Invariant: the salt of a non-disclosed claim never leaves the holder.
- SerializeBase-SHoC: sign
proofHash ‖ mandatoryHash ‖ saltedHashesonly
(dropsalts; also drop the extra "hash of" — see bug 3 below). - SerializeDerived-SHoC: carry only the salts of selectively disclosed
claims, i.e.salts[i] = fullSalts[selectiveIndexes[i]]. - VerifyDerived-SHoC:
toVerifydropssalts; length check becomes
len(salts) == len(selectiveIndexes) == len(nonMandatory); per-claim check
pairs positionally:H(salts[i] ‖ nonMandatory[i]) == saltedHashes[selectiveIndexes[i]]. - ParseDerived-SHoC: update validation (strictly increasing indexes;
selectiveIndexesbounded bylen(saltedHashes)). - CreateDisclosureData: compute
selectiveIndexesas positions within the
non-mandatory list (= indexes into thesaltedHashesarray), not relative to
the combined output.
Effects: derived proof size drops from N×(salt+hash) + sig to
N×hash + k×salt + sig; guessing attacks now require recovering a 128-bit
CSPRNG salt in addition to the claim value; integrity is unaffected (forging a
disclosed (salt, claim) pair still requires a preimage of a signed hash).
Residual leakage (total count N, disclosed positions, cross-presentation
linkability) is unchanged from the current baseline.
Two additional inconsistencies found (the fix touches the same algorithms)
- Index-space mismatch — verification fails today.
CreateDisclosureData
computesselectiveIndexesrelative to the combined output (the revealed
document), but VerifyDerived-SHoC uses them as indexes into the salted-hashes
table (all non-mandatory claims). The verifier cannot convert between the two,
since table positions of non-disclosed claims are unknown to it. Whenever any
mandatory N-Quad precedes a disclosed selective one in canonical order — the
common case — the hash checks misalign and verification fails. The ednote in
ParseDerived-SHoC assumes table-space semantics, contradicting
CreateDisclosureData. toSign/toVerifymismatch. SerializeBase-SHoC signs the hash of the
concatenation; VerifyDerived-SHoC verifies against the raw concatenation.
SIC signs and verifies the plain concatenation on both sides. Align SHoC to
the SIC convention.
Suggested housekeeping
- Update the intro text and
SDApproachTable
(derived proof contents/size:N×hash + k×salt + sig). - Add a Security/Privacy Considerations subsection on salt confidentiality and
guessing attacks (currently absent), including the CSPRNG references already
flagged in theSaltedHashSDednote. - SHoC header bytes are still tentative and no published cryptosuite ships SHoC,
so the change can land in place; the companion Quantum-Safe-SD draft needs syncing. - Open question: should salt length stay at hash-length/2 (16 bytes for SHA-256)
now that salts are confidential parameters?
Activity
Wind4Greg commentedon Sep 21, 2026
Thanks @studyzy, good catches. Will update. Are you saying you want to reduce the salt lengths? I was using a similar justification on salt size as SD-JWT.
Wind4Greg commentedon Sep 21, 2026
Hi @studyzy, it looks like step 11 of Create Disclosure Data is wrong "For each absoluteIndex in the keys in groups.combined.matching, " and should be "For each absoluteIndex in the keys in groups.mandatory.nonMatching,".
studyzy commentedon Sep 22, 2026
Thanks for the quick response and for confirming the fixes, @Wind4Greg.
To clarify my open question: no, I'm not proposing to reduce the salt length. Keeping it at hash-length/2 (16 bytes for SHA-256) is fine — the SD-JWT-aligned justification works for me, especially once the salts of non-disclosed claims become confidential per the fix here.
My concern about salt overhead is about the base-proof encoding rather than salt length: I've filed #372, proposing to replace the full
saltsarray in the base proof with asaltSeed+saltMethodpair, from which the holder re-derives all salts locally. That proposal is independent of, and compatible with, the fix in this issue — the derived proof stays untouched.