diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 0572b3b..09d6d90 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -50,9 +50,11 @@ jobs: with: version: 9 - - name: Install integration test deps - working-directory: integration-tests - run: pnpm install + - name: Install workspace deps + run: pnpm install --frozen-lockfile + + - name: Build workspace packages used by E2E + run: pnpm --filter @okeyamy/drs-sdk --filter @drs/mcp-server build - name: Run E2E working-directory: integration-tests @@ -77,9 +79,11 @@ jobs: with: version: 9 - - name: Install integration test deps - working-directory: integration-tests - run: pnpm install + - name: Install workspace deps + run: pnpm install --frozen-lockfile + + - name: Build workspace packages used by E2E + run: pnpm --filter @okeyamy/drs-sdk --filter @drs/mcp-server build - name: Wait for image to propagate # Publish workflow pushes the image earlier in the run; this job diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index e95992d..359ddf5 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -52,6 +52,7 @@ jobs: format: sarif output: trivy-fs.sarif severity: CRITICAL,HIGH + limit-severities-for-sarif: 'true' exit-code: '1' ignore-unfixed: 'true' diff --git a/docs-site/src/explanation/security-model.md b/docs-site/src/explanation/security-model.md index 5ee1c39..fb184ca 100644 --- a/docs-site/src/explanation/security-model.md +++ b/docs-site/src/explanation/security-model.md @@ -12,7 +12,7 @@ | **Chain injection** | Insert a fake intermediate DR | `prev_dr_hash` changes break subsequent links — fails Block B | None — structural | | **Replay after revocation** | Agent replays a revoked DR | Block F: Bitstring Status List (5-min cache TTL) | Up to 5-minute stale cache window | | **JSON malleability** | Different canonical bytes for same logical JSON | RFC 8785 JCS enforced at both issuance and verification ends | Non-conforming JCS at one end | -| **Signature malleability** | `(R, S)` and `(R, S+L)` both verify under naive check | `ed25519-dalek 2.x` enforces `S < L` via `verify_strict()` | None — library enforces | +| **Signature malleability** | `(R, S)` and `(R, S+L)` both verify under naive check | Rust uses `ed25519-dalek` `verify_strict()`; Go rejects `S >= L` before stdlib verification and reports `SIGNATURE_MALLEABILITY` | Residual point-malleability differences must stay covered by strictness vectors | | **DID spoofing** | Attacker impersonates a legitimate issuer | `did:key` DIDs are derived from the public key — impossible without the private key | `did:web` requires DNS/TLS security | | **Prompt injection** | Attacker embeds instructions in tool content | DRS records every invocation chain | Out of scope — model/runtime responsibility | | **Model-level bypass** | Adversarial prompts bypass safety constraints | Model safety ≠ execution safety | Entirely outside DRS scope | diff --git a/docs-site/src/explanation/verification-algorithm.md b/docs-site/src/explanation/verification-algorithm.md index ca761ed..714352b 100644 --- a/docs-site/src/explanation/verification-algorithm.md +++ b/docs-site/src/explanation/verification-algorithm.md @@ -146,5 +146,5 @@ At 10,000 requests/second on the Go verification server: | Policy check per level | O(1) avg | Hash-set intersection in capability index | | DID resolution | O(1) amortised | LRU cache, 10,000 entry cap, 1-hour TTL | | Status list check | O(1) amortised | 5-min TTL, `sync.Once` guard | -| Ed25519 verify | implementation-dependent | Go uses `crypto/ed25519` | +| Ed25519 verify | implementation-dependent | Rust uses `ed25519-dalek` `verify_strict`; Go checks canonical `S < L` before `crypto/ed25519.Verify` | | Total per request (2-hop chain) | ~0.8ms p99 | | diff --git a/docs/drs-source-of-truth.md b/docs/drs-source-of-truth.md index ff64216..de9bdca 100644 --- a/docs/drs-source-of-truth.md +++ b/docs/drs-source-of-truth.md @@ -185,6 +185,21 @@ not by cross-language conformance vectors. --- +## Ed25519 Strictness Policy + +DRS requires canonical Ed25519 signatures. A signature whose scalar `S` is not in +the canonical range `0 <= S < L` is rejected as `SIGNATURE_MALLEABILITY`, not as a +generic signature failure. + +- Rust enforces this through `ed25519-dalek` `VerifyingKey::verify_strict`. +- Go enforces the same public contract by checking `S < L` before calling + `crypto/ed25519.Verify`, then maps that strictness failure to + `SIGNATURE_MALLEABILITY`. +- Shared vectors live in `fixtures/conformance/ed25519-strict/vectors.json` and + are consumed by both Rust and Go tests. + +--- + ## CI Enforcement The conformance workflow (`.github/workflows/conformance.yml`) runs on: diff --git a/drs-core/tests/test_ed25519.rs b/drs-core/tests/test_ed25519.rs index 9735234..d8ff304 100644 --- a/drs-core/tests/test_ed25519.rs +++ b/drs-core/tests/test_ed25519.rs @@ -1,4 +1,43 @@ use drs_core::crypto::ed25519::{generate_keypair, sign, verify_strict}; +use ed25519_dalek::SigningKey; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Ed25519StrictFixture { + vectors: Vec, +} + +#[derive(Deserialize)] +struct Ed25519StrictVector { + id: String, + seed_hex: String, + message: String, + mutation: String, + valid: bool, +} + +fn ed25519_strict_fixture() -> Ed25519StrictFixture { + serde_json::from_str(include_str!( + "../../fixtures/conformance/ed25519-strict/vectors.json" + )) + .expect("Ed25519 strict fixture parses") +} + +fn apply_ed25519_strict_mutation(mut sig: [u8; 64], mutation: &str) -> [u8; 64] { + match mutation { + "none" => sig, + "s_equals_l" => { + let group_order: [u8; 32] = [ + 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, + 0xde, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x10, + ]; + sig[32..].copy_from_slice(&group_order); + sig + } + other => panic!("unsupported Ed25519 strict mutation {other}"), + } +} /// Integration tests for Ed25519 sign/verify round-trip. /// Unit tests live in src/crypto/ed25519.rs; these exercise the public API. @@ -35,3 +74,25 @@ fn different_keypairs_produce_different_public_keys() { let (_, vk2) = generate_keypair().unwrap(); assert_ne!(vk1.to_bytes(), vk2.to_bytes()); } + +#[test] +fn shared_strictness_vectors_match_rust_verify_strict() { + let fixture = ed25519_strict_fixture(); + for vector in fixture.vectors { + let seed = hex::decode(&vector.seed_hex).expect("seed hex decodes"); + let seed: [u8; 32] = seed.try_into().expect("seed is 32 bytes"); + let signing_key = SigningKey::from_bytes(&seed); + let verifying_key = signing_key.verifying_key(); + let sig = sign(&signing_key, vector.message.as_bytes()); + let sig = apply_ed25519_strict_mutation(sig, &vector.mutation); + + let result = verify_strict(&verifying_key, vector.message.as_bytes(), &sig); + assert_eq!( + result.is_ok(), + vector.valid, + "vector {} strictness mismatch: {:?}", + vector.id, + result.err() + ); + } +} diff --git a/drs-verify/Dockerfile b/drs-verify/Dockerfile index 2dd1bb9..11d48ac 100644 --- a/drs-verify/Dockerfile +++ b/drs-verify/Dockerfile @@ -1,7 +1,7 @@ # Build stage — compile a static binary with CGO disabled. -# Go 1.25 is required to pick up stdlib CVE fixes (crypto/tls, net/http, +# Go 1.25.10 is required to pick up stdlib CVE fixes (crypto/tls, net/http, # encoding/asn1, etc.) flagged by govulncheck on older toolchains. -FROM golang:1.25-alpine AS builder +FROM golang:1.25.10-alpine AS builder WORKDIR /build diff --git a/drs-verify/go.mod b/drs-verify/go.mod index 0a7c25c..97adb17 100644 --- a/drs-verify/go.mod +++ b/drs-verify/go.mod @@ -1,6 +1,6 @@ module github.com/drs-protocol/drs-verify -go 1.25.9 +go 1.25.10 require ( github.com/gowebpki/jcs v1.0.1 diff --git a/drs-verify/pkg/verify/chain.go b/drs-verify/pkg/verify/chain.go index 7e7fabf..2431b73 100644 --- a/drs-verify/pkg/verify/chain.go +++ b/drs-verify/pkg/verify/chain.go @@ -17,6 +17,7 @@ import ( "crypto/x509" "encoding/base64" "encoding/json" + "errors" "fmt" "log/slog" "strings" @@ -30,6 +31,8 @@ import ( "github.com/drs-protocol/drs-verify/pkg/types" ) +var errSignatureMalleability = errors.New("signature malleability") + const ( expectedDRSVersion = "4.0" expectedDRType = "delegation-receipt" @@ -504,15 +507,19 @@ func verifyJWTSignature(ctx context.Context, jwt string, issuerDID string, res * return fmt.Errorf("signature base64 decode: %w", err) } - pubKey := ed25519.PublicKey(pubKeyBytes[:]) - if !ed25519.Verify(pubKey, []byte(signingInput), sigBytes) { - return fmt.Errorf("Ed25519 signature verification failed") - } // Strict S-range check: reject non-canonical signatures that Go's stdlib - // accepts but ed25519-dalek verify_strict rejects. Ensures cross-layer parity. + // either rejects generically or accepted in older/alternate implementations. + // Running this before ed25519.Verify lets DRS surface the documented + // SIGNATURE_MALLEABILITY code instead of collapsing the failure into a + // generic INVALID_SIGNATURE. if err := strictVerifyEd25519(sigBytes); err != nil { return fmt.Errorf("Ed25519 strict check failed: %w", err) } + + pubKey := ed25519.PublicKey(pubKeyBytes[:]) + if !ed25519.Verify(pubKey, []byte(signingInput), sigBytes) { + return fmt.Errorf("Ed25519 signature verification failed") + } return nil } @@ -526,11 +533,11 @@ var edGroupOrder = [32]byte{ } // strictVerifyEd25519 checks that the scalar S in an Ed25519 signature is -// canonical (S < L, the group order). Go's stdlib ed25519.Verify accepts -// non-canonical S values; ed25519-dalek verify_strict does not. This check -// closes the cross-implementation gap without external dependencies. -// -// Must be called only after ed25519.Verify returns true. +// canonical (S < L, the group order). Go's stdlib ed25519.Verify has not +// always surfaced this distinction as DRS's documented SIGNATURE_MALLEABILITY +// result. This check closes the cross-implementation gap without external +// dependencies and runs before ed25519.Verify so non-canonical signatures are +// classified precisely. func strictVerifyEd25519(sig []byte) error { if len(sig) != ed25519.SignatureSize { return fmt.Errorf("invalid signature length: %d", len(sig)) @@ -539,7 +546,7 @@ func strictVerifyEd25519(sig []byte) error { // byte (index 31) downward to determine whether S < L. for i := 31; i >= 0; i-- { if sig[32+i] > edGroupOrder[i] { - return fmt.Errorf("non-canonical Ed25519 signature scalar: S >= group order L") + return fmt.Errorf("non-canonical Ed25519 signature scalar: S >= group order L: %w", errSignatureMalleability) } if sig[32+i] < edGroupOrder[i] { return nil // S < L — canonical @@ -547,13 +554,17 @@ func strictVerifyEd25519(sig []byte) error { // bytes equal at this position; continue to less-significant byte } // S == L exactly — also non-canonical (must be strictly less than L) - return fmt.Errorf("non-canonical Ed25519 signature scalar: S == group order L") + return fmt.Errorf("non-canonical Ed25519 signature scalar: S == group order L: %w", errSignatureMalleability) } // classifySignatureError maps a verifyJWTSignature error to a VerificationResult error code. // DID resolution failures get UNRESOLVABLE_DID; all others get INVALID_SIGNATURE. func classifySignatureError(err error) (code, suggestion string) { msg := err.Error() + if errors.Is(err, errSignatureMalleability) { + return "SIGNATURE_MALLEABILITY", + "The receipt signature is non-canonical. Re-issue the receipt with a canonical Ed25519 signature." + } if strings.Contains(msg, "DID resolution failed") || strings.Contains(msg, "unsupported DID method") || strings.Contains(msg, "base58 decoding failed") || diff --git a/drs-verify/pkg/verify/chain_test.go b/drs-verify/pkg/verify/chain_test.go index aae711a..515f123 100644 --- a/drs-verify/pkg/verify/chain_test.go +++ b/drs-verify/pkg/verify/chain_test.go @@ -9,9 +9,12 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "math/big" + "os" + "path/filepath" "strings" "testing" "time" @@ -34,6 +37,48 @@ func testDeps(t *testing.T) Deps { return Deps{Resolver: res} } +type ed25519StrictFixture struct { + Vectors []ed25519StrictVector `json:"vectors"` +} + +type ed25519StrictVector struct { + ID string `json:"id"` + SeedHex string `json:"seed_hex"` + Message string `json:"message"` + Mutation string `json:"mutation"` + Valid bool `json:"valid"` + ErrorCode *string `json:"error_code"` +} + +func loadEd25519StrictFixture(t *testing.T) ed25519StrictFixture { + t.Helper() + path := filepath.Join("..", "..", "..", "fixtures", "conformance", "ed25519-strict", "vectors.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read Ed25519 strict fixture: %v", err) + } + var fixture ed25519StrictFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatalf("parse Ed25519 strict fixture: %v", err) + } + return fixture +} + +func applyEd25519StrictMutation(t *testing.T, sig []byte, mutation string) []byte { + t.Helper() + mutated := append([]byte(nil), sig...) + switch mutation { + case "none": + return mutated + case "s_equals_l": + copy(mutated[32:], edGroupOrder[:]) + return mutated + default: + t.Fatalf("unsupported Ed25519 strict mutation %q", mutation) + return nil + } +} + type testKey struct { pub ed25519.PublicKey prv ed25519.PrivateKey @@ -748,3 +793,92 @@ func TestStrictEd25519AcceptsValidSignatures(t *testing.T) { t.Errorf("strict verifier rejected a valid canonical signature: %v", err) } } + +func TestEd25519StrictFixtureVectors(t *testing.T) { + fixture := loadEd25519StrictFixture(t) + for _, vector := range fixture.Vectors { + t.Run(vector.ID, func(t *testing.T) { + seed, err := hex.DecodeString(vector.SeedHex) + if err != nil { + t.Fatalf("decode seed: %v", err) + } + if len(seed) != ed25519.SeedSize { + t.Fatalf("seed length = %d, want %d", len(seed), ed25519.SeedSize) + } + privateKey := ed25519.NewKeyFromSeed(seed) + sig := ed25519.Sign(privateKey, []byte(vector.Message)) + sig = applyEd25519StrictMutation(t, sig, vector.Mutation) + + err = strictVerifyEd25519(sig) + if vector.Valid && err != nil { + t.Fatalf("strictVerifyEd25519 rejected valid vector: %v", err) + } + if !vector.Valid && err == nil { + t.Fatal("strictVerifyEd25519 accepted invalid vector") + } + }) + } +} + +func TestChainClassifiesNonCanonicalReceiptSignatureAsMalleability(t *testing.T) { + fixture := loadEd25519StrictFixture(t) + var vector ed25519StrictVector + for _, candidate := range fixture.Vectors { + if candidate.Mutation == "s_equals_l" { + vector = candidate + break + } + } + if vector.ID == "" { + t.Fatal("fixture missing s_equals_l vector") + } + + seed, err := hex.DecodeString(vector.SeedHex) + if err != nil { + t.Fatalf("decode seed: %v", err) + } + privateKey := ed25519.NewKeyFromSeed(seed) + publicKey := privateKey.Public().(ed25519.PublicKey) + issuer := testKey{ + pub: publicKey, + prv: privateKey, + did: "did:key:z" + base58Encode(append([]byte{0xed, 0x01}, publicKey...)), + } + leaf := newTestKey(t) + now := time.Now().Unix() + + receipt, receiptJWT := makeReceipt(issuer.did, issuer.did, leaf.did, now, nil, issuer) + malformedReceiptJWT := replaceJWTSignature(t, receiptJWT, func(sig []byte) []byte { + return applyEd25519StrictMutation(t, sig, vector.Mutation) + }) + hash := computeChainHash(malformedReceiptJWT) + invocationJWT := makeInvocation(leaf.did, issuer.did, []string{hash}, now, leaf) + + result := Chain(context.Background(), types.ChainBundle{ + BundleVersion: "4.0", + Receipts: []string{malformedReceiptJWT}, + Invocation: invocationJWT, + }, testDeps(t)) + if result.Valid { + t.Fatal("expected non-canonical receipt signature to be rejected") + } + if result.Error == nil { + t.Fatal("expected structured error") + } + if result.Error.Code != *vector.ErrorCode { + t.Fatalf("error code = %q, want %q (receipt=%s)", result.Error.Code, *vector.ErrorCode, receipt.Jti) + } +} + +func replaceJWTSignature(t *testing.T, jwt string, mutate func([]byte) []byte) string { + t.Helper() + parts := strings.Split(jwt, ".") + if len(parts) != 3 { + t.Fatalf("JWT has %d parts, want 3", len(parts)) + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + t.Fatalf("decode signature: %v", err) + } + return parts[0] + "." + parts[1] + "." + base64.RawURLEncoding.EncodeToString(mutate(sig)) +} diff --git a/fixtures/conformance/README.md b/fixtures/conformance/README.md index 540911f..7109208 100644 --- a/fixtures/conformance/README.md +++ b/fixtures/conformance/README.md @@ -14,6 +14,7 @@ conformance/ policy/fail.json Policy attenuation — invalid pairs temporal/vectors.json Temporal validity checks revocation/vectors.json Revocation status lookup + ed25519-strict/vectors.json Ed25519 canonical-signature strictness receipts/ root-delegation.json Signed root DR with known test keys sub-delegation.json Signed sub-delegation with chain linkage @@ -37,9 +38,14 @@ The generator uses `@noble/ed25519` from `drs-sdk/node_modules`. Ed25519 signing is deterministic — the same seed always produces the same signature for the same message, so regeneration produces identical output. +`ed25519-strict/vectors.json` is intentionally maintained by hand because the +tests derive real signatures from deterministic seeds and then mutate the +signature scalar `S` to exercise verifier strictness boundaries. + ## Adding New Vectors -1. Add the vector definition to `generate.mjs`. +1. Add the vector definition to `generate.mjs`, unless the suite documents a + manual exception such as `ed25519-strict`. 2. Run the generator to update the JSON files. 3. Run the conformance test suite across all three languages. 4. When there is ambiguity about expected output, the Rust implementation decides. diff --git a/fixtures/conformance/ed25519-strict/vectors.json b/fixtures/conformance/ed25519-strict/vectors.json new file mode 100644 index 0000000..53cccf2 --- /dev/null +++ b/fixtures/conformance/ed25519-strict/vectors.json @@ -0,0 +1,22 @@ +{ + "vectors": [ + { + "id": "ed25519-strict-valid-canonical", + "description": "Canonical Ed25519 signature generated from a deterministic seed must verify.", + "seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "message": "DRS Ed25519 strictness parity vector", + "mutation": "none", + "valid": true, + "error_code": null + }, + { + "id": "ed25519-strict-reject-s-equals-l", + "description": "Signature with scalar S set to the Ed25519 group order L must be rejected as signature malleability.", + "seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "message": "DRS Ed25519 strictness parity vector", + "mutation": "s_equals_l", + "valid": false, + "error_code": "SIGNATURE_MALLEABILITY" + } + ] +} diff --git a/integration-tests/tests/e2e.test.mjs b/integration-tests/tests/e2e.test.mjs index dcc1cf0..b6a9dfa 100644 --- a/integration-tests/tests/e2e.test.mjs +++ b/integration-tests/tests/e2e.test.mjs @@ -303,9 +303,19 @@ describe("Node middleware golden path", () => { assert.equal(valid.ok, true, JSON.stringify(valid)); assert.equal(executed, 1, "handler should run after verified request"); + const tamperedBundle = await createInvocationBundle({ + rootReceipt, + signingKey: agentKey, + issuerDid: agentDid, + subjectDid: operatorDid, + toolServer: "did:key:z6MkTool", + tool: "approve_payment", + args: { transaction_id: "T1" }, + }); + const tampered = await middleware( { - headers: { "x-drs-bundle": serialiseBundle(bundle) }, + headers: { "x-drs-bundle": serialiseBundle(tamperedBundle) }, body: { tool: "approve_payment", transaction_id: "T2" }, }, () => {