Skip to content

Commit 5c3d86f

Browse files
committed
make marshalling of authorized keys deterministic (#1672)
* make marshalling of authorized keys deterministic * fix test
1 parent b63d5cb commit 5c3d86f

File tree

2 files changed

+130
-21
lines changed

2 files changed

+130
-21
lines changed

pkg/types/gateway/workflow_metadata.go

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ package gateway
33
import (
44
"crypto/sha256"
55
"encoding/hex"
6-
"encoding/json"
6+
"fmt"
7+
"sort"
8+
9+
jsonv2 "github.com/go-json-experiment/json"
10+
"github.com/go-json-experiment/json/jsontext"
711
)
812

913
const (
@@ -23,32 +27,42 @@ type WorkflowMetadata struct {
2327
AuthorizedKeys []AuthorizedKey
2428
}
2529

30+
// Digest returns a digest of the workflow metadata. This is used for aggregating metadata
31+
// across multiple nodes. The digest is a SHA256 hash of the canonical JSON representation,
32+
// ensuring deterministic output regardless of the order in which authorized keys are reported.
2633
func (wm *WorkflowMetadata) Digest() (string, error) {
27-
data, err := json.Marshal(wm)
34+
sortedMetadata := WorkflowMetadata{
35+
WorkflowSelector: wm.WorkflowSelector,
36+
AuthorizedKeys: make([]AuthorizedKey, len(wm.AuthorizedKeys)),
37+
}
38+
copy(sortedMetadata.AuthorizedKeys, wm.AuthorizedKeys)
39+
sort.Slice(sortedMetadata.AuthorizedKeys, func(i, j int) bool {
40+
if sortedMetadata.AuthorizedKeys[i].KeyType != sortedMetadata.AuthorizedKeys[j].KeyType {
41+
return sortedMetadata.AuthorizedKeys[i].KeyType < sortedMetadata.AuthorizedKeys[j].KeyType
42+
}
43+
return sortedMetadata.AuthorizedKeys[i].PublicKey < sortedMetadata.AuthorizedKeys[j].PublicKey
44+
})
45+
46+
JSONBytes, err := jsonv2.Marshal(sortedMetadata, jsonv2.Deterministic(true))
47+
if err != nil {
48+
return "", fmt.Errorf("error marshaling JSON: %w", err)
49+
}
50+
51+
canonicalJSONBytes := jsontext.Value(JSONBytes)
52+
err = canonicalJSONBytes.Canonicalize()
2853
if err != nil {
29-
return "", err
54+
return "", fmt.Errorf("error canonicalizing JSON: %w", err)
3055
}
56+
3157
hasher := sha256.New()
32-
hasher.Write(data)
33-
digestBytes := hasher.Sum(nil)
58+
if _, err := hasher.Write(canonicalJSONBytes); err != nil {
59+
return "", fmt.Errorf("error writing to hasher: %w", err)
60+
}
3461

35-
return hex.EncodeToString(digestBytes), nil
62+
return hex.EncodeToString(hasher.Sum(nil)), nil
3663
}
3764

3865
type AuthorizedKey struct {
39-
KeyType KeyType `json:"keyType"`
40-
PublicKey string `json:"publicKey"`
41-
}
42-
43-
// MarshalJSON implements custom JSON marshalling to ensure alphabetical order of keys for AuthorizedKey,
44-
// and only includes non-empty fields.
45-
func (r AuthorizedKey) MarshalJSON() ([]byte, error) {
46-
m := make(map[string]any)
47-
if r.KeyType != "" {
48-
m["keyType"] = r.KeyType
49-
}
50-
if r.PublicKey != "" {
51-
m["publicKey"] = r.PublicKey
52-
}
53-
return marshalWithSortedKeys(m)
66+
KeyType KeyType `json:"keyType,omitempty"`
67+
PublicKey string `json:"publicKey,omitempty"`
5468
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package gateway
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestWorkflowMetadata_Digest(t *testing.T) {
10+
t.Run("deterministic - same input produces same digest", func(t *testing.T) {
11+
metadata := WorkflowMetadata{
12+
WorkflowSelector: WorkflowSelector{
13+
WorkflowID: "workflow-456",
14+
WorkflowName: "deterministic-test",
15+
WorkflowOwner: "0xTestOwner",
16+
},
17+
AuthorizedKeys: []AuthorizedKey{
18+
{KeyType: KeyTypeECDSAEVM, PublicKey: "0xkey1"},
19+
{KeyType: KeyTypeECDSAEVM, PublicKey: "0xkey2"},
20+
},
21+
}
22+
23+
digest1, err := metadata.Digest()
24+
require.NoError(t, err)
25+
require.NotEmpty(t, digest1)
26+
require.Len(t, digest1, 64)
27+
28+
digest2, err := metadata.Digest()
29+
require.NoError(t, err)
30+
31+
require.Equal(t, digest1, digest2, "Multiple calls should produce identical digests")
32+
})
33+
34+
t.Run("array ordering does not affect digest - fixes duplicate workflow ID bug", func(t *testing.T) {
35+
metadata1 := WorkflowMetadata{
36+
WorkflowSelector: WorkflowSelector{WorkflowID: "workflow-789"},
37+
AuthorizedKeys: []AuthorizedKey{
38+
{KeyType: KeyTypeECDSAEVM, PublicKey: "0xAAAA"},
39+
{KeyType: KeyTypeECDSAEVM, PublicKey: "0xBBBB"},
40+
{KeyType: KeyTypeECDSAEVM, PublicKey: "0xCCCC"},
41+
},
42+
}
43+
44+
metadata2 := WorkflowMetadata{
45+
WorkflowSelector: WorkflowSelector{WorkflowID: "workflow-789"},
46+
AuthorizedKeys: []AuthorizedKey{
47+
{KeyType: KeyTypeECDSAEVM, PublicKey: "0xBBBB"},
48+
{KeyType: KeyTypeECDSAEVM, PublicKey: "0xCCCC"},
49+
{KeyType: KeyTypeECDSAEVM, PublicKey: "0xAAAA"},
50+
},
51+
}
52+
53+
digest1, err := metadata1.Digest()
54+
require.NoError(t, err)
55+
56+
digest2, err := metadata2.Digest()
57+
require.NoError(t, err)
58+
59+
require.Equal(t, digest1, digest2,
60+
"Different key order should produce identical digests")
61+
})
62+
63+
t.Run("different metadata produces different digests", func(t *testing.T) {
64+
metadata1 := WorkflowMetadata{
65+
WorkflowSelector: WorkflowSelector{WorkflowID: "workflow-abc"},
66+
AuthorizedKeys: []AuthorizedKey{{KeyType: KeyTypeECDSAEVM, PublicKey: "0xkey1"}},
67+
}
68+
69+
metadata2 := WorkflowMetadata{
70+
WorkflowSelector: WorkflowSelector{WorkflowID: "workflow-xyz"},
71+
AuthorizedKeys: []AuthorizedKey{{KeyType: KeyTypeECDSAEVM, PublicKey: "0xkey1"}},
72+
}
73+
74+
digest1, err := metadata1.Digest()
75+
require.NoError(t, err)
76+
77+
digest2, err := metadata2.Digest()
78+
require.NoError(t, err)
79+
80+
require.NotEqual(t, digest1, digest2,
81+
"Different metadata should produce different digests")
82+
})
83+
84+
t.Run("empty authorized keys", func(t *testing.T) {
85+
metadata := WorkflowMetadata{
86+
WorkflowSelector: WorkflowSelector{WorkflowID: "workflow-empty"},
87+
AuthorizedKeys: []AuthorizedKey{},
88+
}
89+
90+
digest, err := metadata.Digest()
91+
require.NoError(t, err)
92+
require.NotEmpty(t, digest)
93+
require.Len(t, digest, 64)
94+
})
95+
}

0 commit comments

Comments
 (0)