-
-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathverify_local.go
More file actions
284 lines (242 loc) · 8.59 KB
/
Copy pathverify_local.go
File metadata and controls
284 lines (242 loc) · 8.59 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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
package peac
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/peacprotocol/peac/sdks/go/jws"
)
// decodeCompactHeaderAndPayload splits a compact JWS and base64url-decodes the
// protected header and payload segments, so the I-JSON gate can run on the raw
// bytes BEFORE any JSON parsing (matching the TypeScript verify path).
func decodeCompactHeaderAndPayload(receiptJWS string) ([]byte, []byte, error) {
parts := strings.Split(receiptJWS, ".")
if len(parts) != 3 {
return nil, nil, fmt.Errorf("JWS compact serialization must have three parts")
}
headerRaw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, nil, fmt.Errorf("JWS protected header: invalid base64url")
}
payloadRaw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, nil, fmt.Errorf("JWS payload: invalid base64url")
}
return headerRaw, payloadRaw, nil
}
// VerifyLocalOptions contains options for local interaction record verification.
type VerifyLocalOptions struct {
// PublicKey is the Ed25519 public key (32 bytes, required).
PublicKey ed25519.PublicKey
// Issuer is the expected issuer URI (optional; if set, iss must match).
Issuer string
// MaxClockSkew is the tolerance for clock differences (default: 30 seconds).
MaxClockSkew time.Duration
// RequireExp requires the exp claim to be present.
RequireExp bool
// PolicyBytes is the local policy document for binding check (optional).
// When provided, the policy digest is computed via JCS + SHA-256 and
// compared with claims.Peac.Digest.
PolicyBytes []byte
}
// VerifyLocalResult contains the result of local interaction record verification.
type VerifyLocalResult struct {
// Valid indicates whether the receipt passed all verification checks.
Valid bool
// Claims contains the verified interaction record claims (nil if invalid).
Claims *InteractionRecordClaims
// Kid is the key ID from the JWS header.
Kid string
// Algorithm is always "EdDSA" for Ed25519.
Algorithm string
// Warnings contains non-fatal verification warnings.
Warnings []VerificationWarning
// PolicyBinding is the three-state policy binding result.
PolicyBinding PolicyBindingStatus
// WireVersion is the wire format version ("0.2").
WireVersion string
// ReceiptRef is the receipt reference ("sha256:<hex>" of compact JWS bytes).
ReceiptRef string
// Error details (populated only when Valid is false).
ErrorCode string
ErrorMessage string
}
// VerificationWarning represents a non-fatal verification warning.
type VerificationWarning struct {
Code string `json:"code"`
Message string `json:"message"`
Pointer string `json:"pointer,omitempty"`
}
// VerifyLocal verifies a signed interaction record locally with a provided public key.
//
// Enforces the current stable Interaction Record format (interaction-record+jwt)
// at the protocol layer. The underlying jws/ package remains typ-agnostic.
func VerifyLocal(receiptJWS string, opts VerifyLocalOptions) *VerifyLocalResult {
result := &VerifyLocalResult{
Algorithm: "EdDSA",
WireVersion: PeacVersion,
PolicyBinding: PolicyBindingUnavailable,
}
// Compute receipt_ref
h := sha256.Sum256([]byte(receiptJWS))
result.ReceiptRef = "sha256:" + hex.EncodeToString(h[:])
// I-JSON (RFC 7493) gate on the raw protected-header and payload bytes BEFORE
// any JSON parsing (matching the TypeScript verify path, which gates before
// JSON.parse). A malformed-structure record is rejected deterministically here,
// before header parsing and before signature verification.
headerRaw, payloadRaw, err := decodeCompactHeaderAndPayload(receiptJWS)
if err != nil {
result.ErrorCode = "E_INVALID_FORMAT"
result.ErrorMessage = fmt.Sprintf("invalid JWS: %v", err)
return result
}
for _, raw := range [][]byte{headerRaw, payloadRaw} {
if err := assertIJSON(raw); err != nil {
if ie, ok := err.(*ijsonError); ok {
result.ErrorCode = ie.Code
} else {
result.ErrorCode = "E_INVALID_FORMAT"
}
result.ErrorMessage = err.Error()
return result
}
}
// Parse JWS
parsed, err := jws.Parse(receiptJWS)
if err != nil {
result.ErrorCode = "E_INVALID_FORMAT"
result.ErrorMessage = fmt.Sprintf("invalid JWS: %v", err)
return result
}
// Low-level header validation (typ-agnostic)
if err := jws.ValidateHeader(parsed.Header); err != nil {
result.ErrorCode = "E_INVALID_FORMAT"
result.ErrorMessage = fmt.Sprintf("invalid header: %v", err)
return result
}
result.Kid = parsed.Header.KeyID
// Protocol-layer format enforcement: require interaction-record+jwt
if parsed.Header.Type != InteractionRecordTyp {
result.ErrorCode = "E_UNSUPPORTED_WIRE_VERSION"
result.ErrorMessage = fmt.Sprintf("expected typ %s, got %s", InteractionRecordTyp, parsed.Header.Type)
return result
}
// JOSE hardening: reject unsafe header fields
if err := checkJOSEHardening(parsed.HeaderRaw); err != nil {
result.ErrorCode = "E_INVALID_FORMAT"
result.ErrorMessage = err.Error()
return result
}
// Verify Ed25519 signature
if len(opts.PublicKey) != ed25519.PublicKeySize {
result.ErrorCode = "E_INVALID_FORMAT"
result.ErrorMessage = fmt.Sprintf("invalid public key size: expected %d, got %d", ed25519.PublicKeySize, len(opts.PublicKey))
return result
}
if err := jws.VerifyJWS(parsed, opts.PublicKey); err != nil {
result.ErrorCode = "E_INVALID_SIGNATURE"
result.ErrorMessage = "Ed25519 signature verification failed"
return result
}
// Unmarshal claims
var claims InteractionRecordClaims
if err := json.Unmarshal(parsed.Payload, &claims); err != nil {
result.ErrorCode = "E_INVALID_FORMAT"
result.ErrorMessage = fmt.Sprintf("failed to parse claims: %v", err)
return result
}
// Validate peac_version
if claims.PeacVersion != PeacVersion {
result.ErrorCode = "E_UNSUPPORTED_WIRE_VERSION"
result.ErrorMessage = fmt.Sprintf("expected peac_version %s, got %s", PeacVersion, claims.PeacVersion)
return result
}
// Validate kind
if !ValidKinds[claims.Kind] {
result.ErrorCode = "E_CONSTRAINT_VIOLATION"
result.ErrorMessage = fmt.Sprintf("invalid kind %q", claims.Kind)
return result
}
// Apply default clock skew
maxSkew := opts.MaxClockSkew
if maxSkew == 0 {
maxSkew = 30 * time.Second
}
now := time.Now()
// Check iat (not in future)
iat := time.Unix(claims.Iat, 0)
if iat.After(now.Add(maxSkew)) {
result.ErrorCode = "E_NOT_YET_VALID"
result.ErrorMessage = "iat is in the future"
return result
}
// Check exp (if present)
if claims.Exp > 0 {
exp := time.Unix(claims.Exp, 0)
if exp.Before(now.Add(-maxSkew)) {
result.ErrorCode = "E_EXPIRED"
result.ErrorMessage = "interaction record has expired"
return result
}
} else if opts.RequireExp {
result.ErrorCode = "E_CONSTRAINT_VIOLATION"
result.ErrorMessage = "exp is required but not present"
return result
}
// Check issuer match
if opts.Issuer != "" && claims.Iss != opts.Issuer {
result.ErrorCode = "E_INVALID_ISSUER"
result.ErrorMessage = fmt.Sprintf("expected issuer %s, got %s", opts.Issuer, claims.Iss)
return result
}
// Policy binding
if opts.PolicyBytes != nil && claims.Peac != nil && claims.Peac.Digest != "" {
localDigest, err := ComputePolicyDigest(opts.PolicyBytes)
if err == nil {
result.PolicyBinding = CheckPolicyBinding(claims.Peac.Digest, localDigest)
if result.PolicyBinding == PolicyBindingFailed {
result.ErrorCode = "E_POLICY_BINDING_FAILED"
result.ErrorMessage = "policy digest mismatch"
return result
}
}
} else if opts.PolicyBytes != nil && (claims.Peac == nil || claims.Peac.Digest == "") {
result.PolicyBinding = PolicyBindingUnavailable
}
result.Valid = true
result.Claims = &claims
return result
}
// checkJOSEHardening rejects unsafe JOSE header fields per Wire 0.2 spec.
func checkJOSEHardening(headerRaw []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(headerRaw, &raw); err != nil {
return fmt.Errorf("failed to parse header for JOSE hardening: %w", err)
}
// Reject embedded keys
for _, field := range []string{"jwk", "x5c", "x5t", "x5u", "jku"} {
if _, ok := raw[field]; ok {
return fmt.Errorf("JOSE hardening: embedded key field %q is not allowed", field)
}
}
// Reject crit
if _, ok := raw["crit"]; ok {
return fmt.Errorf("JOSE hardening: crit header is not allowed")
}
// Reject b64:false
if b64Raw, ok := raw["b64"]; ok {
var b64 bool
if err := json.Unmarshal(b64Raw, &b64); err == nil && !b64 {
return fmt.Errorf("JOSE hardening: b64:false is not allowed")
}
}
// Reject zip
if _, ok := raw["zip"]; ok {
return fmt.Errorf("JOSE hardening: zip header is not allowed")
}
return nil
}