You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(identity): require canonical DER in every identity decode path
Identity.UniqueID() hashes the raw identity bytes rather than a canonicalised
form of the decoded value, and it is the cache key throughout the identity and
wallet layers (role/registry.go's fast path, provider.go's signer cache). Any
two byte strings that decode to the same logical identity but hash differently
give that one identity two cache slots: a token paid to the second spelling
still verifies, because verification works on the decoded value, but never
resolves to its owner's wallet, because the lookup works on UniqueID().
Every identity decode path allowed exactly that. The four encoding/asn1 sites
(MultiIdentity.Deserialize, PolicyIdentity.Deserialize, MultiSignature.FromBytes,
PolicySignature.FromBytes) discarded asn1.Unmarshal's "rest" return, and
DecodeIdentity read the outer SEQUENCE length only to throw it away:
canonical : 300e 300c 0405 616c696365 0403 626f62
variant : 3011 300c 0405 616c696365 0403 626f62 02012a
Both decode to MultiIdentity{alice, bob}; both were accepted; their UniqueID()s
differ.
Four spellings of one identity were reachable:
- garbage appended after the value
- garbage smuggled inside the SEQUENCE with the outer length grown to cover
it. This is the one a "rest" check cannot see: rest reports only bytes after
the top-level TLV, and encoding/asn1 silently discards SEQUENCE elements the
destination struct has no field for, so the outer TLV consumes the whole
input and the parse looks clean
- T61String/IA5String/GeneralString where UTF8String was declared, which
encoding/asn1 accepts
- non-minimal lengths (0x81 0x06) and non-minimal INTEGER contents
(02 02 00 05)
marshal.UnmarshalStrict, which the four asn1 sites now route through, checks
rest and then re-encodes the decoded value and requires it to reproduce the
input byte-for-byte (ErrNonCanonical). One check covers the first three and any
further encoding/asn1 leniency, for one extra marshal of a small struct per
decode. DecodeIdentity — the TypedIdentity envelope and the hot path, which
hand-walks the TLVs and does not re-marshal — pins the outer SEQUENCE length to
len(b), requires the OCTET STRING to end exactly at len(b), and rejects
non-minimal lengths (ErrNonMinimalLen) and non-minimal integers
(ErrNonMinimalInt).
Nothing previously written can be rejected. The envelope is single-sourced: all
seven construction sites funnel through TypedIdentity.Bytes() ->
marshal.EncodeIdentity, and the four inner envelopes only through their own
asn1.Marshal. Every one of those encoders is canonical — appendTLV emits minimal
lengths, encodeInt32 strips exactly the bytes parseInt32 now rejects, and
asn1.Marshal is canonical by construction, including the legacy string-typed
spellings written by older versions of this SDK. There is no non-Go producer of
these bytes, so no identity on a ledger or in storage becomes undecodable and
old and new nodes cannot disagree during a rolling upgrade. Key material is
untouched: x509 certs and idemix credentials live inside the OCTET STRING and
never reach these decoders.
Scope is the DER envelopes. Signature parsing (x509/crypto/ecdsa.go,
idemixnym/nym/signer.go) stays lenient for external signers and HSMs; the
MultiSignature/PolicySignature envelopes are strict because we always produce
them, the signatures they carry are not. Two things remain malleable and are now
documented rather than implied away: the protobuf payload inside the OCTET
STRING, which is not DER at all, and the legacy type fold, where INTEGER 2,
UTF8String "x509" and PrintableString "x509" still give one x509 identity three
UniqueID()s. This change narrows that set from unbounded to exactly three;
closing it to one needs a rule at the validator boundary, since the older
spellings may exist in persisted data, and is tracked separately.
UnmarshalStrict's round-trip means "b is what asn1.Marshal would emit", which is
narrower than "b is valid DER": a field tagged optional or omitempty, or a
time.Time, would false-reject legal encodings. None of the four types has one,
and TestUnmarshalStrict_FourCallSitesHaveNoOptionalFields reflects over them so
a future field trips there rather than in production.
Tests cover every vector at each affected site, asserting the bypass really did
decode to the same value with an empty rest and a different UniqueID() rather
than only that it now errors, alongside guards that our own encoders' output is
never rejected. The five fuzz targets also assert canonicality; at the four asn1
sites that is a contract-level regression guard rather than an independent check
(UnmarshalStrict enforces it the way Bytes() computes it), and it is a real
check only in FuzzDecodeIdentityNoPanic, where DecodeIdentity and EncodeIdentity
are separate implementations. boolpolicy's two targets are new and wired into
the nightly-fuzz matrix. Two TestDecodeErrors fixtures had an outer length
disagreeing with their own buffer, so the new check fired before the inner
failure they were named for; their lengths are corrected rather than their
expectations.
Signed-off-by: AkramBitar <akram@il.ibm.com>
Copy file name to clipboardExpand all lines: docs/services/identity.md
+28-2Lines changed: 28 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -177,6 +177,30 @@ It wraps the raw identity bytes with a type label, enabling the system to verify
177
177
-`Type` (string): The identifier of the identity scheme (e.g., `"x509"`, `"idemix"`).
178
178
-`Identity` (bytes): The raw payload of the identity, specific to the key manager.
179
179
180
+
#### Canonical encoding requirement
181
+
182
+
The **DER envelopes** of an identity must be the canonical encoding of the value they decode to — exactly one byte string per logical envelope:
183
+
184
+
*`marshal.DecodeIdentity` (the `TypedIdentity` envelope decoder in `token/services/identity/marshal`) pins the outer `SEQUENCE`'s declared length to the end of the buffer, requires the read position to land exactly on the last byte after the final field (`ErrTrailingBytes`), rejects non-minimal DER length encodings — a length that fits the short form written in the long form, or a long form with leading zero bytes (`ErrNonMinimalLen`) — and rejects non-minimal `INTEGER` contents, i.e. a redundant leading `0x00`/`0xFF` in the type field (`ErrNonMinimalInt`).
185
+
* Envelopes decoded with `encoding/asn1` (`MultiIdentity`, `PolicyIdentity`, `MultiSignature`, `PolicySignature`) go through `marshal.UnmarshalStrict`, which rejects any bytes left over after the top-level value **and** re-encodes the decoded value to require it reproduces the input byte-for-byte (`ErrNonCanonical`). The second check is the load-bearing one: `asn1.Unmarshal`'s `rest` return only reports bytes *after* the top-level TLV, while `encoding/asn1` silently discards `SEQUENCE` elements the destination struct has no field for and accepts `T61String`/`IA5String`/`GeneralString` where a `UTF8String` was declared — neither of which leaves anything in `rest`.
186
+
187
+
The reason is `Identity.UniqueID()`: it hashes the **raw** identity bytes rather than a canonicalised form of the decoded value, and it is the cache key throughout the identity and wallet layers (`role/registry.go`'s fast-path cache, `provider.go`'s signer cache, and so on). Any two byte strings that decode to the same logical identity but hash differently give that one identity two cache slots — a token paid to the second spelling still verifies, because verification works on the decoded value, but never resolves to its owner's wallet, because the lookup works on `UniqueID()`. Both producers of these bytes — `appendTLV` in the `marshal` package and `encoding/asn1.Marshal` for legacy encodings — already emit minimal lengths, minimal integers and no undeclared elements, so the stricter decode rejects nothing this tree ever writes.
188
+
189
+
**What this does not cover.** The guarantee is about the envelopes, not about everything reachable through them:
190
+
191
+
***The legacy type spellings remain a `UniqueID()` split, and it is the same class of problem as the one above.**`DecodeIdentity` folds `INTEGER 2`, `UTF8String "x509"` and `PrintableString "x509"` onto the same type for compatibility with identities written by older versions of this SDK. For one x509 identity that is three accepted byte strings and therefore three `UniqueID()`s:
A token paid to the second or third spelling of a victim's identity verifies — validation decodes type 2 and checks the payload's cert — but does not resolve to that owner's wallet. The checks above reduce this set from unbounded to exactly three; closing it to one cannot be done in the decoder, because the older spellings may exist in persisted data. It needs a rule at the validator boundary: require the `INTEGER` spelling for identities in *new* transactions while still decoding the others for reads. Out of scope here, tracked separately.
200
+
* The payload inside the `TypedIdentity` `OCTET STRING` is **protobuf** for x509 and idemix identities (`x509/crypto/config.go`, `idemix/crypto/deserializer.go`), not DER. Protobuf permits field reordering and redundant varints, so those payload bytes remain malleable and the checks above say nothing about them.
201
+
202
+
This applies to identity decoding only. Signature parsing (`x509/crypto/ecdsa.go`, `idemixnym/nym/signer.go`) stays deliberately lenient: those bytes come from external signers and HSMs whose DER encoders are routinely non-minimal in ways that are still valid for signature purposes. The `MultiSignature` / `PolicySignature` *envelopes* are strict, because we always produce them ourselves; the individual signatures they carry are not.
203
+
180
204
### Default Key Managers
181
205
182
206
The identity service includes two primary implementations for concrete identities:
0 commit comments