-
-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathjcs.go
More file actions
254 lines (231 loc) · 6.34 KB
/
Copy pathjcs.go
File metadata and controls
254 lines (231 loc) · 6.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
package peac
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
)
// Canonicalize produces an RFC 8785 (JCS) canonical serialization of JSON input.
//
// Parses with json.Decoder + UseNumber() to preserve numeric precision.
// Sort object keys by Unicode code point order. Numbers serialized per
// RFC 8785 Section 3.2.2.3. Strings serialized per RFC 8785 Section 3.2.2.2.
//
// Byte-for-byte equivalent with TypeScript canonicalize() from @peac/crypto.
func Canonicalize(input []byte) ([]byte, error) {
dec := json.NewDecoder(bytes.NewReader(input))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, fmt.Errorf("jcs: failed to decode JSON: %w", err)
}
// Reject trailing non-whitespace after the first JSON value.
// json.Decoder reads only the first value; trailing data must be rejected
// for protocol-grade canonicalization.
remaining := input[dec.InputOffset():]
for _, b := range remaining {
if b != ' ' && b != '\t' && b != '\n' && b != '\r' {
return nil, fmt.Errorf("jcs: trailing non-whitespace data after JSON value")
}
}
var buf bytes.Buffer
if err := canonicalizeValue(&buf, v); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// JCSHash computes the JCS canonical form then returns "sha256:<hex>".
func JCSHash(input []byte) (string, error) {
canonical, err := Canonicalize(input)
if err != nil {
return "", err
}
h := sha256.Sum256(canonical)
return "sha256:" + hex.EncodeToString(h[:]), nil
}
func canonicalizeValue(buf *bytes.Buffer, v any) error {
switch val := v.(type) {
case nil:
buf.WriteString("null")
case bool:
if val {
buf.WriteString("true")
} else {
buf.WriteString("false")
}
case json.Number:
return canonicalizeNumber(buf, val)
case string:
return canonicalizeString(buf, val)
case []any:
buf.WriteByte('[')
for i, item := range val {
if i > 0 {
buf.WriteByte(',')
}
if err := canonicalizeValue(buf, item); err != nil {
return err
}
}
buf.WriteByte(']')
case map[string]any:
keys := make([]string, 0, len(val))
for k := range val {
keys = append(keys, k)
}
// Sort by Unicode code point order (Go string comparison is byte-level
// which matches code point order for valid UTF-8)
sort.Strings(keys)
buf.WriteByte('{')
first := true
for _, k := range keys {
if first {
first = false
} else {
buf.WriteByte(',')
}
if err := canonicalizeString(buf, k); err != nil {
return err
}
buf.WriteByte(':')
if err := canonicalizeValue(buf, val[k]); err != nil {
return err
}
}
buf.WriteByte('}')
default:
return fmt.Errorf("jcs: unsupported type %T", v)
}
return nil
}
// canonicalizeNumber per RFC 8785 Section 3.2.2.3:
// Use the shortest representation that uniquely identifies the IEEE 754 value.
func canonicalizeNumber(buf *bytes.Buffer, n json.Number) error {
s := n.String()
// Try integer first (no decimal point, no exponent)
if i, err := n.Int64(); err == nil {
// Check roundtrip: the string form must match
if strconv.FormatInt(i, 10) == s {
buf.WriteString(s)
return nil
}
}
f, err := n.Float64()
if err != nil {
return fmt.Errorf("jcs: invalid number %q: %w", s, err)
}
// Handle special cases
if math.IsNaN(f) || math.IsInf(f, 0) {
return fmt.Errorf("jcs: NaN and Infinity are not valid JSON")
}
// -0 must serialize as 0
if f == 0 {
buf.WriteByte('0')
return nil
}
// Use ECMAScript Number serialization (matches RFC 8785)
buf.WriteString(formatECMANumber(f))
return nil
}
// formatECMANumber formats a float64 per ECMAScript Number::toString()
// which matches RFC 8785 Section 3.2.2.3 requirements.
//
// ECMAScript uses exponential notation when the exponent is < -6 or >= 21.
// For all other cases, it uses plain decimal notation.
// Go's strconv.FormatFloat with 'e' format gives us the mantissa and exponent;
// we then apply the ECMAScript formatting rules.
func formatECMANumber(f float64) string {
// Get the shortest exponential representation
s := strconv.FormatFloat(f, 'e', -1, 64)
// Parse mantissa and exponent
eIdx := strings.IndexByte(s, 'e')
if eIdx < 0 {
return s
}
mantissa := s[:eIdx]
exp, _ := strconv.Atoi(s[eIdx+1:])
// Remove decimal point from mantissa, track position
negative := false
if mantissa[0] == '-' {
negative = true
mantissa = mantissa[1:]
}
if dotIdx := strings.IndexByte(mantissa, '.'); dotIdx >= 0 {
mantissa = mantissa[:dotIdx] + mantissa[dotIdx+1:]
}
// Number of digits after the leading digit
fracDigits := len(mantissa) - 1
// The actual exponent for the integer mantissa
// mantissa = "12345", exp = 3 means 1.2345e+3
// The effective decimal position is exp + 1 from the left
// ECMAScript: if exponent n satisfies -6 < n <= 0 (small decimals) or 0 < n < 21 (plain integers/decimals)
// use plain form; otherwise use exponential
n := exp + 1 // number of digits before decimal point in plain form
prefix := ""
if negative {
prefix = "-"
}
if n >= 1 && n <= 21 && n >= len(mantissa) {
// Integer-like: pad with zeros
return prefix + mantissa + strings.Repeat("0", n-len(mantissa))
}
if n >= 1 && n <= 21 && n < len(mantissa) {
// Decimal: insert dot
return prefix + mantissa[:n] + "." + mantissa[n:]
}
if n <= 0 && n > -6 {
// Small decimal: 0.000...digits
return prefix + "0." + strings.Repeat("0", -n) + mantissa
}
// Exponential notation
result := prefix + string(mantissa[0])
if fracDigits > 0 {
result += "." + mantissa[1:]
}
result += "e+"
if exp < 0 {
result = prefix + string(mantissa[0])
if fracDigits > 0 {
result += "." + mantissa[1:]
}
result += "e-" + strconv.Itoa(-exp)
} else {
result += strconv.Itoa(exp)
}
return result
}
// canonicalizeString per RFC 8785 Section 3.2.2.2:
// Minimal escaping -- only required characters are escaped.
func canonicalizeString(buf *bytes.Buffer, s string) error {
buf.WriteByte('"')
for _, r := range s {
switch {
case r == '"':
buf.WriteString(`\"`)
case r == '\\':
buf.WriteString(`\\`)
case r == '\b':
buf.WriteString(`\b`)
case r == '\f':
buf.WriteString(`\f`)
case r == '\n':
buf.WriteString(`\n`)
case r == '\r':
buf.WriteString(`\r`)
case r == '\t':
buf.WriteString(`\t`)
case r < 0x20:
// Control characters: \u00XX
fmt.Fprintf(buf, `\u%04x`, r)
default:
buf.WriteRune(r)
}
}
buf.WriteByte('"')
return nil
}