Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/explanation/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/explanation/verification-algorithm.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
15 changes: 15 additions & 0 deletions docs/drs-source-of-truth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 61 additions & 0 deletions drs-core/tests/test_ed25519.rs
Original file line number Diff line number Diff line change
@@ -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<Ed25519StrictVector>,
}

#[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.
Expand Down Expand Up @@ -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()
);
}
}
4 changes: 2 additions & 2 deletions drs-verify/Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion drs-verify/go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
35 changes: 23 additions & 12 deletions drs-verify/pkg/verify/chain.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
Expand All @@ -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"
Expand Down Expand Up @@ -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
}

Expand All @@ -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))
Expand All @@ -539,21 +546,25 @@ 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
}
// 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") ||
Expand Down
Loading
Loading