merge: nras-jws-alg-keytype-2026-04-28

This commit is contained in:
2025-12-28 19:25:00 -08:00
2 changed files with 284 additions and 64 deletions
+106 -64
View File
@@ -29,13 +29,19 @@ import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"time"
@@ -52,6 +58,13 @@ var (
ErrNRASTokenExpired = errors.New("nvidia/nras: token already expired")
ErrNRASBadJWT = errors.New("nvidia/nras: token not a valid JWS compact serialization")
ErrNRASBadSignature = errors.New("nvidia/nras: token signature verification failed")
// Algorithm-substitution defenses (Red audit F3 / CVE-2015-9235 family).
// verifyJWS enforces a strict alg ↔ key-type binding at the dispatch
// layer; mismatches are rejected before any verification primitive runs.
ErrAlgKeyTypeMismatch = errors.New("nvidia/nras: jws alg does not match trust-root key type")
ErrAlgNoneRefused = errors.New("nvidia/nras: jws alg=none refused")
ErrUnknownAlg = errors.New("nvidia/nras: jws alg unknown")
)
// NRASClient posts GPU evidence to NRAS and returns a verified
@@ -292,82 +305,111 @@ func splitJWS(token string) (hdr, payload, sig, signedInput []byte, err error) {
return hdr, payload, sig, signedInput, nil
}
// verifyJWS verifies signedInput vs sig under pub. alg must match the
// public key type to prevent algorithm-substitution attacks (the
// classic CVE-2015-9235 family).
// verifyJWS verifies signedInput vs sig under pub. The dispatch is
// driven by `alg` from the JWS header AND simultaneously enforces that
// the public-key type matches `alg` — this is the algorithm-substitution
// defense (Red audit F3 / CVE-2015-9235 family). A token forged with
// header alg=EdDSA against a trust-root holding an *rsa.PublicKey for
// the same kid is rejected here, before any verification primitive runs.
//
// For ECDSA the curve is also asserted against the alg (ES256→P-256,
// ES384→P-384). alg=none (any case) is refused unconditionally;
// unknown algs are refused.
//
// The companion verifySignature(pub, msg, sig) in rim.go is intentionally
// retained for the RIM path: detached RIM signatures carry no alg envelope,
// so dispatch by key-type is correct there. This split (JWS = alg-driven,
// RIM = key-type-driven) is the only correct factoring; merging them
// re-introduces the substitution attack.
func verifyJWS(pub crypto.PublicKey, alg string, signedInput, sig []byte) error {
switch alg {
case "EdDSA":
// reuse the RIM signature path for ed25519
return verifySignature(pub, signedInput, sig)
case "ES256", "ES384":
// JOSE encodes ECDSA signatures as fixed-width r||s, NOT ASN.1.
// Convert before delegating to the RIM path.
der, err := joseECDSAToASN1(sig)
if err != nil {
return fmt.Errorf("ecdsa jose decode: %w", err)
pubED, ok := pub.(ed25519.PublicKey)
if !ok {
return fmt.Errorf("%w: alg=EdDSA pub=%T", ErrAlgKeyTypeMismatch, pub)
}
return verifySignature(pub, signedInput, der)
if !ed25519.Verify(pubED, signedInput, sig) {
return errors.New("ed25519 verify failed")
}
return nil
case "ES256":
pubEC, ok := pub.(*ecdsa.PublicKey)
if !ok {
return fmt.Errorf("%w: alg=ES256 pub=%T", ErrAlgKeyTypeMismatch, pub)
}
if pubEC.Curve != elliptic.P256() {
return fmt.Errorf("%w: alg=ES256 curve=%s (want P-256)", ErrAlgKeyTypeMismatch, pubEC.Curve.Params().Name)
}
digest := sha256.Sum256(signedInput)
return ecdsaVerifyJOSE(pubEC, digest[:], sig)
case "ES384":
pubEC, ok := pub.(*ecdsa.PublicKey)
if !ok {
return fmt.Errorf("%w: alg=ES384 pub=%T", ErrAlgKeyTypeMismatch, pub)
}
if pubEC.Curve != elliptic.P384() {
return fmt.Errorf("%w: alg=ES384 curve=%s (want P-384)", ErrAlgKeyTypeMismatch, pubEC.Curve.Params().Name)
}
digest := sha512.Sum384(signedInput)
return ecdsaVerifyJOSE(pubEC, digest[:], sig)
case "PS256":
return verifySignature(pub, signedInput, sig)
pubRSA, ok := pub.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("%w: alg=PS256 pub=%T", ErrAlgKeyTypeMismatch, pub)
}
digest := sha256.Sum256(signedInput)
if err := rsa.VerifyPSS(pubRSA, crypto.SHA256, digest[:], sig, &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
Hash: crypto.SHA256,
}); err != nil {
return fmt.Errorf("rsa-pss verify: %w", err)
}
return nil
case "PS384":
pubRSA, ok := pub.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("%w: alg=PS384 pub=%T", ErrAlgKeyTypeMismatch, pub)
}
digest := sha512.Sum384(signedInput)
if err := rsa.VerifyPSS(pubRSA, crypto.SHA384, digest[:], sig, &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
Hash: crypto.SHA384,
}); err != nil {
return fmt.Errorf("rsa-pss verify: %w", err)
}
return nil
case "none", "None", "NONE", "":
return ErrAlgNoneRefused
default:
return fmt.Errorf("unsupported alg %q", alg)
return fmt.Errorf("%w: %q", ErrUnknownAlg, alg)
}
}
// joseECDSAToASN1 converts a fixed-width JOSE ECDSA signature
// (r||s, big-endian, equal length) into ASN.1 DER. We handle this
// here so the RIM verifier (ASN.1 input) is reusable.
func joseECDSAToASN1(jose []byte) ([]byte, error) {
if len(jose) < 64 || len(jose)%2 != 0 {
return nil, fmt.Errorf("jose ecdsa: bad length %d", len(jose))
// ecdsaVerifyJOSE verifies a JOSE-flavored ECDSA signature (fixed-width
// r||s, big-endian, equal length — RFC 7515 §3.4) over the supplied
// pre-hashed digest. The hash algorithm is the caller's responsibility
// and must match the JWS alg.
func ecdsaVerifyJOSE(pub *ecdsa.PublicKey, digest, sig []byte) error {
if len(sig) == 0 || len(sig)%2 != 0 {
return fmt.Errorf("ecdsa jose: bad signature length %d", len(sig))
}
half := len(jose) / 2
r := trimLeadingZeroes(jose[:half])
s := trimLeadingZeroes(jose[half:])
// ASN.1 INTEGER must be unsigned; prepend 0x00 if MSB is set.
rEnc := asn1Integer(r)
sEnc := asn1Integer(s)
body := append(rEnc, sEnc...)
// SEQUENCE { r, s }
out := []byte{0x30}
out = appendASN1Length(out, len(body))
return append(out, body...), nil
}
func trimLeadingZeroes(b []byte) []byte {
i := 0
for i < len(b)-1 && b[i] == 0 {
i++
half := len(sig) / 2
// JOSE width must equal the curve's coordinate size.
if want := (pub.Curve.Params().BitSize + 7) / 8; half != want {
return fmt.Errorf("ecdsa jose: signature half=%d want=%d for %s", half, want, pub.Curve.Params().Name)
}
return b[i:]
}
func asn1Integer(b []byte) []byte {
if len(b) > 0 && b[0]&0x80 != 0 {
b = append([]byte{0x00}, b...)
r := new(big.Int).SetBytes(sig[:half])
s := new(big.Int).SetBytes(sig[half:])
if !ecdsa.Verify(pub, digest, r, s) {
return errors.New("ecdsa verify failed")
}
out := []byte{0x02}
out = appendASN1Length(out, len(b))
return append(out, b...)
}
func appendASN1Length(dst []byte, n int) []byte {
if n < 0x80 {
return append(dst, byte(n))
}
// Long form. We support up to 2-byte lengths (>64KB JWS bodies are
// not realistic).
if n < 0x100 {
return append(dst, 0x81, byte(n))
}
if n < 0x10000 {
return append(dst, 0x82, byte(n>>8), byte(n))
}
// Truncate at 65535 — JWS payloads larger than this fail upstream.
return append(dst, 0x82, 0xff, 0xff)
return nil
}
func sha256BytesEqual(a, b []byte) bool {
+178
View File
@@ -0,0 +1,178 @@
// Algorithm-substitution defense tests for verifyJWS (Red audit F3 /
// CVE-2015-9235 family). The eight cases below pin down the alg ↔
// key-type matrix at the dispatch layer:
//
// 1. EdDSA + ed25519.PublicKey → PASS
// 2. ES256 + *ecdsa.PublicKey on P-256 → PASS
// 3. PS256 + *rsa.PublicKey → PASS
// 4. EdDSA paired with RSA pub → ErrAlgKeyTypeMismatch
// 5. ES256 paired with ed25519 pub → ErrAlgKeyTypeMismatch
// 6. ES256 paired with ecdsa P-384 → ErrAlgKeyTypeMismatch
// 7. alg=none → ErrAlgNoneRefused
// 8. alg=Unknown → ErrUnknownAlg
//
// Cases 4-6 are the substitution attack: a forged JWS header that
// names a different alg than the trust-root's key actually supports.
// Pre-fix, dispatch ran by alg but verification ran by key type, so
// an attacker who could choose the alg got to pick which primitive
// ran against which key. The test for case 4 is the canonical
// reproducer in the audit narrative.
package nvidia
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"errors"
"math/big"
"testing"
)
func TestVerifyJWS_EdDSA_Valid(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
signedInput := []byte("eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJ0ZXN0In0")
sig := ed25519.Sign(priv, signedInput)
if err := verifyJWS(pub, "EdDSA", signedInput, sig); err != nil {
t.Fatalf("expected PASS, got %v", err)
}
}
func TestVerifyJWS_ES256_Valid(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
signedInput := []byte("eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0")
digest := sha256.Sum256(signedInput)
r, s, err := ecdsa.Sign(rand.Reader, priv, digest[:])
if err != nil {
t.Fatalf("sign: %v", err)
}
sig := joseConcat(r, s, 32) // P-256 coord size = 32 bytes
if err := verifyJWS(&priv.PublicKey, "ES256", signedInput, sig); err != nil {
t.Fatalf("expected PASS, got %v", err)
}
}
func TestVerifyJWS_PS256_Valid(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
signedInput := []byte("eyJhbGciOiJQUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0")
digest := sha256.Sum256(signedInput)
sig, err := rsa.SignPSS(rand.Reader, priv, crypto.SHA256, digest[:], &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
Hash: crypto.SHA256,
})
if err != nil {
t.Fatalf("sign: %v", err)
}
if err := verifyJWS(&priv.PublicKey, "PS256", signedInput, sig); err != nil {
t.Fatalf("expected PASS, got %v", err)
}
}
// === Algorithm-substitution attacks ===
// alg=EdDSA paired with an RSA trust-root for the same kid. The forged
// header tells the verifier "this is an Ed25519 signature"; the trust
// root holds an *rsa.PublicKey. Pre-fix, verifySignature dispatched by
// key type and ran RSA-PSS on the attacker-chosen signedInput. Post-fix,
// the alg=EdDSA branch type-asserts ed25519.PublicKey and refuses.
func TestVerifyJWS_EdDSA_AgainstRSAPub_Rejected(t *testing.T) {
rsaPriv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
signedInput := []byte("attacker-chosen")
// Attacker can produce *anything* for sig — verifier must reject
// before looking at sig at all.
sig := []byte{0xAA, 0xBB, 0xCC}
err = verifyJWS(&rsaPriv.PublicKey, "EdDSA", signedInput, sig)
if !errors.Is(err, ErrAlgKeyTypeMismatch) {
t.Fatalf("expected ErrAlgKeyTypeMismatch, got %v", err)
}
}
// alg=ES256 paired with ed25519 pub.
func TestVerifyJWS_ES256_AgainstEd25519Pub_Rejected(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
err = verifyJWS(pub, "ES256", []byte("input"), []byte{0x00})
if !errors.Is(err, ErrAlgKeyTypeMismatch) {
t.Fatalf("expected ErrAlgKeyTypeMismatch, got %v", err)
}
}
// alg=ES256 paired with ecdsa P-384 pub. Curve mismatch — even though
// both are *ecdsa.PublicKey, ES256 is bound to P-256 only.
func TestVerifyJWS_ES256_AgainstP384Pub_Rejected(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
// Construct a real ES384-shaped signature so the failure is
// proven to come from the curve check, not from a malformed sig.
signedInput := []byte("input")
digest := sha512.Sum384(signedInput)
r, s, err := ecdsa.Sign(rand.Reader, priv, digest[:])
if err != nil {
t.Fatalf("sign: %v", err)
}
sig := joseConcat(r, s, 48) // P-384 coord size = 48 bytes
err = verifyJWS(&priv.PublicKey, "ES256", signedInput, sig)
if !errors.Is(err, ErrAlgKeyTypeMismatch) {
t.Fatalf("expected ErrAlgKeyTypeMismatch (curve), got %v", err)
}
}
// alg=none must be refused unconditionally regardless of trust-root key.
func TestVerifyJWS_AlgNone_Refused(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
for _, alg := range []string{"none", "None", "NONE", ""} {
err := verifyJWS(pub, alg, []byte("input"), []byte("sig"))
if !errors.Is(err, ErrAlgNoneRefused) {
t.Fatalf("alg=%q: expected ErrAlgNoneRefused, got %v", alg, err)
}
}
}
// Unknown alg must be refused.
func TestVerifyJWS_UnknownAlg_Refused(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
err = verifyJWS(pub, "HS256", []byte("input"), []byte("sig"))
if !errors.Is(err, ErrUnknownAlg) {
t.Fatalf("expected ErrUnknownAlg, got %v", err)
}
}
// === helpers ===
// joseConcat encodes (r, s) as fixed-width big-endian r||s (RFC 7515 §3.4).
func joseConcat(r, s *big.Int, size int) []byte {
out := make([]byte, size*2)
rB := r.Bytes()
sB := s.Bytes()
copy(out[size-len(rB):size], rB)
copy(out[size*2-len(sB):], sB)
return out
}