Files
ietf-wimse-ect/refimpl/ect/verify.go
Christian Nennemann f9357fdf88 Add WIMSE ECT reference implementation (Go)
- ect library: create, verify, DAG validation, ledger interface
- In-memory ledger and ECTStore for full ledger mode
- Test vectors and unit tests; two-agent demo (cmd/demo)
- README: document refimpl scope and usage

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 22:05:30 +01:00

196 lines
5.6 KiB
Go

package ect
import (
"crypto/ecdsa"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-jose/go-jose/v4"
)
// ParsedECT holds a verified or parsed ECT (header + payload).
type ParsedECT struct {
Header *jose.Header
Payload *Payload
Raw string // compact JWS
}
// KeyResolver returns the public key for a given kid, or nil if unknown/revoked.
// Returning (nil, nil) means key not found; (nil, err) means lookup error.
type KeyResolver func(kid string) (*ecdsa.PublicKey, error)
// VerifyOptions configures ECT verification per Section 7.
type VerifyOptions struct {
// VerifierID is the workload identity of the verifier (must be in aud).
VerifierID string
// ResolveKey returns the public key for kid. Required.
ResolveKey KeyResolver
// ECTStore for DAG validation. If nil, DAG validation is skipped (e.g. first hop or point-to-point without ledger).
Store ECTStore
// DAGConfig for DAG validation. Used only if Store != nil.
DAG DAGConfig
// Now is the current time; if zero, time.Now() is used.
Now time.Time
// IATMaxAge is max age of iat (recommended 15 min). If zero, 15 minutes.
IATMaxAge time.Duration
// IATMaxFuture is max clock skew for iat in future (recommended 30 sec). If zero, 30 seconds.
IATMaxFuture time.Duration
// JTISeen returns true if jti was already seen (replay). If nil, replay check is skipped.
JTISeen func(jti string) bool
// WITSubject if set must equal payload.iss (issuer must match WIT subject for this key).
WITSubject string
}
// DefaultVerifyOptions returns recommended defaults.
func DefaultVerifyOptions() VerifyOptions {
return VerifyOptions{
IATMaxAge: 15 * time.Minute,
IATMaxFuture: 30 * time.Second,
DAG: DefaultDAGConfig(),
}
}
// Parse parses compact JWS and returns header + payload without cryptographic verification.
// Use Verify for full signature and claim validation.
func Parse(compact string) (*ParsedECT, error) {
jws, err := jose.ParseSigned(compact, []jose.SignatureAlgorithm{jose.ES256})
if err != nil {
return nil, err
}
if len(jws.Signatures) != 1 {
return nil, errors.New("ect: expected single signature")
}
sig := jws.Signatures[0]
payloadBytes := jws.UnsafePayloadWithoutVerification()
if len(payloadBytes) == 0 {
return nil, errors.New("ect: empty payload")
}
var p Payload
if err := json.Unmarshal(payloadBytes, &p); err != nil {
return nil, err
}
return &ParsedECT{
Header: &sig.Header,
Payload: &p,
Raw: compact,
}, nil
}
// Verify performs full Section 7 verification and optional DAG validation.
func Verify(compact string, opts VerifyOptions) (*ParsedECT, error) {
jws, err := jose.ParseSigned(compact, []jose.SignatureAlgorithm{jose.ES256})
if err != nil {
return nil, err
}
if len(jws.Signatures) != 1 {
return nil, errors.New("ect: expected single signature")
}
sig := jws.Signatures[0]
header := &sig.Header
// 1. Parse JWS — done
// 2. typ must be wimse-exec+jwt
typ, _ := header.ExtraHeaders["typ"].(string)
if typ == "" {
// try standard typ
typ = header.ExtraHeaders[jose.HeaderType].(string)
}
if typ != ECTType {
return nil, errors.New("ect: invalid typ parameter")
}
// 3. alg not none and not symmetric
if header.Algorithm == "none" || header.Algorithm == "HS256" || header.Algorithm == "HS384" || header.Algorithm == "HS512" {
return nil, errors.New("ect: prohibited algorithm")
}
// 4. kid references known key
kid := header.KeyID
if kid == "" {
return nil, errors.New("ect: missing kid")
}
pub, err := opts.ResolveKey(kid)
if err != nil {
return nil, fmt.Errorf("ect: key resolution: %w", err)
}
if pub == nil {
return nil, errors.New("ect: unknown key identifier")
}
// 5. Verify JWS signature
payloadBytes, err := jws.Verify(&jose.JSONWebKey{Key: pub, KeyID: kid})
if err != nil {
return nil, fmt.Errorf("ect: invalid signature: %w", err)
}
var p Payload
if err := json.Unmarshal(payloadBytes, &p); err != nil {
return nil, err
}
// 6. Key revocation — caller's ResolveKey can return nil for revoked
// 7. alg match WIT — we don't have WIT; optional WITSubject check below
// 8. iss matches WIT subject
if opts.WITSubject != "" && p.Iss != opts.WITSubject {
return nil, errors.New("ect: issuer does not match WIT subject")
}
// 9. aud contains verifier
if opts.VerifierID != "" && !p.ContainsAudience(opts.VerifierID) {
return nil, errors.New("ect: audience does not include verifier")
}
// 10. exp not expired
now := opts.Now
if now.IsZero() {
now = time.Now()
}
if now.Unix() > p.Exp {
return nil, errors.New("ect: token expired")
}
// 11. iat not too far past/future
if opts.IATMaxAge == 0 {
opts.IATMaxAge = 15 * time.Minute
}
if opts.IATMaxFuture == 0 {
opts.IATMaxFuture = 30 * time.Second
}
if now.Unix()-p.Iat > int64(opts.IATMaxAge.Seconds()) {
return nil, errors.New("ect: iat too far in the past")
}
if p.Iat > now.Unix()+int64(opts.IATMaxFuture.Seconds()) {
return nil, errors.New("ect: iat in the future")
}
// 12. Required claims present
if p.Jti == "" || p.Tid == "" || p.ExecAct == "" || p.Pol == "" || p.PolDecision == "" {
return nil, errors.New("ect: missing required claims")
}
if p.Par == nil {
p.Par = []string{}
}
// 13. pol_decision in registry
if !ValidPolDecision(p.PolDecision) {
return nil, errors.New("ect: invalid pol_decision value")
}
// 14. DAG validation
if opts.Store != nil {
if err := ValidateDAG(&p, opts.Store, opts.DAG); err != nil {
return nil, err
}
}
// 15. Replay (jti seen)
if opts.JTISeen != nil && opts.JTISeen(p.Jti) {
return nil, errors.New("ect: jti already seen (replay)")
}
return &ParsedECT{Header: header, Payload: &p, Raw: compact}, nil
}