Skip to content

Bug: SHoC: derived proof reveals salts of non-disclosed claims, enabling offline guessing attacks (+ 2 algorithm inconsistencies) #370

Open

Description

@studyzy

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.

  1. SerializeBase-SHoC: sign proofHash ‖ mandatoryHash ‖ saltedHashes only
    (drop salts; also drop the extra "hash of" — see bug 3 below).
  2. SerializeDerived-SHoC: carry only the salts of selectively disclosed
    claims, i.e. salts[i] = fullSalts[selectiveIndexes[i]].
  3. VerifyDerived-SHoC: toVerify drops salts; length check becomes
    len(salts) == len(selectiveIndexes) == len(nonMandatory); per-claim check
    pairs positionally: H(salts[i] ‖ nonMandatory[i]) == saltedHashes[selectiveIndexes[i]].
  4. ParseDerived-SHoC: update validation (strictly increasing indexes;
    selectiveIndexes bounded by len(saltedHashes)).
  5. CreateDisclosureData: compute selectiveIndexes as positions within the
    non-mandatory list (= indexes into the saltedHashes array), 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)

  1. Index-space mismatch — verification fails today.
    CreateDisclosureData
    computes selectiveIndexes relative 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.
  2. toSign/toVerify mismatch. 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 the SaltedHashSD ednote.
  • 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

  1. Wind4Greg commented on Sep 21, 2026

    @Wind4Greg
    Collaborator

    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.

  2. Wind4Greg commented on Sep 21, 2026

    @Wind4Greg
    Collaborator

    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,".

  3. self-assigned this
    on Sep 21, 2026
  4. studyzy commented on Sep 22, 2026

    @studyzy
    Author

    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 salts array in the base proof with a saltSeed + saltMethod pair, 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions