Skip to content

Commit 73b770a

Browse files
committed
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>
1 parent 5019e00 commit 73b770a

15 files changed

Lines changed: 1285 additions & 28 deletions

File tree

.github/workflows/nightly-fuzz.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ jobs:
5454
- name: identity-multisig-signature-from-bytes
5555
pkg: ./token/services/identity/multisig
5656
func: FuzzMultiSignatureFromBytesNoPanic
57+
- name: identity-boolpolicy-identity-deserializer
58+
pkg: ./token/services/identity/boolpolicy
59+
func: FuzzPolicyIdentityDeserializeNoPanic
60+
- name: identity-boolpolicy-signature-from-bytes
61+
pkg: ./token/services/identity/boolpolicy
62+
func: FuzzPolicySignatureFromBytesNoPanic
5763
- name: identity-idemix-audit-info-deserializer
5864
pkg: ./token/services/identity/idemix/crypto
5965
func: FuzzDeserializeAuditInfoNoPanic

docs/services/identity.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,30 @@ It wraps the raw identity bytes with a type label, enabling the system to verify
177177
- `Type` (string): The identifier of the identity scheme (e.g., `"x509"`, `"idemix"`).
178178
- `Identity` (bytes): The raw payload of the identity, specific to the key manager.
179179

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:
192+
193+
```
194+
3006 020102 040150 -> {Type: 2, "P"} uid NQ5fFHOcay5c
195+
3009 0c0478353039 040150 -> {Type: 2, "P"} uid FK3RQ1kfFhZG
196+
3009 1304783530 39 040150 -> {Type: 2, "P"} uid f4VvsY1uwzTB
197+
```
198+
199+
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+
180204
### Default Key Managers
181205
182206
The identity service includes two primary implementations for concrete identities:
@@ -388,8 +412,10 @@ type MyNewIdentity struct {
388412

389413
func (m *MyNewIdentity) Serialize() ([]byte, error) { return asn1.Marshal(*m) }
390414
func (m *MyNewIdentity) Deserialize(raw []byte) error {
391-
_, err := asn1.Unmarshal(raw, m)
392-
return err
415+
// Never `_, err := asn1.Unmarshal(raw, m)`: discarding the "rest" return
416+
// accepts trailing garbage, which breaks the canonical encoding
417+
// requirement described under TypedIdentity above.
418+
return marshal.UnmarshalStrict(raw, m)
393419
}
394420
```
395421

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package boolpolicy
8+
9+
import (
10+
"encoding/asn1"
11+
"testing"
12+
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
const maxFuzzBoolPolicyBytes = 64 << 10
17+
18+
// FuzzPolicyIdentityDeserializeNoPanic hunts for malformed ASN.1 that panics
19+
// PolicyIdentity.Deserialize instead of returning an error. This is the
20+
// deserialization entry point for policy identities across DeserializeVerifier,
21+
// GetAuditInfoMatcher, Recipients and Unwrap, and it mirrors
22+
// FuzzMultiIdentityDeserializeNoPanic in the structurally identical multisig
23+
// package.
24+
func FuzzPolicyIdentityDeserializeNoPanic(f *testing.F) {
25+
valid, err := (&PolicyIdentity{
26+
Policy: "$0 OR ($1 AND $2)",
27+
Identities: [][]byte{[]byte("alice"), []byte("bob"), []byte("carol")},
28+
}).Bytes()
29+
require.NoError(f, err)
30+
f.Add(valid)
31+
// Trailing bytes after the canonical encoding — the laxness this decode
32+
// path was hardened against; it must now be a clean error, never a panic.
33+
f.Add(append(append([]byte{}, valid...), 0xDE, 0xAD))
34+
// An element smuggled *inside* the SEQUENCE rather than appended after it.
35+
// A rest check cannot see this one, so it is the shape most likely to hide
36+
// a regression.
37+
smuggled, err := asn1.Marshal(policyIdentityWithExtra{
38+
Policy: "$0 OR ($1 AND $2)",
39+
Identities: [][]byte{[]byte("alice"), []byte("bob"), []byte("carol")},
40+
Extra: 42,
41+
})
42+
require.NoError(f, err)
43+
f.Add(smuggled)
44+
f.Add([]byte{})
45+
f.Add([]byte("not asn1"))
46+
f.Add([]byte{0x30, 0x81})
47+
48+
f.Fuzz(func(t *testing.T, raw []byte) {
49+
if len(raw) > maxFuzzBoolPolicyBytes {
50+
t.Skip()
51+
}
52+
pi := &PolicyIdentity{}
53+
var err error
54+
require.NotPanics(t, func() {
55+
err = pi.Deserialize(raw)
56+
})
57+
if err != nil {
58+
return
59+
}
60+
// Canonicality contract: raw was accepted, so it must be the one
61+
// encoding of what it decoded to — any other accepted spelling is a
62+
// second Identity.UniqueID() cache slot for one identity. This is a
63+
// regression guard stated in terms of the contract, not an independent
64+
// check: UnmarshalStrict enforces it by re-marshalling and comparing,
65+
// which is what Bytes() does, so it cannot fail against the current
66+
// implementation. security_test.go carries the actual vectors.
67+
reencoded, err := pi.Bytes()
68+
require.NoError(t, err, "an accepted PolicyIdentity must be re-serializable")
69+
require.Equal(t, raw, reencoded,
70+
"accepted bytes must be the canonical encoding of the decoded identity")
71+
})
72+
}
73+
74+
// FuzzPolicySignatureFromBytesNoPanic hunts for malformed ASN.1 that panics
75+
// PolicySignature.FromBytes instead of returning an error. This is the
76+
// deserialization entry point invoked directly on peer-supplied signature bytes
77+
// in PolicyVerifier.Verify.
78+
func FuzzPolicySignatureFromBytesNoPanic(f *testing.F) {
79+
sigs := [][]byte{[]byte("sig1"), nil, []byte("sig3")}
80+
valid, err := (&PolicySignature{Signatures: sigs}).Bytes()
81+
require.NoError(f, err)
82+
f.Add(valid)
83+
f.Add(append(append([]byte{}, valid...), 0x00))
84+
smuggled, err := asn1.Marshal(policySignatureWithExtra{Signatures: sigs, Extra: 7})
85+
require.NoError(f, err)
86+
f.Add(smuggled)
87+
f.Add([]byte{})
88+
f.Add([]byte("not asn1"))
89+
90+
f.Fuzz(func(t *testing.T, raw []byte) {
91+
if len(raw) > maxFuzzBoolPolicyBytes {
92+
t.Skip()
93+
}
94+
sig := &PolicySignature{}
95+
var err error
96+
require.NotPanics(t, func() {
97+
err = sig.FromBytes(raw)
98+
})
99+
if err != nil {
100+
return
101+
}
102+
reencoded, err := sig.Bytes()
103+
require.NoError(t, err, "an accepted PolicySignature must be re-serializable")
104+
// Contract-level regression guard; see FuzzPolicyIdentityDeserializeNoPanic.
105+
require.Equal(t, raw, reencoded,
106+
"accepted bytes must be the canonical encoding of the decoded signature")
107+
})
108+
}

token/services/identity/boolpolicy/identity.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import (
3939
"github.com/LFDT-Panurus/panurus/token/core/common/encoding/json"
4040
tdriver "github.com/LFDT-Panurus/panurus/token/driver"
4141
"github.com/LFDT-Panurus/panurus/token/services/identity"
42+
"github.com/LFDT-Panurus/panurus/token/services/identity/marshal"
4243
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
4344
)
4445

@@ -74,11 +75,13 @@ func (p *PolicyIdentity) Serialize() ([]byte, error) {
7475
return asn1.Marshal(*p)
7576
}
7677

77-
// Deserialize decodes raw DER bytes into the receiver.
78+
// Deserialize decodes raw DER bytes into the receiver. It rejects trailing
79+
// bytes after the encoded value: raw comes off the wire, and accepting a
80+
// non-canonical re-encoding of an identity would make two distinct byte
81+
// strings decode to the same PolicyIdentity while hashing to different
82+
// Identity.UniqueID()s. See marshal.UnmarshalStrict.
7883
func (p *PolicyIdentity) Deserialize(raw []byte) error {
79-
_, err := asn1.Unmarshal(raw, p)
80-
81-
return err
84+
return marshal.UnmarshalStrict(raw, p)
8285
}
8386

8487
// Bytes is an alias for Serialize, provided for symmetry with MultiIdentity.

token/services/identity/boolpolicy/security_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package boolpolicy
88

99
import (
1010
"context"
11+
"encoding/asn1"
1112
"testing"
1213

1314
"github.com/LFDT-Panurus/panurus/token"
@@ -99,3 +100,164 @@ func TestDuplicateIdentityRejectedAtDeserializeTime(t *testing.T) {
99100
_, err = d.DeserializeVerifier(ctx, Policy, raw)
100101
require.Error(t, err, "a policy identity with a duplicated component identity must be rejected at deserialize time")
101102
}
103+
104+
// TestTrailingBytesRejectedOnIdentityDeserialize is the reproduction from the
105+
// issue: PolicyIdentity.Deserialize discarded encoding/asn1.Unmarshal's "rest"
106+
// return, so a canonical PolicyIdentity with garbage appended still
107+
// deserialized successfully — while hashing to a *different*
108+
// Identity.UniqueID(), because UniqueID() hashes the raw bytes rather than a
109+
// canonicalised form of the decoded value. Since UniqueID() is the cache key
110+
// throughout the identity/wallet layers (role/registry.go's fast path,
111+
// provider.go's signer cache, …), one logical identity could occupy two cache
112+
// slots.
113+
func TestTrailingBytesRejectedOnIdentityDeserialize(t *testing.T) {
114+
canonical, err := (&PolicyIdentity{Policy: "$0 OR $1", Identities: [][]byte{id0, id1}}).Bytes()
115+
require.NoError(t, err)
116+
117+
require.NoError(t, (&PolicyIdentity{}).Deserialize(canonical),
118+
"the canonical encoding must still deserialize")
119+
120+
padded := append(append([]byte{}, canonical...), 0xDE, 0xAD, 0xBE, 0xEF)
121+
122+
require.Error(t, (&PolicyIdentity{}).Deserialize(padded),
123+
"a PolicyIdentity with trailing bytes appended must be rejected")
124+
125+
// This inequality is why the lenient parse mattered: the two byte strings
126+
// decoded to the same logical identity but keyed the caches differently.
127+
require.NotEqual(t, token.Identity(canonical).UniqueID(), token.Identity(padded).UniqueID(),
128+
"UniqueID() hashes the raw bytes, so the padded form was a distinct cache key")
129+
}
130+
131+
// TestTrailingBytesRejectedOnSignatureFromBytes covers the same laxness in
132+
// PolicySignature.FromBytes, which is invoked directly on peer-supplied bytes
133+
// in PolicyVerifier.Verify. Only the policy *envelope* is tightened here; the
134+
// individual signatures it carries are still parsed by their own verifiers,
135+
// which must stay lenient for external signers and HSMs.
136+
func TestTrailingBytesRejectedOnSignatureFromBytes(t *testing.T) {
137+
canonical, err := (&PolicySignature{Signatures: [][]byte{
138+
[]byte("sig1"), []byte("sig2"),
139+
}}).Bytes()
140+
require.NoError(t, err)
141+
142+
require.NoError(t, (&PolicySignature{}).FromBytes(canonical),
143+
"the canonical encoding must still deserialize")
144+
145+
padded := append(append([]byte{}, canonical...), 0x00)
146+
147+
require.Error(t, (&PolicySignature{}).FromBytes(padded),
148+
"a PolicySignature with trailing bytes appended must be rejected")
149+
}
150+
151+
// policyIdentityWithExtra is PolicyIdentity plus one undeclared trailing
152+
// element. Marshalling it is how an attacker writes the smuggled form:
153+
// encoding/asn1 consumes only as many SEQUENCE elements as the destination
154+
// struct has fields and silently drops the rest, so these bytes decode into a
155+
// plain PolicyIdentity with nothing left over.
156+
type policyIdentityWithExtra struct {
157+
Policy string `asn1:"utf8"`
158+
Identities [][]byte
159+
Extra int32
160+
}
161+
162+
// TestExtraElementInsideSequenceRejectedOnIdentityDeserialize covers the vector
163+
// the trailing-byte check alone cannot see. Appending garbage *after* the
164+
// top-level TLV is caught by asn1.Unmarshal's "rest"; moving that same garbage
165+
// *inside* the SEQUENCE, with the outer length grown to cover it, leaves rest
166+
// empty — so the identity decodes to the same policy over the same components
167+
// under raw bytes that hash to a different UniqueID(). Concretely: a token
168+
// transferred to the smuggled form verifies (the verifier works on the decoded
169+
// policy) but never resolves to a component owner's wallet (the lookup works on
170+
// UniqueID()), leaving a valid token none of the owners can see.
171+
func TestExtraElementInsideSequenceRejectedOnIdentityDeserialize(t *testing.T) {
172+
const policy = "$0 OR $1"
173+
ids := [][]byte{id0, id1}
174+
175+
canonical, err := (&PolicyIdentity{Policy: policy, Identities: ids}).Bytes()
176+
require.NoError(t, err)
177+
require.NoError(t, (&PolicyIdentity{}).Deserialize(canonical),
178+
"the canonical encoding must still deserialize")
179+
180+
smuggled, err := asn1.Marshal(policyIdentityWithExtra{Policy: policy, Identities: ids, Extra: 42})
181+
require.NoError(t, err)
182+
require.NotEqual(t, canonical, smuggled, "the two encodings must really differ")
183+
184+
// Baseline: the lax decode accepts it, yields the same policy identity, and
185+
// has nothing left over for a rest check to catch.
186+
var lenient PolicyIdentity
187+
rest, err := asn1.Unmarshal(smuggled, &lenient)
188+
require.NoError(t, err)
189+
require.Empty(t, rest, "the extra element is inside the SEQUENCE, so there is no tail to reject")
190+
require.Equal(t, policy, lenient.Policy)
191+
require.Equal(t, ids, lenient.Identities, "same logical identity, different bytes")
192+
193+
require.NotEqual(t, token.Identity(canonical).UniqueID(), token.Identity(smuggled).UniqueID(),
194+
"UniqueID() hashes the raw bytes, so the smuggled form is a distinct cache key")
195+
196+
require.Error(t, (&PolicyIdentity{}).Deserialize(smuggled),
197+
"a PolicyIdentity with an element smuggled inside the SEQUENCE must be rejected")
198+
}
199+
200+
// TestAlternateStringTagRejectedOnIdentityDeserialize covers a third spelling,
201+
// unique to PolicyIdentity because it is the only one of the four envelopes
202+
// carrying a string field: encoding/asn1 accepts T61String, IA5String and
203+
// GeneralString wherever `asn1:"utf8"` was declared. Flipping that one tag byte
204+
// leaves the decoded Policy identical and the raw bytes — hence the UniqueID()
205+
// — different, with no trailing bytes and no length anomaly for either earlier
206+
// check to catch.
207+
func TestAlternateStringTagRejectedOnIdentityDeserialize(t *testing.T) {
208+
canonical, err := (&PolicyIdentity{Policy: "$0 OR $1", Identities: [][]byte{id0, id1}}).Bytes()
209+
require.NoError(t, err)
210+
211+
// The Policy field is the first element of the SEQUENCE, so its tag is the
212+
// first byte of the body: [SEQUENCE, len, tag, ...].
213+
require.Equal(t, byte(asn1.TagUTF8String), canonical[2],
214+
"fixture assumption: canonical[2] is the Policy field's UTF8String tag")
215+
216+
for _, tag := range []byte{
217+
byte(asn1.TagT61String),
218+
byte(asn1.TagIA5String),
219+
byte(asn1.TagGeneralString),
220+
} {
221+
retagged := append([]byte{}, canonical...)
222+
retagged[2] = tag
223+
224+
// Baseline: same policy string, no leftovers.
225+
var lenient PolicyIdentity
226+
rest, err := asn1.Unmarshal(retagged, &lenient)
227+
require.NoError(t, err, "tag 0x%02X: encoding/asn1 accepts this where UTF8String was declared", tag)
228+
require.Empty(t, rest)
229+
require.Equal(t, "$0 OR $1", lenient.Policy, "tag 0x%02X: same policy, different bytes", tag)
230+
231+
require.NotEqual(t, token.Identity(canonical).UniqueID(), token.Identity(retagged).UniqueID(),
232+
"tag 0x%02X: the retagged form is a distinct cache key", tag)
233+
234+
require.Error(t, (&PolicyIdentity{}).Deserialize(retagged),
235+
"tag 0x%02X: a non-UTF8String spelling of the same policy must be rejected", tag)
236+
}
237+
}
238+
239+
// policySignatureWithExtra is PolicySignature plus one undeclared element, the
240+
// signature-envelope analog of policyIdentityWithExtra.
241+
type policySignatureWithExtra struct {
242+
Signatures [][]byte
243+
Extra int32
244+
}
245+
246+
// TestExtraElementInsideSequenceRejectedOnSignatureFromBytes covers the same
247+
// vector in PolicySignature.FromBytes, which PolicyVerifier.Verify calls
248+
// directly on peer-supplied bytes. The nil slot mirrors an unsigned OR branch,
249+
// which must keep decoding.
250+
func TestExtraElementInsideSequenceRejectedOnSignatureFromBytes(t *testing.T) {
251+
sigs := [][]byte{[]byte("sig1"), nil, []byte("sig3")}
252+
253+
canonical, err := (&PolicySignature{Signatures: sigs}).Bytes()
254+
require.NoError(t, err)
255+
require.NoError(t, (&PolicySignature{}).FromBytes(canonical),
256+
"the canonical encoding, unsigned slot included, must still deserialize")
257+
258+
smuggled, err := asn1.Marshal(policySignatureWithExtra{Signatures: sigs, Extra: 7})
259+
require.NoError(t, err)
260+
261+
require.Error(t, (&PolicySignature{}).FromBytes(smuggled),
262+
"a PolicySignature with an element smuggled inside the SEQUENCE must be rejected")
263+
}

token/services/identity/boolpolicy/sig.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/LFDT-Panurus/panurus/token"
1313
"github.com/LFDT-Panurus/panurus/token/driver"
14+
"github.com/LFDT-Panurus/panurus/token/services/identity/marshal"
1415
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
1516
)
1617

@@ -29,11 +30,15 @@ func (s *PolicySignature) Bytes() ([]byte, error) {
2930
return asn1.Marshal(*s)
3031
}
3132

32-
// FromBytes deserialises raw ASN.1 DER into the receiver.
33+
// FromBytes deserialises raw ASN.1 DER into the receiver. It rejects trailing
34+
// bytes after the encoded value: raw is peer-supplied, and the envelope itself
35+
// is always produced by our own canonical asn1.Marshal, so leftover bytes only
36+
// ever mean a mangled or deliberately padded signature. (This tightens the
37+
// PolicySignature *envelope* only — the individual signatures it carries are
38+
// parsed by their own verifiers, which must stay lenient for external
39+
// signers/HSMs.)
3340
func (s *PolicySignature) FromBytes(raw []byte) error {
34-
_, err := asn1.Unmarshal(raw, s)
35-
36-
return err
41+
return marshal.UnmarshalStrict(raw, s)
3742
}
3843

3944
// JoinSignatures builds a PolicySignature from a map of per-identity

0 commit comments

Comments
 (0)