Skip to content

Commit c9e53f1

Browse files
fix: harden go credential proofs
1 parent ad68e3b commit c9e53f1

7 files changed

Lines changed: 377 additions & 55 deletions

File tree

sdk/go/credential/proof.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package credential
2+
3+
import (
4+
"crypto/hmac"
5+
"encoding/hex"
6+
"encoding/json"
7+
"fmt"
8+
"sort"
9+
"time"
10+
11+
"golang.org/x/crypto/sha3"
12+
)
13+
14+
// ComputeCredentialSubjectMerkleRoot returns the deterministic root covered by
15+
// a credential proof. The subject ID and each attribute are committed as
16+
// independent leaves so claim tampering changes the root.
17+
func ComputeCredentialSubjectMerkleRoot(subject CredentialSubject) (string, error) {
18+
leaves := make([][]byte, 0, len(subject.Attributes)+1)
19+
20+
idLeaf, err := hashSubjectLeaf("id", subject.ID)
21+
if err != nil {
22+
return "", err
23+
}
24+
leaves = append(leaves, idLeaf)
25+
26+
keys := make([]string, 0, len(subject.Attributes))
27+
for key := range subject.Attributes {
28+
keys = append(keys, key)
29+
}
30+
sort.Strings(keys)
31+
32+
for _, key := range keys {
33+
leaf, err := hashSubjectLeaf(key, subject.Attributes[key])
34+
if err != nil {
35+
return "", err
36+
}
37+
leaves = append(leaves, leaf)
38+
}
39+
40+
root := computeMerkleRoot(leaves)
41+
return hex.EncodeToString(root), nil
42+
}
43+
44+
// BuildCredentialProofPayload builds the canonical bytes signed by issuer
45+
// proof keys.
46+
func BuildCredentialProofPayload(cred *VerifiableCredential, subjectMerkleRoot string) ([]byte, error) {
47+
if cred == nil {
48+
return nil, fmt.Errorf("credential: nil credential")
49+
}
50+
payload := map[string]interface{}{
51+
"credentialSchema": cred.SchemaID,
52+
"expirationDate": formatProofTime(cred.ExpirationDate),
53+
"id": cred.ID,
54+
"issuanceDate": formatProofTime(cred.IssuanceDate),
55+
"issuer": cred.Issuer,
56+
"subjectMerkleRoot": subjectMerkleRoot,
57+
"type": cred.Type,
58+
}
59+
return json.Marshal(payload)
60+
}
61+
62+
// ComputeCredentialProofValue computes the issuer-key MAC over a proof payload.
63+
func ComputeCredentialProofValue(signingKey string, payload []byte) string {
64+
mac := hmac.New(sha3.New256, []byte(signingKey))
65+
mac.Write(payload)
66+
return hex.EncodeToString(mac.Sum(nil))
67+
}
68+
69+
func hashSubjectLeaf(key string, value interface{}) ([]byte, error) {
70+
payload, err := json.Marshal(map[string]interface{}{key: value})
71+
if err != nil {
72+
return nil, fmt.Errorf("credential: subject leaf %q is not serializable: %w", key, err)
73+
}
74+
return sha3256(payload), nil
75+
}
76+
77+
func computeMerkleRoot(leaves [][]byte) []byte {
78+
layer := make([][]byte, len(leaves))
79+
copy(layer, leaves)
80+
81+
for len(layer) > 1 {
82+
next := make([][]byte, 0, (len(layer)+1)/2)
83+
for i := 0; i < len(layer); i += 2 {
84+
if i+1 >= len(layer) {
85+
next = append(next, layer[i])
86+
continue
87+
}
88+
next = append(next, hashMerklePair(layer[i], layer[i+1]))
89+
}
90+
layer = next
91+
}
92+
return layer[0]
93+
}
94+
95+
func hashMerklePair(left, right []byte) []byte {
96+
if string(left) <= string(right) {
97+
return sha3256(append(append([]byte{}, left...), right...))
98+
}
99+
return sha3256(append(append([]byte{}, right...), left...))
100+
}
101+
102+
func sha3256(data []byte) []byte {
103+
h := sha3.New256()
104+
h.Write(data)
105+
return h.Sum(nil)
106+
}
107+
108+
func formatProofTime(t time.Time) string {
109+
if t.IsZero() {
110+
return ""
111+
}
112+
return t.UTC().Format(time.RFC3339Nano)
113+
}

sdk/go/credential/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ type Proof struct {
8686
ProofPurpose string `json:"proofPurpose"`
8787
// ProofValue is the encoded proof value.
8888
ProofValue string `json:"proofValue"`
89+
// MerkleRoot commits to the credential subject covered by the proof.
90+
MerkleRoot string `json:"merkleRoot,omitempty"`
8991
}
9092

9193
// VerifiablePresentation represents a W3C Verifiable Presentation.

sdk/go/credential/types_test.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,12 @@ func TestVerifiableCredentialJSON(t *testing.T) {
4747
},
4848
},
4949
Proof: &Proof{
50-
Type: "BbsBlsSignature2020",
50+
Type: "ZeroIDCredentialProof2026",
5151
Created: now,
52-
VerificationMethod: "did:zero:0x1111#key-1",
52+
VerificationMethod: "did:zero:0x1111111111111111111111111111111111111111#key-1",
5353
ProofPurpose: "assertionMethod",
5454
ProofValue: "z3abc123",
55+
MerkleRoot: "abc123",
5556
},
5657
Status: StatusActive,
5758
SchemaID: "https://schema.zeroid.io/identity/v1",
@@ -79,8 +80,11 @@ func TestVerifiableCredentialJSON(t *testing.T) {
7980
if decoded.Proof == nil {
8081
t.Fatal("Proof should not be nil")
8182
}
82-
if decoded.Proof.Type != "BbsBlsSignature2020" {
83-
t.Errorf("Proof.Type = %q, want %q", decoded.Proof.Type, "BbsBlsSignature2020")
83+
if decoded.Proof.Type != "ZeroIDCredentialProof2026" {
84+
t.Errorf("Proof.Type = %q, want %q", decoded.Proof.Type, "ZeroIDCredentialProof2026")
85+
}
86+
if decoded.Proof.MerkleRoot != cred.Proof.MerkleRoot {
87+
t.Errorf("Proof.MerkleRoot = %q, want %q", decoded.Proof.MerkleRoot, cred.Proof.MerkleRoot)
8488
}
8589
if decoded.SchemaID != cred.SchemaID {
8690
t.Errorf("SchemaID = %q, want %q", decoded.SchemaID, cred.SchemaID)
@@ -200,11 +204,12 @@ func TestCredentialSubjectJSON(t *testing.T) {
200204
func TestProofJSON(t *testing.T) {
201205
now := time.Now().Truncate(time.Second)
202206
p := Proof{
203-
Type: "BbsBlsSignature2020",
207+
Type: "ZeroIDCredentialProof2026",
204208
Created: now,
205209
VerificationMethod: "did:zero:0xabc#key-1",
206210
ProofPurpose: "assertionMethod",
207211
ProofValue: "z3signature",
212+
MerkleRoot: "abc123",
208213
}
209214

210215
data, err := json.Marshal(p)
@@ -226,4 +231,7 @@ func TestProofJSON(t *testing.T) {
226231
if decoded.ProofPurpose != p.ProofPurpose {
227232
t.Errorf("ProofPurpose = %q, want %q", decoded.ProofPurpose, p.ProofPurpose)
228233
}
234+
if decoded.MerkleRoot != p.MerkleRoot {
235+
t.Errorf("MerkleRoot = %q, want %q", decoded.MerkleRoot, p.MerkleRoot)
236+
}
229237
}

sdk/go/credential/verifier.go

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package credential
22

33
import (
4+
"crypto/subtle"
45
"errors"
56
"fmt"
7+
"strings"
68
"time"
79
)
810

@@ -20,6 +22,10 @@ var (
2022
ErrIssuerNotApproved = errors.New("credential: issuer not approved")
2123
// ErrNoProof is returned when a credential has no proof.
2224
ErrNoProof = errors.New("credential: no proof")
25+
// ErrInvalidProof is returned when a credential proof is malformed or invalid.
26+
ErrInvalidProof = errors.New("credential: invalid proof")
27+
// ErrIssuerKeyNotTrusted is returned when no trusted proof key is configured for the issuer.
28+
ErrIssuerKeyNotTrusted = errors.New("credential: issuer proof key not trusted")
2329
// ErrSchemaNotFound is returned when the credential schema is not found.
2430
ErrSchemaNotFound = errors.New("credential: schema not found")
2531
)
@@ -49,17 +55,36 @@ type VerificationResult struct {
4955
// Verifier verifies verifiable credentials against on-chain status,
5056
// expiry, and issuer authorization.
5157
type Verifier struct {
52-
schemas SchemaRegistry
53-
issuers IssuerRegistry
54-
now func() time.Time
58+
schemas SchemaRegistry
59+
issuers IssuerRegistry
60+
trustedIssuerKeys map[string]string
61+
now func() time.Time
5562
}
5663

5764
// NewVerifier creates a new credential verifier with the given registries.
5865
func NewVerifier(schemas SchemaRegistry, issuers IssuerRegistry) *Verifier {
5966
return &Verifier{
60-
schemas: schemas,
61-
issuers: issuers,
62-
now: time.Now,
67+
schemas: schemas,
68+
issuers: issuers,
69+
trustedIssuerKeys: make(map[string]string),
70+
now: time.Now,
71+
}
72+
}
73+
74+
// NewVerifierWithTrustedIssuerKeys creates a verifier with issuer-scoped proof keys.
75+
func NewVerifierWithTrustedIssuerKeys(schemas SchemaRegistry, issuers IssuerRegistry, keys map[string]string) *Verifier {
76+
v := NewVerifier(schemas, issuers)
77+
v.SetTrustedIssuerKeys(keys)
78+
return v
79+
}
80+
81+
// SetTrustedIssuerKeys replaces the issuer DID to proof-key mapping used to
82+
// authenticate credential proofs. Keys are copied so callers can safely mutate
83+
// their input map after configuration.
84+
func (v *Verifier) SetTrustedIssuerKeys(keys map[string]string) {
85+
v.trustedIssuerKeys = make(map[string]string, len(keys))
86+
for issuer, key := range keys {
87+
v.trustedIssuerKeys[issuer] = key
6388
}
6489
}
6590

@@ -145,9 +170,63 @@ func (v *Verifier) checkProof(cred *VerifiableCredential, result *VerificationRe
145170
result.Valid = false
146171
result.Checks["proof"] = false
147172
result.Errors = append(result.Errors, ErrNoProof.Error())
148-
} else {
149-
result.Checks["proof"] = true
173+
return
174+
}
175+
176+
proofErrors := make([]string, 0)
177+
proof := cred.Proof
178+
179+
if proof.Type != "ZeroIDCredentialProof2026" {
180+
proofErrors = append(proofErrors, "credential: unsupported proof type")
181+
}
182+
if proof.ProofPurpose != "assertionMethod" {
183+
proofErrors = append(proofErrors, "credential: unsupported proof purpose")
184+
}
185+
if proof.Created.IsZero() {
186+
proofErrors = append(proofErrors, "credential: missing proof creation time")
187+
}
188+
if proof.VerificationMethod == "" {
189+
proofErrors = append(proofErrors, "credential: missing verification method")
190+
} else if !strings.HasPrefix(proof.VerificationMethod, cred.Issuer+"#") {
191+
proofErrors = append(proofErrors, "credential: verification method is not controlled by issuer")
192+
}
193+
if proof.ProofValue == "" {
194+
proofErrors = append(proofErrors, "credential: missing proof value")
150195
}
196+
if proof.MerkleRoot == "" {
197+
proofErrors = append(proofErrors, "credential: missing subject Merkle root")
198+
}
199+
200+
subjectRoot, err := ComputeCredentialSubjectMerkleRoot(cred.CredentialSubject)
201+
if err != nil {
202+
proofErrors = append(proofErrors, err.Error())
203+
} else if proof.MerkleRoot != "" && proof.MerkleRoot != subjectRoot {
204+
proofErrors = append(proofErrors, "credential: subject does not match proof root")
205+
}
206+
207+
issuerKey := v.trustedIssuerKeys[cred.Issuer]
208+
if issuerKey == "" {
209+
proofErrors = append(proofErrors, ErrIssuerKeyNotTrusted.Error())
210+
} else if proof.ProofValue != "" && proof.MerkleRoot != "" {
211+
payload, err := BuildCredentialProofPayload(cred, proof.MerkleRoot)
212+
if err != nil {
213+
proofErrors = append(proofErrors, err.Error())
214+
} else {
215+
expected := ComputeCredentialProofValue(issuerKey, payload)
216+
if subtle.ConstantTimeCompare([]byte(proof.ProofValue), []byte(expected)) != 1 {
217+
proofErrors = append(proofErrors, "credential: proof signature is invalid")
218+
}
219+
}
220+
}
221+
222+
if len(proofErrors) > 0 {
223+
result.Valid = false
224+
result.Checks["proof"] = false
225+
result.Errors = append(result.Errors, proofErrors...)
226+
return
227+
}
228+
229+
result.Checks["proof"] = true
151230
}
152231

153232
func (v *Verifier) checkIssuer(cred *VerifiableCredential, result *VerificationResult) error {

0 commit comments

Comments
 (0)