Restructure refimpl into go-lang and python subdirectories
Move Go reference implementation to refimpl/go-lang/ and add new Python reference implementation in refimpl/python/. Update build.sh with renamed draft and simplified tool paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
107
refimpl/go-lang/README.md
Normal file
107
refimpl/go-lang/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# WIMSE ECT — Go Reference Implementation
|
||||
|
||||
Go reference implementation of [Execution Context Tokens (ECTs)](../../draft-nennemann-wimse-execution-context-00.txt) for WIMSE. Implements ECT creation (ES256), verification (Section 7), DAG validation (Section 6), and an in-memory audit ledger (Section 9).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
go-lang/
|
||||
├── go.mod, go.sum
|
||||
├── ect/ # library
|
||||
│ ├── types.go # Payload, Audience, constants
|
||||
│ ├── audience.go # aud marshal/unmarshal
|
||||
│ ├── create.go # Create(), GenerateKey()
|
||||
│ ├── verify.go # Parse(), Verify(), VerifyOptions
|
||||
│ ├── dag.go # ValidateDAG(), ECTStore
|
||||
│ ├── ledger.go # Ledger, MemoryLedger
|
||||
│ ├── config.go # Config, LoadConfigFromEnv()
|
||||
│ ├── jti_cache.go # JTICache for replay protection
|
||||
│ ├── errors.go # Sentinel errors (ErrExpired, ErrReplay, etc.)
|
||||
│ ├── validate.go # ValidateExt, ValidUUID, ValidateHashFormat
|
||||
│ └── *_test.go
|
||||
├── testdata/
|
||||
│ └── valid_root_ect_payload.json
|
||||
└── cmd/demo/ # two-agent workflow demo
|
||||
└── main.go
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Library
|
||||
|
||||
```go
|
||||
import "github.com/nennemann/ect-refimpl/go-lang/ect"
|
||||
|
||||
// Production: load config from environment
|
||||
cfg := ect.LoadConfigFromEnv()
|
||||
|
||||
key, _ := ect.GenerateKey()
|
||||
payload := &ect.Payload{
|
||||
Iss: "spiffe://example.com/agent/a",
|
||||
Aud: ect.Audience{"spiffe://example.com/agent/b"},
|
||||
Iat: time.Now().Unix(),
|
||||
Exp: time.Now().Add(10*time.Minute).Unix(),
|
||||
Jti: "550e8400-e29b-41d4-a716-446655440000",
|
||||
ExecAct: "review_spec",
|
||||
Par: []string{},
|
||||
Pol: "policy_v1",
|
||||
PolDecision: ect.PolDecisionApproved,
|
||||
}
|
||||
compact, err := ect.Create(payload, key, cfg.CreateOptions("agent-a-key"))
|
||||
|
||||
// Verify with optional replay cache
|
||||
store := ect.NewMemoryLedger()
|
||||
var jtiCache ect.JTICache
|
||||
if cfg.JTIReplaySize > 0 {
|
||||
jtiCache = ect.NewJTICache(cfg.JTIReplaySize, cfg.JTIReplayTTL)
|
||||
}
|
||||
opts := cfg.VerifyOptions()
|
||||
opts.VerifierID = "spiffe://example.com/agent/b"
|
||||
opts.ResolveKey = resolver
|
||||
opts.Store = store
|
||||
if jtiCache != nil {
|
||||
opts.JTISeen = jtiCache.Seen
|
||||
}
|
||||
parsed, err := ect.Verify(compact, opts)
|
||||
if err != nil { ... }
|
||||
if jtiCache != nil {
|
||||
jtiCache.Add(parsed.Payload.Jti)
|
||||
}
|
||||
store.Append(compact, parsed.Payload)
|
||||
```
|
||||
|
||||
### Demo
|
||||
|
||||
```bash
|
||||
cd refimpl/go-lang && go run ./cmd/demo
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
cd refimpl/go-lang && go test ./ect/... -cover
|
||||
```
|
||||
|
||||
Unit tests are in `ect/*_test.go`. Coverage target: **~90%** (run `go test ./ect/... -coverprofile=cover.out && go tool cover -func=cover.out`). Remaining uncovered lines are mostly Parse/Verify error paths that require custom JWS or multi-sig tokens.
|
||||
|
||||
## Production configuration (environment)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ECT_IAT_MAX_AGE_MINUTES` | 15 | Max age of `iat` (minutes). |
|
||||
| `ECT_IAT_MAX_FUTURE_SEC` | 30 | Max `iat` in future / clock skew (seconds). |
|
||||
| `ECT_DEFAULT_EXPIRY_MIN` | 10 | Default token expiry when `exp` is zero (minutes). |
|
||||
| `ECT_JTI_REPLAY_CACHE_SIZE` | 0 | Max JTIs to remember for replay check; 0 = disabled. |
|
||||
| `ECT_JTI_REPLAY_TTL_MIN` | 60 | TTL for JTI cache entries (minutes). |
|
||||
|
||||
### Replay cache (multi-instance)
|
||||
|
||||
`JTICache` is in-memory only. For multiple verifier instances (e.g. behind a load balancer), use a shared store (Redis, database) so every instance sees the same “seen” JTIs. Implement `JTISeen` as a function that checks (and optionally records) the JTI in that store (e.g. with TTL). Pass it in `VerifyOptions.JTISeen`. See refimpl/README for an overview.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [go-jose/v4](https://github.com/go-jose/go-jose/v4) for JWS (ES256) and JWK handling.
|
||||
|
||||
## License
|
||||
|
||||
Same as the Internet-Draft (IETF Trust). Code under Revised BSD per BCP 78/79.
|
||||
121
refimpl/go-lang/cmd/demo/main.go
Normal file
121
refimpl/go-lang/cmd/demo/main.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Demo runs a minimal two-agent ECT workflow: Agent A creates a root ECT,
|
||||
// "sends" it to Agent B; Agent B verifies, appends to ledger, then creates
|
||||
// a child ECT; verification runs with DAG validation against the ledger.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/nennemann/ect-refimpl/go-lang/ect"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ledger := ect.NewMemoryLedger()
|
||||
now := time.Now()
|
||||
|
||||
// Agent A: spec reviewer
|
||||
keyA, err := ect.GenerateKey()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
agentA := "spiffe://example.com/agent/spec-reviewer"
|
||||
agentB := "spiffe://example.com/agent/implementer"
|
||||
kidA := "agent-a-key"
|
||||
|
||||
// 1) Agent A creates root ECT (task id = jti per spec)
|
||||
payloadA := &ect.Payload{
|
||||
Iss: agentA,
|
||||
Aud: []string{agentB},
|
||||
Iat: now.Unix(),
|
||||
Exp: now.Add(10 * time.Minute).Unix(),
|
||||
Jti: "550e8400-e29b-41d4-a716-446655440001",
|
||||
Wid: "wf-demo-001",
|
||||
ExecAct: "review_requirements_spec",
|
||||
Par: []string{},
|
||||
Pol: "spec_review_policy_v2",
|
||||
PolDecision: ect.PolDecisionApproved,
|
||||
}
|
||||
ectA, err := ect.Create(payloadA, keyA, ect.CreateOptions{KeyID: kidA})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Agent A created root ECT (jti=550e8400-..., review_requirements_spec)")
|
||||
|
||||
// 2) Agent B verifies (no store for DAG on first ECT)
|
||||
resolveKey := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == kidA {
|
||||
return &keyA.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
opts := ect.VerifyOptions{
|
||||
VerifierID: agentB,
|
||||
ResolveKey: resolveKey,
|
||||
Store: ledger,
|
||||
Now: now,
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
IATMaxFuture: 30 * time.Second,
|
||||
}
|
||||
parsed, err := ect.Verify(ectA, opts)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
_, err = ledger.Append(ectA, parsed.Payload)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Agent B verified root ECT and appended to ledger")
|
||||
|
||||
// 3) Agent B creates child ECT (par contains parent jti values per spec)
|
||||
keyB, _ := ect.GenerateKey()
|
||||
kidB := "agent-b-key"
|
||||
payloadB := &ect.Payload{
|
||||
Iss: agentB,
|
||||
Aud: []string{"spiffe://example.com/system/ledger"},
|
||||
Iat: now.Unix() + 1,
|
||||
Exp: now.Add(10 * time.Minute).Unix(),
|
||||
Jti: "550e8400-e29b-41d4-a716-446655440002",
|
||||
Wid: "wf-demo-001",
|
||||
ExecAct: "implement_module",
|
||||
Par: []string{"550e8400-e29b-41d4-a716-446655440001"},
|
||||
Pol: "coding_standards_v3",
|
||||
PolDecision: ect.PolDecisionApproved,
|
||||
}
|
||||
ectB, err := ect.Create(payloadB, keyB, ect.CreateOptions{KeyID: kidB})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Agent B created child ECT (jti=550e8400-...002, implement_module, par=[parent jti])")
|
||||
|
||||
// 4) Verify child ECT with DAG (ledger has task-001)
|
||||
resolverB := ect.KeyResolver(func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == kidB {
|
||||
return &keyB.PublicKey, nil
|
||||
}
|
||||
if kid == kidA {
|
||||
return &keyA.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
optsB := ect.VerifyOptions{
|
||||
VerifierID: "spiffe://example.com/system/ledger",
|
||||
ResolveKey: resolverB,
|
||||
Store: ledger,
|
||||
Now: now.Add(2 * time.Second),
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
IATMaxFuture: 30 * time.Second,
|
||||
}
|
||||
parsedB, err := ect.Verify(ectB, optsB)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
_, err = ledger.Append(ectB, parsedB.Payload)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("Verified child ECT with DAG validation and appended to ledger")
|
||||
fmt.Printf("Ledger entries: %s (%s), %s (%s)\n", parsed.Payload.Jti, parsed.Payload.ExecAct, parsedB.Payload.Jti, parsedB.Payload.ExecAct)
|
||||
}
|
||||
34
refimpl/go-lang/ect/audience.go
Normal file
34
refimpl/go-lang/ect/audience.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package ect
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
func marshalJSONString(s string) []byte {
|
||||
b, _ := json.Marshal(s)
|
||||
return b
|
||||
}
|
||||
|
||||
func marshalJSONStringArray(a []string) []byte {
|
||||
b, _ := json.Marshal(a)
|
||||
return b
|
||||
}
|
||||
|
||||
func unmarshalAudience(data []byte, a *Audience) error {
|
||||
var raw json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) > 0 && raw[0] == '[' {
|
||||
var arr []string
|
||||
if err := json.Unmarshal(raw, &arr); err != nil {
|
||||
return err
|
||||
}
|
||||
*a = arr
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*a = []string{s}
|
||||
return nil
|
||||
}
|
||||
84
refimpl/go-lang/ect/config.go
Normal file
84
refimpl/go-lang/ect/config.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Env names for production configuration. All optional; zero values use defaults.
|
||||
const (
|
||||
EnvIATMaxAgeMinutes = "ECT_IAT_MAX_AGE_MINUTES" // max age of iat (default 15)
|
||||
EnvIATMaxFutureSec = "ECT_IAT_MAX_FUTURE_SEC" // max iat in future / clock skew (default 30)
|
||||
EnvDefaultExpiryMin = "ECT_DEFAULT_EXPIRY_MIN" // default token expiry when exp is zero (default 10)
|
||||
EnvJTIReplayCacheSize = "ECT_JTI_REPLAY_CACHE_SIZE" // max JTIs to remember for replay check (0 = disabled)
|
||||
EnvJTIReplayTTLMin = "ECT_JTI_REPLAY_TTL_MIN" // TTL for JTI cache entries (default 60)
|
||||
)
|
||||
|
||||
// Config holds production-friendly settings loaded from environment.
|
||||
type Config struct {
|
||||
IATMaxAge time.Duration
|
||||
IATMaxFuture time.Duration
|
||||
DefaultExpiry time.Duration
|
||||
JTIReplaySize int // 0 = no replay cache
|
||||
JTIReplayTTL time.Duration
|
||||
}
|
||||
|
||||
// DefaultConfig returns in-process defaults (no env).
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
IATMaxFuture: 30 * time.Second,
|
||||
DefaultExpiry: 10 * time.Minute,
|
||||
JTIReplayTTL: 60 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadConfigFromEnv returns Config with values from environment where set.
|
||||
func LoadConfigFromEnv() Config {
|
||||
c := DefaultConfig()
|
||||
if v := os.Getenv(EnvIATMaxAgeMinutes); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
c.IATMaxAge = time.Duration(n) * time.Minute
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(EnvIATMaxFutureSec); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
c.IATMaxFuture = time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(EnvDefaultExpiryMin); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
c.DefaultExpiry = time.Duration(n) * time.Minute
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(EnvJTIReplayCacheSize); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
c.JTIReplaySize = n
|
||||
}
|
||||
}
|
||||
if v := os.Getenv(EnvJTIReplayTTLMin); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
c.JTIReplayTTL = time.Duration(n) * time.Minute
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// CreateOptions returns CreateOptions from this config.
|
||||
func (c Config) CreateOptions(keyID string) CreateOptions {
|
||||
return CreateOptions{
|
||||
KeyID: keyID,
|
||||
IATMaxAge: c.IATMaxAge,
|
||||
DefaultExpiry: c.DefaultExpiry,
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyOptions returns VerifyOptions from this config (VerifierID, ResolveKey, Store must be set by caller).
|
||||
func (c Config) VerifyOptions() VerifyOptions {
|
||||
opts := DefaultVerifyOptions()
|
||||
opts.IATMaxAge = c.IATMaxAge
|
||||
opts.IATMaxFuture = c.IATMaxFuture
|
||||
opts.DAG = DefaultDAGConfig()
|
||||
return opts
|
||||
}
|
||||
79
refimpl/go-lang/ect/config_test.go
Normal file
79
refimpl/go-lang/ect/config_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
if c.IATMaxAge != 15*time.Minute {
|
||||
t.Errorf("IATMaxAge: got %v", c.IATMaxAge)
|
||||
}
|
||||
if c.JTIReplaySize != 0 {
|
||||
t.Errorf("JTIReplaySize: got %d", c.JTIReplaySize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFromEnv(t *testing.T) {
|
||||
os.Setenv(EnvIATMaxAgeMinutes, "20")
|
||||
os.Setenv(EnvJTIReplayCacheSize, "1000")
|
||||
os.Setenv(EnvIATMaxFutureSec, "45")
|
||||
os.Setenv(EnvDefaultExpiryMin, "12")
|
||||
os.Setenv(EnvJTIReplayTTLMin, "90")
|
||||
defer func() {
|
||||
os.Unsetenv(EnvIATMaxAgeMinutes)
|
||||
os.Unsetenv(EnvJTIReplayCacheSize)
|
||||
os.Unsetenv(EnvIATMaxFutureSec)
|
||||
os.Unsetenv(EnvDefaultExpiryMin)
|
||||
os.Unsetenv(EnvJTIReplayTTLMin)
|
||||
}()
|
||||
c := LoadConfigFromEnv()
|
||||
if c.IATMaxAge != 20*time.Minute {
|
||||
t.Errorf("IATMaxAge from env: got %v", c.IATMaxAge)
|
||||
}
|
||||
if c.JTIReplaySize != 1000 {
|
||||
t.Errorf("JTIReplaySize from env: got %d", c.JTIReplaySize)
|
||||
}
|
||||
if c.IATMaxFuture != 45*time.Second {
|
||||
t.Errorf("IATMaxFuture: got %v", c.IATMaxFuture)
|
||||
}
|
||||
if c.DefaultExpiry != 12*time.Minute {
|
||||
t.Errorf("DefaultExpiry: got %v", c.DefaultExpiry)
|
||||
}
|
||||
if c.JTIReplayTTL != 90*time.Minute {
|
||||
t.Errorf("JTIReplayTTL: got %v", c.JTIReplayTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigFromEnv_invalidInt(t *testing.T) {
|
||||
os.Setenv(EnvIATMaxAgeMinutes, "bad")
|
||||
defer os.Unsetenv(EnvIATMaxAgeMinutes)
|
||||
c := LoadConfigFromEnv()
|
||||
if c.IATMaxAge != 15*time.Minute {
|
||||
t.Errorf("invalid int should keep default: got %v", c.IATMaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_CreateOptions(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
opts := c.CreateOptions("my-kid")
|
||||
if opts.KeyID != "my-kid" {
|
||||
t.Errorf("KeyID: got %q", opts.KeyID)
|
||||
}
|
||||
if opts.DefaultExpiry != c.DefaultExpiry {
|
||||
t.Errorf("DefaultExpiry: got %v", opts.DefaultExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_VerifyOptions(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
opts := c.VerifyOptions()
|
||||
if opts.IATMaxAge != c.IATMaxAge {
|
||||
t.Errorf("IATMaxAge: got %v", opts.IATMaxAge)
|
||||
}
|
||||
if opts.DAG.ClockSkewTolerance != DefaultClockSkewTolerance {
|
||||
t.Errorf("DAG.ClockSkewTolerance: got %d", opts.DAG.ClockSkewTolerance)
|
||||
}
|
||||
}
|
||||
152
refimpl/go-lang/ect/create.go
Normal file
152
refimpl/go-lang/ect/create.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
)
|
||||
|
||||
// CreateOptions configures ECT creation.
|
||||
type CreateOptions struct {
|
||||
// KeyID is the kid header value (references public key from WIT).
|
||||
KeyID string
|
||||
// IATMaxAge caps how far in the past iat may be (recommended 15 min).
|
||||
IATMaxAge time.Duration
|
||||
// DefaultExpiry is added to time.Now() for exp when zero (recommended 5–15 min).
|
||||
DefaultExpiry time.Duration
|
||||
// ValidateUUIDs when true requires jti and wid (if set) to be UUID format (RFC 9562).
|
||||
ValidateUUIDs bool
|
||||
// MaxParLength is the max number of parent references (0 = no limit; recommended 100).
|
||||
MaxParLength int
|
||||
}
|
||||
|
||||
// DefaultCreateOptions returns recommended defaults.
|
||||
func DefaultCreateOptions() CreateOptions {
|
||||
return CreateOptions{
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
DefaultExpiry: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// Create builds and signs an ECT. Payload must have required claims set;
|
||||
// Iat/Exp can be zero to use defaults (now, now+DefaultExpiry).
|
||||
// Create may modify the payload in place (Iat, Exp, Sub, Par) when filling defaults; pass a copy if the original must stay unchanged.
|
||||
func Create(payload *Payload, privateKey *ecdsa.PrivateKey, opts CreateOptions) (compact string, err error) {
|
||||
if payload == nil || privateKey == nil {
|
||||
return "", ErrPayloadRequired
|
||||
}
|
||||
if opts.KeyID == "" {
|
||||
return "", ErrKeyIDRequired
|
||||
}
|
||||
now := time.Now()
|
||||
if payload.Iat == 0 {
|
||||
payload.Iat = now.Unix()
|
||||
}
|
||||
if payload.Exp == 0 {
|
||||
if opts.DefaultExpiry == 0 {
|
||||
opts.DefaultExpiry = 10 * time.Minute
|
||||
}
|
||||
payload.Exp = now.Add(opts.DefaultExpiry).Unix()
|
||||
}
|
||||
if payload.Sub == "" {
|
||||
payload.Sub = payload.Iss
|
||||
}
|
||||
if payload.Par == nil {
|
||||
payload.Par = []string{}
|
||||
}
|
||||
|
||||
if err := validatePayloadForCreate(payload, opts); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
sig := jose.SigningKey{Algorithm: jose.ES256, Key: privateKey}
|
||||
options := &jose.SignerOptions{
|
||||
ExtraHeaders: map[jose.HeaderKey]interface{}{
|
||||
jose.HeaderType: ECTType,
|
||||
jose.HeaderKey("kid"): opts.KeyID,
|
||||
},
|
||||
}
|
||||
signer, err := jose.NewSigner(sig, options)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jws, err := signer.Sign(payloadBytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return jws.CompactSerialize()
|
||||
}
|
||||
|
||||
func validatePayloadForCreate(p *Payload, opts CreateOptions) error {
|
||||
if p.Iss == "" {
|
||||
return errors.New("ect: iss required")
|
||||
}
|
||||
if len(p.Aud) == 0 {
|
||||
return errors.New("ect: aud required")
|
||||
}
|
||||
if p.Jti == "" {
|
||||
return errors.New("ect: jti required")
|
||||
}
|
||||
if p.ExecAct == "" {
|
||||
return errors.New("ect: exec_act required")
|
||||
}
|
||||
if opts.ValidateUUIDs {
|
||||
if !ValidUUID(p.Jti) {
|
||||
return ErrInvalidJTI
|
||||
}
|
||||
if p.Wid != "" && !ValidUUID(p.Wid) {
|
||||
return ErrInvalidWID
|
||||
}
|
||||
}
|
||||
if opts.MaxParLength > 0 && len(p.Par) > opts.MaxParLength {
|
||||
return ErrParLength
|
||||
}
|
||||
if p.InpHash != "" {
|
||||
if err := ValidateHashFormat(p.InpHash); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.OutHash != "" {
|
||||
if err := ValidateHashFormat(p.OutHash); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := ValidateExt(p.Ext); err != nil {
|
||||
return err
|
||||
}
|
||||
// pol/pol_decision are OPTIONAL; if either is set, both must be present and valid
|
||||
if p.Pol != "" || p.PolDecision != "" {
|
||||
if p.Pol == "" || p.PolDecision == "" {
|
||||
return ErrPolPolDecisionPair
|
||||
}
|
||||
if !ValidPolDecision(p.PolDecision) {
|
||||
return ErrInvalidPolDecision
|
||||
}
|
||||
}
|
||||
// compensation_* live in ext per spec
|
||||
if p.Ext != nil {
|
||||
if _, hasReason := p.Ext["compensation_reason"]; hasReason {
|
||||
if v, _ := p.Ext["compensation_required"].(bool); !v {
|
||||
return errors.New("ect: ext.compensation_reason requires ext.compensation_required true")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateKey creates an ECDSA P-256 key for ES256 (for testing/demo).
|
||||
func GenerateKey() (*ecdsa.PrivateKey, error) {
|
||||
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
}
|
||||
192
refimpl/go-lang/ect/create_test.go
Normal file
192
refimpl/go-lang/ect/create_test.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateRoundtrip(t *testing.T) {
|
||||
key, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "spiffe://example.com/agent/a",
|
||||
Aud: []string{"spiffe://example.com/agent/b"},
|
||||
Iat: now.Unix(),
|
||||
Exp: now.Add(10 * time.Minute).Unix(),
|
||||
Jti: "e4f5a6b7-c8d9-0123-ef01-234567890abc",
|
||||
ExecAct: "review_spec",
|
||||
Par: []string{},
|
||||
Pol: "spec_review_policy_v2",
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, err := Create(payload, key, CreateOptions{KeyID: "agent-a-key-1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if compact == "" {
|
||||
t.Fatal("expected non-empty compact JWS")
|
||||
}
|
||||
|
||||
// Verify with same key
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid != "agent-a-key-1" {
|
||||
return nil, nil
|
||||
}
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
opts := VerifyOptions{
|
||||
VerifierID: "spiffe://example.com/agent/b",
|
||||
ResolveKey: resolver,
|
||||
Now: now,
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
IATMaxFuture: 30 * time.Second,
|
||||
}
|
||||
parsed, err := Verify(compact, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Payload.Jti != payload.Jti || parsed.Payload.ExecAct != payload.ExecAct {
|
||||
t.Errorf("payload mismatch: got jti=%q exec_act=%q", parsed.Payload.Jti, parsed.Payload.ExecAct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCreateOptions(t *testing.T) {
|
||||
opts := DefaultCreateOptions()
|
||||
if opts.KeyID != "" {
|
||||
t.Errorf("KeyID: got %q", opts.KeyID)
|
||||
}
|
||||
if opts.DefaultExpiry != 10*time.Minute {
|
||||
t.Errorf("DefaultExpiry: got %v", opts.DefaultExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_Errors(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
payload := &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved, Iat: 1, Exp: 2}
|
||||
if _, err := Create(nil, key, CreateOptions{KeyID: "k"}); err == nil {
|
||||
t.Error("expected error for nil payload")
|
||||
}
|
||||
if _, err := Create(payload, nil, CreateOptions{KeyID: "k"}); err == nil {
|
||||
t.Error("expected error for nil key")
|
||||
}
|
||||
if _, err := Create(payload, key, CreateOptions{KeyID: ""}); err == nil {
|
||||
t.Error("expected error for empty KeyID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_OptionalPol(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss", Aud: []string{"aud"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-nopol", ExecAct: "act", Par: []string{},
|
||||
}
|
||||
compact, err := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if compact == "" {
|
||||
t.Fatal("expected token without pol")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ZeroExpiryUsesDefault(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
payload := &Payload{
|
||||
Iss: "i", Aud: []string{"a"}, Iat: 0, Exp: 0,
|
||||
Jti: "jti-z", ExecAct: "e", Par: []string{},
|
||||
}
|
||||
_, err := Create(payload, key, CreateOptions{KeyID: "kid", DefaultExpiry: 5 * time.Minute})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Exp <= payload.Iat {
|
||||
t.Error("exp should be after iat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ExtCompensationReasonRequiresRequired(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
payload := &Payload{
|
||||
Iss: "i", Aud: []string{"a"}, Iat: 1, Exp: 2,
|
||||
Jti: "j", ExecAct: "e", Par: []string{},
|
||||
Ext: map[string]interface{}{"compensation_reason": "rollback", "compensation_required": false},
|
||||
}
|
||||
_, err := Create(payload, key, CreateOptions{KeyID: "k"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when ext has compensation_reason without compensation_required true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_ValidationErrors(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
tests := []struct {
|
||||
name string
|
||||
p *Payload
|
||||
}{
|
||||
{"missing iss", &Payload{Iss: "", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}},
|
||||
{"missing aud", &Payload{Iss: "i", Aud: nil, Jti: "j", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}},
|
||||
{"missing jti", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}},
|
||||
{"missing exec_act", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "", Par: []string{}, Iat: 1, Exp: 2}},
|
||||
{"pol without pol_decision", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: "", Iat: 1, Exp: 2}},
|
||||
{"invalid pol_decision", &Payload{Iss: "i", Aud: []string{"a"}, Jti: "j", ExecAct: "e", Par: []string{}, Pol: "p", PolDecision: "bad", Iat: 1, Exp: 2}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := Create(tt.p, key, CreateOptions{KeyID: "k"}); err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWithTestVector(t *testing.T) {
|
||||
// testdata at module root (go-lang/testdata/); test cwd is package dir (ect/)
|
||||
data, err := os.ReadFile("../testdata/valid_root_ect_payload.json")
|
||||
if err != nil {
|
||||
data, err = os.ReadFile("testdata/valid_root_ect_payload.json")
|
||||
}
|
||||
if err != nil {
|
||||
t.Skipf("test vector not found: %v", err)
|
||||
return
|
||||
}
|
||||
var p Payload
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Override timestamps for verification
|
||||
now := time.Now()
|
||||
p.Iat = now.Unix()
|
||||
p.Exp = now.Add(10 * time.Minute).Unix()
|
||||
|
||||
compact, err := Create(&p, key, CreateOptions{KeyID: "test-kid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid != "test-kid" {
|
||||
return nil, nil
|
||||
}
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
_, err = Verify(compact, VerifyOptions{
|
||||
VerifierID: p.Aud[0],
|
||||
ResolveKey: resolver,
|
||||
Now: now,
|
||||
IATMaxAge: 15 * time.Minute,
|
||||
IATMaxFuture: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
114
refimpl/go-lang/ect/dag.go
Normal file
114
refimpl/go-lang/ect/dag.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DefaultClockSkewTolerance is the recommended clock skew between agents (Section 6.2).
|
||||
const DefaultClockSkewTolerance = 30 // seconds
|
||||
|
||||
// DefaultMaxAncestorLimit is the recommended max ancestor traversal for cycle detection (Section 6.3).
|
||||
const DefaultMaxAncestorLimit = 10000
|
||||
|
||||
// ECTStore provides lookup of ECTs by task ID (and optionally workflow ID) for DAG validation.
|
||||
// Implemented by ledger or in-memory cache of verified parent ECTs.
|
||||
type ECTStore interface {
|
||||
// GetByTid returns the payload for the given task ID, or nil if not found.
|
||||
GetByTid(tid string) *Payload
|
||||
// Contains returns true if (tid, wid) already exists. wid may be empty for global scope.
|
||||
Contains(tid, wid string) bool
|
||||
}
|
||||
|
||||
// DAGConfig holds parameters for DAG validation.
|
||||
type DAGConfig struct {
|
||||
ClockSkewTolerance int // seconds; recommended 30
|
||||
MaxAncestorLimit int // recommended 10000
|
||||
MaxParLength int // max par length (0 = no limit; recommended 100)
|
||||
}
|
||||
|
||||
// DefaultDAGConfig returns recommended defaults.
|
||||
func DefaultDAGConfig() DAGConfig {
|
||||
return DAGConfig{
|
||||
ClockSkewTolerance: DefaultClockSkewTolerance,
|
||||
MaxAncestorLimit: DefaultMaxAncestorLimit,
|
||||
MaxParLength: DefaultMaxParLength,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateDAG runs Section 6.2 validation rules: uniqueness, parent existence,
|
||||
// temporal ordering, acyclicity, parent policy decision.
|
||||
func ValidateDAG(ect *Payload, store ECTStore, cfg DAGConfig) error {
|
||||
if store == nil {
|
||||
return errors.New("ect: ECTStore required for DAG validation")
|
||||
}
|
||||
if cfg.ClockSkewTolerance <= 0 {
|
||||
cfg.ClockSkewTolerance = DefaultClockSkewTolerance
|
||||
}
|
||||
if cfg.MaxAncestorLimit <= 0 {
|
||||
cfg.MaxAncestorLimit = DefaultMaxAncestorLimit
|
||||
}
|
||||
if cfg.MaxParLength > 0 && len(ect.Par) > cfg.MaxParLength {
|
||||
return ErrParLength
|
||||
}
|
||||
|
||||
// 1. Task ID Uniqueness (task id = jti per spec)
|
||||
if store.Contains(ect.Jti, ect.Wid) {
|
||||
return fmt.Errorf("ect: task ID (jti) already exists: %s", ect.Jti)
|
||||
}
|
||||
|
||||
// 2. Parent Existence and 3. Temporal Ordering
|
||||
for _, parentID := range ect.Par {
|
||||
parent := store.GetByTid(parentID)
|
||||
if parent == nil {
|
||||
return fmt.Errorf("ect: parent task not found: %s", parentID)
|
||||
}
|
||||
// parent.iat < child.iat + clock_skew_tolerance => parent.iat - ect.iat <= clock_skew_tolerance
|
||||
if parent.Iat >= ect.Iat+int64(cfg.ClockSkewTolerance) {
|
||||
return fmt.Errorf("ect: parent task not earlier than current: %s", parentID)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Acyclicity (and depth limit)
|
||||
visited := make(map[string]struct{})
|
||||
if hasCycle(ect.Jti, ect.Par, store, visited, cfg.MaxAncestorLimit) {
|
||||
return errors.New("ect: circular dependency or depth limit exceeded")
|
||||
}
|
||||
|
||||
// 5. Parent Policy Decision (only when parent has policy claims per spec)
|
||||
for _, parentID := range ect.Par {
|
||||
parent := store.GetByTid(parentID)
|
||||
if parent != nil && parent.HasPolicyClaims() &&
|
||||
(parent.PolDecision == PolDecisionRejected || parent.PolDecision == PolDecisionPendingHumanReview) {
|
||||
if !ect.CompensationRequired() {
|
||||
return errors.New("ect: parent has non-approved pol_decision; current ECT must be compensation/remediation or have ext.compensation_required true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasCycle returns true if following par from the given parent IDs leads back to targetTid
|
||||
// or if traversal exceeds maxDepth. visited is mutated.
|
||||
func hasCycle(targetTid string, parentIDs []string, store ECTStore, visited map[string]struct{}, maxDepth int) bool {
|
||||
if len(visited) >= maxDepth {
|
||||
return true
|
||||
}
|
||||
for _, parentID := range parentIDs {
|
||||
if parentID == targetTid {
|
||||
return true
|
||||
}
|
||||
if _, ok := visited[parentID]; ok {
|
||||
continue
|
||||
}
|
||||
visited[parentID] = struct{}{}
|
||||
parent := store.GetByTid(parentID)
|
||||
if parent != nil {
|
||||
if hasCycle(targetTid, parent.Par, store, visited, maxDepth) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
140
refimpl/go-lang/ect/dag_test.go
Normal file
140
refimpl/go-lang/ect/dag_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateDAG_Root(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
payload := &Payload{
|
||||
Jti: "jti-001",
|
||||
Wid: "wf-1",
|
||||
Par: []string{},
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_DuplicateJti(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
_, _ = store.Append("dummy-jws", &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved})
|
||||
payload := &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate jti")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_ParentExists(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-001", Wid: "wf-1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix() - 60})
|
||||
payload := &Payload{
|
||||
Jti: "jti-002",
|
||||
Wid: "wf-1",
|
||||
Par: []string{"jti-001"},
|
||||
PolDecision: PolDecisionApproved,
|
||||
Iat: time.Now().Unix(),
|
||||
}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_ParentNotFound(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
payload := &Payload{
|
||||
Jti: "jti-002",
|
||||
Par: []string{"jti-missing"},
|
||||
PolDecision: PolDecisionApproved,
|
||||
Iat: time.Now().Unix(),
|
||||
}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_DepthLimit(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
now := time.Now().Unix()
|
||||
// Chain: jti-1 -> jti-2 -> jti-3 -> ...; validate with maxAncestorLimit=2 so we exceed it
|
||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now - 100})
|
||||
_, _ = store.Append("jws2", &Payload{Jti: "jti-2", Wid: "wf", Par: []string{"jti-1"}, PolDecision: PolDecisionApproved, Iat: now - 50})
|
||||
cfg := DAGConfig{ClockSkewTolerance: DefaultClockSkewTolerance, MaxAncestorLimit: 2}
|
||||
payload := &Payload{Jti: "jti-3", Wid: "wf", Par: []string{"jti-2"}, PolDecision: PolDecisionApproved, Iat: now}
|
||||
err := ValidateDAG(payload, store, cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when ancestor limit exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_StoreNil(t *testing.T) {
|
||||
payload := &Payload{Jti: "j1", Par: []string{}, PolDecision: PolDecisionApproved, Iat: time.Now().Unix()}
|
||||
err := ValidateDAG(payload, nil, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when store is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_TemporalOrdering(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
now := time.Now().Unix()
|
||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-1", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now})
|
||||
// child has iat before parent + skew: parent.iat (now) >= child.iat (now+100) + 30 => invalid
|
||||
payload := &Payload{Jti: "jti-2", Wid: "wf", Par: []string{"jti-1"}, PolDecision: PolDecisionApproved, Iat: now + 100}
|
||||
err := ValidateDAG(payload, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// parent.iat >= child.iat + skew: parent at now+50, child at now+10, skew 30 => 50 >= 40 => invalid
|
||||
_, _ = store.Append("jws2", &Payload{Jti: "jti-1b", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now + 50})
|
||||
payload2 := &Payload{Jti: "jti-2b", Wid: "wf", Par: []string{"jti-1b"}, PolDecision: PolDecisionApproved, Iat: now + 10}
|
||||
err = ValidateDAG(payload2, store, DAGConfig{ClockSkewTolerance: 30, MaxAncestorLimit: 10000})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent not earlier than child")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_DirectCycle(t *testing.T) {
|
||||
// par contains own jti (direct self-reference) -> parent not found
|
||||
store := NewMemoryLedger()
|
||||
now := time.Now().Unix()
|
||||
payload := &Payload{Jti: "jti-self", Wid: "wf", Par: []string{"jti-self"}, PolDecision: PolDecisionApproved, Iat: now}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for direct cycle (par contains self)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_hasCycle_visitedContinue(t *testing.T) {
|
||||
// par has duplicate parent ID so we hit "if _, ok := visited[parentID]; ok { continue }"
|
||||
store := NewMemoryLedger()
|
||||
now := time.Now().Unix()
|
||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-a", Wid: "wf", Par: []string{}, PolDecision: PolDecisionApproved, Iat: now - 10})
|
||||
payload := &Payload{Jti: "jti-b", Wid: "wf", Par: []string{"jti-a", "jti-a"}, PolDecision: PolDecisionApproved, Iat: now}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDAG_ParentPolicyRejected_RequiresCompensation(t *testing.T) {
|
||||
store := NewMemoryLedger()
|
||||
now := time.Now().Unix()
|
||||
_, _ = store.Append("jws1", &Payload{Jti: "jti-rej", Wid: "wf", Par: []string{}, Pol: "p", PolDecision: PolDecisionRejected, Iat: now - 60})
|
||||
payload := &Payload{Jti: "jti-child", Wid: "wf", Par: []string{"jti-rej"}, PolDecision: PolDecisionApproved, Iat: now}
|
||||
err := ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent rejected and no compensation")
|
||||
}
|
||||
payload.Ext = map[string]interface{}{"compensation_required": true}
|
||||
err = ValidateDAG(payload, store, DefaultDAGConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
29
refimpl/go-lang/ect/errors.go
Normal file
29
refimpl/go-lang/ect/errors.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package ect
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors for programmatic handling and logging.
|
||||
var (
|
||||
ErrPayloadRequired = errors.New("ect: payload and privateKey required")
|
||||
ErrKeyIDRequired = errors.New("ect: KeyID required")
|
||||
ErrInvalidTyp = errors.New("ect: invalid typ parameter")
|
||||
ErrProhibitedAlg = errors.New("ect: prohibited algorithm")
|
||||
ErrMissingKid = errors.New("ect: missing kid")
|
||||
ErrUnknownKey = errors.New("ect: unknown key identifier")
|
||||
ErrWITSubjectMismatch = errors.New("ect: issuer does not match WIT subject")
|
||||
ErrAudienceMismatch = errors.New("ect: audience does not include verifier")
|
||||
ErrExpired = errors.New("ect: token expired")
|
||||
ErrIATTooOld = errors.New("ect: iat too far in the past")
|
||||
ErrIATInFuture = errors.New("ect: iat in the future")
|
||||
ErrMissingClaims = errors.New("ect: missing required claims (jti, exec_act, par)")
|
||||
ErrPolPolDecisionPair = errors.New("ect: pol and pol_decision must both be present when either is set")
|
||||
ErrInvalidPolDecision = errors.New("ect: invalid pol_decision value")
|
||||
ErrReplay = errors.New("ect: jti already seen (replay)")
|
||||
ErrResolveKeyRequired = errors.New("ect: ResolveKey required")
|
||||
ErrExtSize = errors.New("ect: ext exceeds max size (4096 bytes)")
|
||||
ErrExtDepth = errors.New("ect: ext exceeds max nesting depth (5)")
|
||||
ErrInvalidJTI = errors.New("ect: jti must be UUID format")
|
||||
ErrInvalidWID = errors.New("ect: wid must be UUID format when set")
|
||||
ErrParLength = errors.New("ect: par exceeds max length")
|
||||
ErrHashFormat = errors.New("ect: inp_hash/out_hash must be algorithm:base64url (e.g. sha-256:...)")
|
||||
)
|
||||
69
refimpl/go-lang/ect/jti_cache.go
Normal file
69
refimpl/go-lang/ect/jti_cache.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JTICache provides replay protection by remembering seen JTIs. Safe for concurrent use.
|
||||
// Use as VerifyOptions.JTISeen: cache.Seen(jti) and call cache.Add(jti) after successful verify.
|
||||
type JTICache interface {
|
||||
// Seen returns true if jti was already added (replay).
|
||||
Seen(jti string) bool
|
||||
// Add records jti. Call after successful verification before accepting the token.
|
||||
Add(jti string)
|
||||
}
|
||||
|
||||
type jtiEntry struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewJTICache returns an in-memory JTI cache with optional max size and TTL.
|
||||
// maxSize 0 means unbounded; entries are evicted after ttl.
|
||||
func NewJTICache(maxSize int, ttl time.Duration) JTICache {
|
||||
return &jtiCache{
|
||||
byJti: make(map[string]jtiEntry),
|
||||
maxSize: maxSize,
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
type jtiCache struct {
|
||||
mu sync.RWMutex
|
||||
byJti map[string]jtiEntry
|
||||
maxSize int
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func (c *jtiCache) Seen(jti string) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
e, ok := c.byJti[jti]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(e.expiresAt) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *jtiCache) Add(jti string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
now := time.Now()
|
||||
// Evict expired
|
||||
for k, v := range c.byJti {
|
||||
if now.After(v.expiresAt) {
|
||||
delete(c.byJti, k)
|
||||
}
|
||||
}
|
||||
if c.maxSize > 0 && len(c.byJti) >= c.maxSize && c.byJti[jti].expiresAt.IsZero() {
|
||||
// Evict one oldest (simple: remove first we see)
|
||||
for k := range c.byJti {
|
||||
delete(c.byJti, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
c.byJti[jti] = jtiEntry{expiresAt: now.Add(c.ttl)}
|
||||
}
|
||||
56
refimpl/go-lang/ect/jti_cache_test.go
Normal file
56
refimpl/go-lang/ect/jti_cache_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestJTICache_SeenAndAdd(t *testing.T) {
|
||||
cache := NewJTICache(10, 1*time.Second)
|
||||
if cache.Seen("jti-1") {
|
||||
t.Error("unseen jti should not be seen")
|
||||
}
|
||||
cache.Add("jti-1")
|
||||
if !cache.Seen("jti-1") {
|
||||
t.Error("added jti should be seen")
|
||||
}
|
||||
if cache.Seen("jti-2") {
|
||||
t.Error("other jti should not be seen")
|
||||
}
|
||||
cache.Add("jti-2")
|
||||
if !cache.Seen("jti-2") {
|
||||
t.Error("second jti should be seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJTICache_Expiry(t *testing.T) {
|
||||
cache := NewJTICache(10, 50*time.Millisecond)
|
||||
cache.Add("jti-1")
|
||||
if !cache.Seen("jti-1") {
|
||||
t.Fatal("added jti should be seen")
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
if cache.Seen("jti-1") {
|
||||
t.Error("expired jti should not be seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJTICache_MaxSizeEviction(t *testing.T) {
|
||||
cache := NewJTICache(2, 5*time.Second)
|
||||
cache.Add("jti-1")
|
||||
cache.Add("jti-2")
|
||||
cache.Add("jti-3") // evicts one
|
||||
// At least one of jti-1, jti-2 may be evicted; jti-3 must be present
|
||||
if !cache.Seen("jti-3") {
|
||||
t.Error("newest jti should be present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJTICache_AddWhenAlreadyPresent(t *testing.T) {
|
||||
cache := NewJTICache(2, 5*time.Second)
|
||||
cache.Add("jti-1")
|
||||
cache.Add("jti-1") // refresh same jti; should not evict
|
||||
if !cache.Seen("jti-1") {
|
||||
t.Error("jti-1 should still be present")
|
||||
}
|
||||
}
|
||||
107
refimpl/go-lang/ect/ledger.go
Normal file
107
refimpl/go-lang/ect/ledger.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LedgerEntry represents a single ECT record in the audit ledger (Section 9.3).
|
||||
type LedgerEntry struct {
|
||||
LedgerSequence int64 `json:"ledger_sequence"`
|
||||
TaskID string `json:"task_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Action string `json:"action"`
|
||||
Parents []string `json:"parents"`
|
||||
ECTJWS string `json:"ect_jws"`
|
||||
SignatureVerified bool `json:"signature_verified"`
|
||||
VerificationTime time.Time `json:"verification_timestamp"`
|
||||
StoredTime time.Time `json:"stored_timestamp"`
|
||||
}
|
||||
|
||||
// Ledger is the audit ledger interface per Section 9. Append-only; lookup by tid.
|
||||
type Ledger interface {
|
||||
// Append records a verified ECT. Returns the new ledger sequence number or error.
|
||||
Append(ectJWS string, payload *Payload) (seq int64, err error)
|
||||
// GetByTid returns the payload for the given task ID, or nil.
|
||||
GetByTid(tid string) *Payload
|
||||
// Contains returns true if (tid, wid) exists. wid may be empty for global scope.
|
||||
Contains(tid, wid string) bool
|
||||
// ECTStore implementation for DAG validation
|
||||
ECTStore
|
||||
}
|
||||
|
||||
// MemoryLedger is an in-memory, append-only ECT store implementing Ledger and ECTStore.
|
||||
type MemoryLedger struct {
|
||||
mu sync.RWMutex
|
||||
seq int64
|
||||
byTid map[string]*Payload
|
||||
bySeq []LedgerEntry
|
||||
entries []LedgerEntry // full entries for audit
|
||||
}
|
||||
|
||||
// NewMemoryLedger creates an empty in-memory ledger.
|
||||
func NewMemoryLedger() *MemoryLedger {
|
||||
return &MemoryLedger{
|
||||
byTid: make(map[string]*Payload),
|
||||
bySeq: make([]LedgerEntry, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Append implements Ledger.
|
||||
func (m *MemoryLedger) Append(ectJWS string, payload *Payload) (int64, error) {
|
||||
if payload == nil {
|
||||
return 0, nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// Uniqueness: jti (task id) must not already exist (scoped by wid when present) per spec
|
||||
wid := payload.Wid
|
||||
if m.containsLocked(payload.Jti, wid) {
|
||||
return 0, ErrTaskIDExists
|
||||
}
|
||||
m.seq++
|
||||
entry := LedgerEntry{
|
||||
LedgerSequence: m.seq,
|
||||
TaskID: payload.Jti, // task id = jti per spec
|
||||
AgentID: payload.Iss,
|
||||
Action: payload.ExecAct,
|
||||
Parents: append([]string(nil), payload.Par...),
|
||||
ECTJWS: ectJWS,
|
||||
SignatureVerified: true,
|
||||
VerificationTime: time.Now().UTC(),
|
||||
StoredTime: time.Now().UTC(),
|
||||
}
|
||||
m.byTid[payload.Jti] = payload
|
||||
m.bySeq = append(m.bySeq, entry)
|
||||
m.entries = append(m.entries, entry)
|
||||
return m.seq, nil
|
||||
}
|
||||
|
||||
// GetByTid implements ECTStore and Ledger.
|
||||
func (m *MemoryLedger) GetByTid(tid string) *Payload {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.byTid[tid]
|
||||
}
|
||||
|
||||
// Contains implements ECTStore and Ledger.
|
||||
func (m *MemoryLedger) Contains(tid, wid string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.containsLocked(tid, wid)
|
||||
}
|
||||
|
||||
func (m *MemoryLedger) containsLocked(tid, wid string) bool {
|
||||
p, ok := m.byTid[tid]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if wid == "" {
|
||||
return true
|
||||
}
|
||||
return p.Wid == wid
|
||||
}
|
||||
|
||||
// ErrTaskIDExists is returned when appending an ECT whose jti already exists.
|
||||
var ErrTaskIDExists = errors.New("ect: task ID (jti) already exists in ledger")
|
||||
58
refimpl/go-lang/ect/ledger_test.go
Normal file
58
refimpl/go-lang/ect/ledger_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMemoryLedger_AppendAndGet(t *testing.T) {
|
||||
m := NewMemoryLedger()
|
||||
p := &Payload{Jti: "jti-1", Iss: "iss", ExecAct: "act", Par: []string{}, Iat: time.Now().Unix(), Exp: time.Now().Add(time.Hour).Unix()}
|
||||
seq, err := m.Append("jws1", p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seq != 1 {
|
||||
t.Errorf("seq: got %d", seq)
|
||||
}
|
||||
got := m.GetByTid("jti-1")
|
||||
if got == nil || got.Jti != "jti-1" {
|
||||
t.Errorf("GetByTid: got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryLedger_ErrTaskIDExists(t *testing.T) {
|
||||
m := NewMemoryLedger()
|
||||
p := &Payload{Jti: "jti-dup", Iss: "i", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}
|
||||
_, _ = m.Append("jws1", p)
|
||||
_, err := m.Append("jws2", p)
|
||||
if err != ErrTaskIDExists {
|
||||
t.Errorf("expected ErrTaskIDExists, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryLedger_ContainsWid(t *testing.T) {
|
||||
m := NewMemoryLedger()
|
||||
p := &Payload{Jti: "j1", Wid: "wf1", Iss: "i", ExecAct: "e", Par: []string{}, Iat: 1, Exp: 2}
|
||||
_, _ = m.Append("jws", p)
|
||||
if !m.Contains("j1", "") {
|
||||
t.Error("Contains(j1, \"\") should be true")
|
||||
}
|
||||
if !m.Contains("j1", "wf1") {
|
||||
t.Error("Contains(j1, wf1) should be true")
|
||||
}
|
||||
if m.Contains("j1", "wf2") {
|
||||
t.Error("Contains(j1, wf2) should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryLedger_AppendNilPayload(t *testing.T) {
|
||||
m := NewMemoryLedger()
|
||||
seq, err := m.Append("jws", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seq != 0 {
|
||||
t.Errorf("Append(nil) should return 0, got %d", seq)
|
||||
}
|
||||
}
|
||||
102
refimpl/go-lang/ect/types.go
Normal file
102
refimpl/go-lang/ect/types.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Package ect implements Execution Context Tokens (ECTs) per
|
||||
// draft-nennemann-wimse-execution-context-00.
|
||||
package ect
|
||||
|
||||
import "time"
|
||||
|
||||
// ECTType is the JOSE typ value for ECTs.
|
||||
const ECTType = "wimse-exec+jwt"
|
||||
|
||||
// PolDecision values per Section 4.2.3.
|
||||
const (
|
||||
PolDecisionApproved = "approved"
|
||||
PolDecisionRejected = "rejected"
|
||||
PolDecisionPendingHumanReview = "pending_human_review"
|
||||
)
|
||||
|
||||
// Payload holds ECT JWT claims per Section 4.2.
|
||||
type Payload struct {
|
||||
// Standard JWT claims (required unless noted)
|
||||
Iss string `json:"iss"` // REQUIRED: issuer, SPIFFE ID
|
||||
Sub string `json:"sub,omitempty"`
|
||||
Aud Audience `json:"aud"` // REQUIRED
|
||||
Iat int64 `json:"iat"` // REQUIRED: NumericDate
|
||||
Exp int64 `json:"exp"` // REQUIRED
|
||||
Jti string `json:"jti"` // REQUIRED: UUID
|
||||
|
||||
// Execution context (Section 4.2.2 / exec-claims)
|
||||
// Task identity is jti only; no separate "tid" claim per spec.
|
||||
Wid string `json:"wid,omitempty"` // OPTIONAL: workflow ID, UUID
|
||||
ExecAct string `json:"exec_act"` // REQUIRED
|
||||
Par []string `json:"par"` // REQUIRED: parent jti values
|
||||
|
||||
// Policy evaluation (Section 4.2.3 / policy-claims) — OPTIONAL
|
||||
Pol string `json:"pol,omitempty"`
|
||||
PolDecision string `json:"pol_decision,omitempty"`
|
||||
PolEnforcer string `json:"pol_enforcer,omitempty"`
|
||||
PolTimestamp int64 `json:"pol_timestamp,omitempty"`
|
||||
|
||||
// Data integrity (Section 4.2.4)
|
||||
InpHash string `json:"inp_hash,omitempty"`
|
||||
OutHash string `json:"out_hash,omitempty"`
|
||||
InpClassification string `json:"inp_classification,omitempty"`
|
||||
|
||||
// Extensions (Section 4.2.7): exec_time_ms, regulated_domain, model_version,
|
||||
// witnessed_by, inp_classification, pol_timestamp, compensation_required, compensation_reason
|
||||
Ext map[string]interface{} `json:"ext,omitempty"`
|
||||
}
|
||||
|
||||
// Audience is aud claim: string or array of strings.
|
||||
type Audience []string
|
||||
|
||||
// MarshalJSON encodes aud as single string if one element, else array.
|
||||
func (a Audience) MarshalJSON() ([]byte, error) {
|
||||
if len(a) == 1 {
|
||||
return marshalJSONString(a[0]), nil
|
||||
}
|
||||
return marshalJSONStringArray(a), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes aud from string or array.
|
||||
func (a *Audience) UnmarshalJSON(data []byte) error {
|
||||
return unmarshalAudience(data, a)
|
||||
}
|
||||
|
||||
// ValidPolDecision returns true if s is a registered pol_decision value.
|
||||
func ValidPolDecision(s string) bool {
|
||||
return s == PolDecisionApproved || s == PolDecisionRejected || s == PolDecisionPendingHumanReview
|
||||
}
|
||||
|
||||
// ContainsAudience returns true if verifierID is in the audience.
|
||||
func (p *Payload) ContainsAudience(verifierID string) bool {
|
||||
for _, id := range p.Aud {
|
||||
if id == verifierID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IATTime returns p.Iat as time.Time.
|
||||
func (p *Payload) IATTime() time.Time {
|
||||
return time.Unix(p.Iat, 0)
|
||||
}
|
||||
|
||||
// ExpTime returns p.Exp as time.Time.
|
||||
func (p *Payload) ExpTime() time.Time {
|
||||
return time.Unix(p.Exp, 0)
|
||||
}
|
||||
|
||||
// CompensationRequired returns true if ext["compensation_required"] is true (per spec extension).
|
||||
func (p *Payload) CompensationRequired() bool {
|
||||
if p.Ext == nil {
|
||||
return false
|
||||
}
|
||||
v, _ := p.Ext["compensation_required"].(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
// HasPolicyClaims returns true if both pol and pol_decision are present (optional pair per spec).
|
||||
func (p *Payload) HasPolicyClaims() bool {
|
||||
return p.Pol != "" && p.PolDecision != ""
|
||||
}
|
||||
115
refimpl/go-lang/ect/types_test.go
Normal file
115
refimpl/go-lang/ect/types_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAudience_MarshalJSON_single(t *testing.T) {
|
||||
a := Audience{"single"}
|
||||
b, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != `"single"` {
|
||||
t.Errorf("single aud: got %s", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudience_MarshalJSON_multi(t *testing.T) {
|
||||
a := Audience{"a", "b"}
|
||||
b, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != `["a","b"]` {
|
||||
t.Errorf("multi aud: got %s", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudience_UnmarshalJSON_array(t *testing.T) {
|
||||
var a Audience
|
||||
err := json.Unmarshal([]byte(`["x","y"]`), &a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a) != 2 || a[0] != "x" || a[1] != "y" {
|
||||
t.Errorf("unmarshal array: got %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudience_UnmarshalJSON_string(t *testing.T) {
|
||||
var a Audience
|
||||
err := json.Unmarshal([]byte(`"single"`), &a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a) != 1 || a[0] != "single" {
|
||||
t.Errorf("unmarshal string: got %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudience_UnmarshalJSON_invalid(t *testing.T) {
|
||||
var a Audience
|
||||
// aud must be string or array; object is invalid
|
||||
err := json.Unmarshal([]byte(`{}`), &a)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid aud type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidPolDecision(t *testing.T) {
|
||||
if !ValidPolDecision(PolDecisionApproved) {
|
||||
t.Error("approved should be valid")
|
||||
}
|
||||
if !ValidPolDecision(PolDecisionRejected) {
|
||||
t.Error("rejected should be valid")
|
||||
}
|
||||
if ValidPolDecision("invalid") {
|
||||
t.Error("invalid should not be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayload_ContainsAudience(t *testing.T) {
|
||||
p := &Payload{Aud: []string{"a", "b"}}
|
||||
if !p.ContainsAudience("a") {
|
||||
t.Error("should contain a")
|
||||
}
|
||||
if p.ContainsAudience("c") {
|
||||
t.Error("should not contain c")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayload_IATTime_ExpTime(t *testing.T) {
|
||||
ts := int64(1000000)
|
||||
p := &Payload{Iat: ts, Exp: ts + 100}
|
||||
if !p.IATTime().Equal(time.Unix(ts, 0)) {
|
||||
t.Error("IATTime mismatch")
|
||||
}
|
||||
if !p.ExpTime().Equal(time.Unix(ts+100, 0)) {
|
||||
t.Error("ExpTime mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayload_CompensationRequired(t *testing.T) {
|
||||
p := &Payload{}
|
||||
if p.CompensationRequired() {
|
||||
t.Error("nil Ext should not be compensation")
|
||||
}
|
||||
p.Ext = map[string]interface{}{"compensation_required": true}
|
||||
if !p.CompensationRequired() {
|
||||
t.Error("ext compensation_required true should return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayload_HasPolicyClaims(t *testing.T) {
|
||||
p := &Payload{Pol: "p", PolDecision: PolDecisionApproved}
|
||||
if !p.HasPolicyClaims() {
|
||||
t.Error("both pol and pol_decision set should have policy claims")
|
||||
}
|
||||
p.Pol = ""
|
||||
if p.HasPolicyClaims() {
|
||||
t.Error("missing pol should not have policy claims")
|
||||
}
|
||||
}
|
||||
83
refimpl/go-lang/ect/validate.go
Normal file
83
refimpl/go-lang/ect/validate.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExtMaxSize is the recommended max serialized size of ext (Section 4.2.7).
|
||||
const ExtMaxSize = 4096
|
||||
|
||||
// ExtMaxDepth is the recommended max JSON nesting depth in ext.
|
||||
const ExtMaxDepth = 5
|
||||
|
||||
// DefaultMaxParLength is the recommended max number of parent references.
|
||||
const DefaultMaxParLength = 100
|
||||
|
||||
// uuidRegex matches RFC 9562 UUID: 8-4-4-4-12 hex.
|
||||
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
|
||||
|
||||
// allowedHashAlgs are the spec-recommended hash algorithm prefixes for inp_hash/out_hash.
|
||||
var allowedHashAlgs = map[string]bool{"sha-256": true, "sha-384": true, "sha-512": true}
|
||||
|
||||
// ValidateExt returns an error if ext exceeds ExtMaxSize or ExtMaxDepth.
|
||||
func ValidateExt(ext map[string]interface{}) error {
|
||||
if ext == nil || len(ext) == 0 {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(ext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(b) > ExtMaxSize {
|
||||
return ErrExtSize
|
||||
}
|
||||
if depth(b) > ExtMaxDepth {
|
||||
return ErrExtDepth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func depth(b []byte) int {
|
||||
var max, level int
|
||||
for _, c := range b {
|
||||
switch c {
|
||||
case '{', '[':
|
||||
level++
|
||||
if level > max {
|
||||
max = level
|
||||
}
|
||||
case '}', ']':
|
||||
level--
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// ValidUUID returns true if s is a UUID string (RFC 9562).
|
||||
func ValidUUID(s string) bool {
|
||||
return uuidRegex.MatchString(s)
|
||||
}
|
||||
|
||||
// ValidateHashFormat returns nil if s is empty or matches "algorithm:base64url" (sha-256, sha-384, sha-512).
|
||||
func ValidateHashFormat(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
idx := strings.Index(s, ":")
|
||||
if idx <= 0 {
|
||||
return ErrHashFormat
|
||||
}
|
||||
alg := strings.ToLower(s[:idx])
|
||||
if !allowedHashAlgs[alg] {
|
||||
return ErrHashFormat
|
||||
}
|
||||
encoded := s[idx+1:]
|
||||
if encoded == "" {
|
||||
return ErrHashFormat
|
||||
}
|
||||
_, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
return err
|
||||
}
|
||||
237
refimpl/go-lang/ect/verify.go
Normal file
237
refimpl/go-lang/ect/verify.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/subtle"
|
||||
"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.
|
||||
Store ECTStore
|
||||
// DAG 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
|
||||
// ValidateUUIDs when true requires jti and wid (if set) to be UUID format.
|
||||
ValidateUUIDs bool
|
||||
// MaxParLength caps par length (0 = no limit). Applied before DAG; DAG may also enforce via DAG.MaxParLength.
|
||||
MaxParLength int
|
||||
// LogVerify if set is called after verification with jti and any error (for observability).
|
||||
LogVerify func(jti string, err error)
|
||||
}
|
||||
|
||||
// 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) (parsed *ParsedECT, err error) {
|
||||
var logJti string
|
||||
defer func() {
|
||||
if opts.LogVerify != nil {
|
||||
opts.LogVerify(logJti, err)
|
||||
}
|
||||
}()
|
||||
|
||||
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
|
||||
|
||||
// 2. typ must be wimse-exec+jwt (constant-time compare)
|
||||
typ, _ := header.ExtraHeaders["typ"].(string)
|
||||
if typ == "" {
|
||||
typ, _ = header.ExtraHeaders[jose.HeaderType].(string)
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(typ), []byte(ECTType)) != 1 {
|
||||
return nil, ErrInvalidTyp
|
||||
}
|
||||
|
||||
// 3. alg not none and not symmetric
|
||||
if header.Algorithm == "none" || header.Algorithm == "HS256" || header.Algorithm == "HS384" || header.Algorithm == "HS512" {
|
||||
return nil, ErrProhibitedAlg
|
||||
}
|
||||
|
||||
// 4. kid references known key
|
||||
kid := header.KeyID
|
||||
if kid == "" {
|
||||
return nil, ErrMissingKid
|
||||
}
|
||||
pub, err := opts.ResolveKey(kid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ect: key resolution: %w", err)
|
||||
}
|
||||
if pub == nil {
|
||||
return nil, ErrUnknownKey
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
logJti = p.Jti
|
||||
|
||||
if err := ValidateExt(p.Ext); 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, ErrWITSubjectMismatch
|
||||
}
|
||||
|
||||
// 9. aud contains verifier
|
||||
if opts.VerifierID != "" && !p.ContainsAudience(opts.VerifierID) {
|
||||
return nil, ErrAudienceMismatch
|
||||
}
|
||||
|
||||
// 10. exp not expired
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
if now.Unix() > p.Exp {
|
||||
return nil, ErrExpired
|
||||
}
|
||||
|
||||
// 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, ErrIATTooOld
|
||||
}
|
||||
if p.Iat > now.Unix()+int64(opts.IATMaxFuture.Seconds()) {
|
||||
return nil, ErrIATInFuture
|
||||
}
|
||||
|
||||
// 12. Required claims present (jti, exec_act, par)
|
||||
if p.Jti == "" || p.ExecAct == "" {
|
||||
return nil, ErrMissingClaims
|
||||
}
|
||||
if p.Par == nil {
|
||||
p.Par = []string{}
|
||||
}
|
||||
if opts.MaxParLength > 0 && len(p.Par) > opts.MaxParLength {
|
||||
return nil, ErrParLength
|
||||
}
|
||||
if opts.ValidateUUIDs {
|
||||
if !ValidUUID(p.Jti) {
|
||||
return nil, ErrInvalidJTI
|
||||
}
|
||||
if p.Wid != "" && !ValidUUID(p.Wid) {
|
||||
return nil, ErrInvalidWID
|
||||
}
|
||||
}
|
||||
if p.InpHash != "" {
|
||||
if err := ValidateHashFormat(p.InpHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if p.OutHash != "" {
|
||||
if err := ValidateHashFormat(p.OutHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 13. If pol or pol_decision present, both must be present and pol_decision in registry
|
||||
if p.Pol != "" || p.PolDecision != "" {
|
||||
if p.Pol == "" || p.PolDecision == "" {
|
||||
return nil, ErrPolPolDecisionPair
|
||||
}
|
||||
if !ValidPolDecision(p.PolDecision) {
|
||||
return nil, ErrInvalidPolDecision
|
||||
}
|
||||
}
|
||||
|
||||
// 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, ErrReplay
|
||||
}
|
||||
|
||||
return &ParsedECT{Header: header, Payload: &p, Raw: compact}, nil
|
||||
}
|
||||
243
refimpl/go-lang/ect/verify_test.go
Normal file
243
refimpl/go-lang/ect/verify_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package ect
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss",
|
||||
Aud: []string{"aud"},
|
||||
Iat: now.Unix(),
|
||||
Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-parse",
|
||||
ExecAct: "act",
|
||||
Par: []string{},
|
||||
Pol: "pol",
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, err := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := Parse(compact)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed.Payload.Jti != "jti-parse" {
|
||||
t.Errorf("jti: got %q", parsed.Payload.Jti)
|
||||
}
|
||||
if parsed.Raw != compact {
|
||||
t.Error("Raw should match compact")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_InvalidTyp(t *testing.T) {
|
||||
// Create token then tamper header to test typ check would need different alg or manual JWS;
|
||||
// instead test Verify with bad token
|
||||
_, err := Verify("not-a-jws", VerifyOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JWS")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_Expired(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss",
|
||||
Aud: []string{"verifier"},
|
||||
Iat: now.Add(-1 * time.Hour).Unix(),
|
||||
Exp: now.Add(-1 * time.Minute).Unix(),
|
||||
Jti: "jti-exp",
|
||||
ExecAct: "act",
|
||||
Par: []string{},
|
||||
Pol: "pol",
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
_, err := Verify(compact, VerifyOptions{
|
||||
VerifierID: "verifier",
|
||||
ResolveKey: resolver,
|
||||
Now: now,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_Replay(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss",
|
||||
Aud: []string{"v"},
|
||||
Iat: now.Unix(),
|
||||
Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-replay",
|
||||
ExecAct: "act",
|
||||
Par: []string{},
|
||||
Pol: "p",
|
||||
PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
opts := VerifyOptions{
|
||||
VerifierID: "v",
|
||||
ResolveKey: resolver,
|
||||
Now: now,
|
||||
JTISeen: func(jti string) bool { return jti == "jti-replay" },
|
||||
}
|
||||
_, err := Verify(compact, opts)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for replay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultVerifyOptions(t *testing.T) {
|
||||
opts := DefaultVerifyOptions()
|
||||
if opts.IATMaxAge != 15*time.Minute {
|
||||
t.Errorf("IATMaxAge: got %v", opts.IATMaxAge)
|
||||
}
|
||||
if opts.DAG.ClockSkewTolerance != DefaultClockSkewTolerance {
|
||||
t.Errorf("DAG: got %d", opts.DAG.ClockSkewTolerance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_WITSubjectMismatch(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-wit", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
_, err := Verify(compact, VerifyOptions{
|
||||
VerifierID: "v", ResolveKey: resolver, Now: now, WITSubject: "other-iss",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for WIT subject mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_IATTooFarPast(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Add(-1 * time.Hour).Unix(), Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-iat", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
_, err := Verify(compact, VerifyOptions{
|
||||
VerifierID: "v", ResolveKey: resolver, Now: now, IATMaxAge: 30 * time.Minute,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for iat too far in past")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_IATInFuture(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Add(60 * time.Second).Unix(), Exp: now.Add(2 * time.Hour).Unix(),
|
||||
Jti: "jti-fut", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
_, err := Verify(compact, VerifyOptions{
|
||||
VerifierID: "v", ResolveKey: resolver, Now: now, IATMaxFuture: 30 * time.Second,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for iat in future")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_ResolveKeyError(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
now := time.Now()
|
||||
payload := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-err", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compact, _ := Create(payload, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
return nil, errors.New("key lookup failed")
|
||||
}
|
||||
_, err := Verify(compact, VerifyOptions{
|
||||
VerifierID: "v", ResolveKey: resolver, Now: now,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from ResolveKey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_WithDAG(t *testing.T) {
|
||||
key, _ := GenerateKey()
|
||||
ledger := NewMemoryLedger()
|
||||
now := time.Now()
|
||||
root := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix(), Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-root", ExecAct: "act", Par: []string{}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compactRoot, _ := Create(root, key, CreateOptions{KeyID: "kid"})
|
||||
resolver := func(kid string) (*ecdsa.PublicKey, error) {
|
||||
if kid == "kid" {
|
||||
return &key.PublicKey, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
opts := VerifyOptions{
|
||||
VerifierID: "v", ResolveKey: resolver, Store: ledger, Now: now,
|
||||
}
|
||||
parsed, err := Verify(compactRoot, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = ledger.Append(compactRoot, parsed.Payload)
|
||||
child := &Payload{
|
||||
Iss: "iss", Aud: []string{"v"}, Iat: now.Unix() + 1, Exp: now.Add(time.Hour).Unix(),
|
||||
Jti: "jti-child", ExecAct: "act2", Par: []string{"jti-root"}, Pol: "p", PolDecision: PolDecisionApproved,
|
||||
}
|
||||
compactChild, _ := Create(child, key, CreateOptions{KeyID: "kid"})
|
||||
parsed2, err := Verify(compactChild, opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed2.Payload.Jti != "jti-child" {
|
||||
t.Errorf("got %q", parsed2.Payload.Jti)
|
||||
}
|
||||
}
|
||||
7
refimpl/go-lang/go.mod
Normal file
7
refimpl/go-lang/go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module github.com/nennemann/ect-refimpl/go-lang
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/go-jose/go-jose/v4 v4.0.2
|
||||
|
||||
require golang.org/x/crypto v0.28.0 // indirect
|
||||
4
refimpl/go-lang/go.sum
Normal file
4
refimpl/go-lang/go.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
1
refimpl/go-lang/testdata/valid_root_ect_payload.json
vendored
Normal file
1
refimpl/go-lang/testdata/valid_root_ect_payload.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"iss":"spiffe://example.com/agent/clinical","sub":"spiffe://example.com/agent/clinical","aud":"spiffe://example.com/agent/safety","iat":1772064150,"exp":1772064750,"jti":"7f3a8b2c-d1e4-4f56-9a0b-c3d4e5f6a7b8","wid":"a0b1c2d3-e4f5-6789-abcd-ef0123456789","exec_act":"recommend_treatment","par":[],"pol":"clinical_reasoning_policy_v2","pol_decision":"approved"}
|
||||
Reference in New Issue
Block a user