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
58 changes: 58 additions & 0 deletions go/canonicalize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
Expand Down Expand Up @@ -174,6 +175,7 @@ func TestBuildSignatureBindingErrors(t *testing.T) {
{"a", "b", "", "d"},
{"a", "b", "https://example.com", ""},
{"a", "b", "example.com", "d"},
{"a", "b", "ftp://example.com", "d"},
}
for _, c := range cases {
if _, err := BuildSignatureBinding(c[0], c[1], c[2], c[3]); err == nil {
Expand All @@ -182,6 +184,12 @@ func TestBuildSignatureBindingErrors(t *testing.T) {
}
}

func TestValidateSerializedOriginIPv6(t *testing.T) {
if err := ValidateSerializedOrigin("https://[2001:db8::1]:8443"); err != nil {
t.Fatalf("valid IPv6 origin rejected: %v", err)
}
}

// ----- VerifySignature -----

func encodePKIX(t *testing.T, pub any) string {
Expand Down Expand Up @@ -283,6 +291,56 @@ func TestVerifySignatureECDSA(t *testing.T) {
}
}

func TestVerifySignatureRegistryECDSA(t *testing.T) {
tests := []struct {
name string
curve elliptic.Curve
algorithm string
width int
digest func(string) []byte
}{
{"P-256", elliptic.P256(), "ecdsa-p256", 32, func(message string) []byte { sum := sha256.Sum256([]byte(message)); return sum[:] }},
{"P-384", elliptic.P384(), "ecdsa-p384", 48, func(message string) []byte { sum := sha512.Sum384([]byte(message)); return sum[:] }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
priv, err := ecdsa.GenerateKey(tc.curve, rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
message := "registry ecdsa"
r, s, err := ecdsa.Sign(rand.Reader, priv, tc.digest(message))
if err != nil {
t.Fatalf("ecdsa.Sign: %v", err)
}
sig := make([]byte, tc.width*2)
r.FillBytes(sig[:tc.width])
s.FillBytes(sig[tc.width:])
ok, err := VerifySignature(message, EncodeBase64Unpadded(sig), encodePKIX(t, &priv.PublicKey), tc.algorithm)
if err != nil || !ok {
t.Fatalf("registry signature did not verify: ok=%v err=%v", ok, err)
}
})
}
}

func TestVerifySignatureRSAPSS(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
message := "rsa pss"
digest := sha256.Sum256([]byte(message))
sig, err := rsa.SignPSS(rand.Reader, priv, crypto.SHA256, digest[:], &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash})
if err != nil {
t.Fatalf("rsa.SignPSS: %v", err)
}
ok, err := VerifySignature(message, EncodeBase64Unpadded(sig), encodePKIX(t, &priv.PublicKey), "rsa-pss-sha256")
if err != nil || !ok {
t.Fatalf("PSS signature did not verify: ok=%v err=%v", ok, err)
}
}

func TestVerifySignatureUnsupportedAlgorithm(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
Expand Down
80 changes: 67 additions & 13 deletions go/endorsement.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package canonicalize

import (
"context"
"encoding/json"
"errors"
"strings"
)

// Endorsement is a third-party signed JSON attestation about a specific
Expand All @@ -12,15 +14,40 @@ type Endorsement struct {
Endorsement string `json:"endorsement"` // the targeted content-hash, e.g. "sha256:..."
Signature string `json:"signature"`
Timestamp string `json:"timestamp"`
Algorithm string `json:"algorithm,omitempty"` // defaults to "ed25519"
Algorithm string `json:"algorithm"`
}

// BuildEndorsementBinding returns the deterministic JSON signing payload for
// an endorsement: the endorsement document serialized with signature omitted.
func BuildEndorsementBinding(endorsement Endorsement) (string, error) {
if endorsement.Endorser == "" {
return "", errors.New("BuildEndorsementBinding: endorser is required")
}
if endorsement.Endorsement == "" {
return "", errors.New("BuildEndorsementBinding: endorsement is required")
}
if endorsement.Algorithm == "" {
return "", errors.New("BuildEndorsementBinding: algorithm is required")
}
if endorsement.Timestamp == "" {
return "", errors.New("BuildEndorsementBinding: timestamp is required")
}
doc := map[string]string{
"algorithm": endorsement.Algorithm,
"endorsement": endorsement.Endorsement,
"endorser": endorsement.Endorser,
"timestamp": endorsement.Timestamp,
}
b, err := json.Marshal(doc)
if err != nil {
return "", err
}
return string(b), nil
}

// VerifyEndorsement resolves the endorser's keyid and verifies the
// endorsement's signature over the canonical binding "{endorsement}:{timestamp}".
// If the endorsement does not specify an algorithm, ed25519 is assumed. If the
// resolver chain returns a key with its own declared algorithm, that takes
// precedence over the endorsement's hint (the resolved key is the source of
// truth about what the signer actually uses).
// endorsement's signature over the deterministic JSON document with the
// signature field omitted.
func VerifyEndorsement(ctx context.Context, endorsement Endorsement, resolvers []KeyResolver) (bool, error) {
if endorsement.Endorser == "" {
return false, errors.New("VerifyEndorsement: endorser is required")
Expand All @@ -38,13 +65,40 @@ func VerifyEndorsement(ctx context.Context, endorsement Endorsement, resolvers [
if err != nil {
return false, err
}
algorithm := key.Algorithm
if algorithm == "" {
algorithm = endorsement.Algorithm
if key.Algorithm != "" && !algorithmsCompatible(key.Algorithm, endorsement.Algorithm) {
return false, errors.New("VerifyEndorsement: resolved key algorithm does not match endorsement")
}
message, err := BuildEndorsementBinding(endorsement)
if err != nil {
return false, err
}
return VerifySignature(message, endorsement.Signature, key.PublicKeyPEM, endorsement.Algorithm)
}

func algorithmFamily(algorithm string) string {
algorithm = strings.ToLower(algorithm)
if strings.HasPrefix(algorithm, "ecdsa") {
return "ecdsa"
}
if strings.HasPrefix(algorithm, "rsa") {
return "rsa"
}
return algorithm
}

func algorithmsCompatible(resolved, declared string) bool {
resolved = strings.ToLower(resolved)
declared = strings.ToLower(declared)
if resolved == declared {
return true
}
resolvedFamily := algorithmFamily(resolved)
declaredFamily := algorithmFamily(declared)
if resolvedFamily != declaredFamily {
return false
}
if algorithm == "" {
algorithm = "ed25519"
if resolved == resolvedFamily || declared == declaredFamily {
return true
}
message := endorsement.Endorsement + ":" + endorsement.Timestamp
return VerifySignature(message, endorsement.Signature, key.PublicKeyPEM, algorithm)
return resolvedFamily == "rsa"
}
111 changes: 103 additions & 8 deletions go/signature.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import (
"crypto/ed25519"
"crypto/rsa"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net"
"net/url"
"strings"
)

Expand All @@ -32,23 +35,77 @@ func BuildSignatureBinding(contentHash, claimsHash, domain, signedAt string) (st
if domain == "" {
return "", errors.New("BuildSignatureBinding: domain is required")
}
if err := ValidateSerializedOrigin(domain); err != nil {
return "", err
}
if signedAt == "" {
return "", errors.New("BuildSignatureBinding: signedAt is required")
}
return contentHash + ":" + claimsHash + ":" + domain + ":" + signedAt, nil
}

// ValidateSerializedOrigin checks that the legacy-named "domain" field carries
// a canonical serialized Web origin: scheme://host[:port], with no path,
// query, fragment, or credentials.
func ValidateSerializedOrigin(origin string) error {
u, err := url.Parse(origin)
if err != nil || u.Scheme == "" || u.Host == "" {
return errors.New("domain must be a serialized Web origin")
}
if u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" {
return errors.New("domain must be a serialized Web origin")
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return errors.New("domain must use the http or https scheme")
}
host := strings.ToLower(u.Hostname())
if host == "" {
return errors.New("domain must be a serialized Web origin")
}
serializedHost := host
if strings.Contains(host, ":") {
serializedHost = "[" + host + "]"
}
canonical := scheme + "://" + serializedHost
if port := u.Port(); port != "" {
if !((scheme == "http" && port == "80") || (scheme == "https" && port == "443")) {
canonical = scheme + "://" + net.JoinHostPort(host, port)
}
}
if canonical != origin {
return fmt.Errorf("domain must use canonical serialized origin form: %s", canonical)
}
return nil
}

// ecdsaSig is the ASN.1 wire encoding for an ECDSA signature.
type ecdsaSig struct {
R, S *big.Int
}

// decodeBase64 accepts both standard padded and unpadded base64.
func decodeBase64(s string) ([]byte, error) {
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
return b, nil
// EncodeBase64Unpadded emits canonical unpadded standard Base64.
func EncodeBase64Unpadded(b []byte) string {
return base64.RawStdEncoding.EncodeToString(b)
}

// DecodeCanonicalBase64 decodes canonical unpadded standard Base64 and rejects
// padded, whitespace-containing, or base64url forms.
func DecodeCanonicalBase64(s string) ([]byte, error) {
if s == "" {
return []byte{}, nil
}
if strings.ContainsAny(s, "=\r\n\t -_") || len(s)%4 == 1 {
return nil, errors.New("non-canonical base64")
}
b, err := base64.RawStdEncoding.DecodeString(s)
if err != nil {
return nil, err
}
if EncodeBase64Unpadded(b) != s {
return nil, errors.New("non-canonical base64")
}
return base64.RawStdEncoding.DecodeString(s)
return b, nil
}

// parsePublicKey decodes a PEM-wrapped PKIX public key.
Expand All @@ -62,10 +119,10 @@ func parsePublicKey(pemStr string) (any, error) {

// VerifySignature verifies a base64-encoded signature over the given message
// using the supplied PEM-encoded public key. Algorithm matching is
// case-insensitive and supports "ed25519", "ecdsa" (with SHA-256), and "rsa"
// (PKCS1v15 with SHA-256).
// case-insensitive and supports the registry algorithms plus the legacy
// generic "ecdsa" and "rsa" spellings.
func VerifySignature(message string, signatureB64 string, publicKeyPEM string, algorithm string) (bool, error) {
sig, err := decodeBase64(signatureB64)
sig, err := DecodeCanonicalBase64(signatureB64)
if err != nil {
return false, fmt.Errorf("VerifySignature: decode signature: %w", err)
}
Expand Down Expand Up @@ -94,7 +151,33 @@ func VerifySignature(message string, signatureB64 string, publicKeyPEM string, a
}
return ecdsa.Verify(key, digest[:], parsed.R, parsed.S), nil

case "ecdsa-p256", "ecdsa-p384":
key, ok := pub.(*ecdsa.PublicKey)
if !ok {
return false, errors.New("VerifySignature: public key is not ecdsa")
}
componentBytes := 32
expectedCurve := "P-256"
var digest []byte
if strings.EqualFold(algorithm, "ecdsa-p384") {
componentBytes = 48
expectedCurve = "P-384"
sum := sha512.Sum384([]byte(message))
digest = sum[:]
} else {
sum := sha256.Sum256([]byte(message))
digest = sum[:]
}
if key.Curve.Params().Name != expectedCurve || len(sig) != 2*componentBytes {
return false, nil
}
r := new(big.Int).SetBytes(sig[:componentBytes])
s := new(big.Int).SetBytes(sig[componentBytes:])
return ecdsa.Verify(key, digest, r, s), nil

case "rsa":
fallthrough
case "rsa-pkcs1-sha256":
key, ok := pub.(*rsa.PublicKey)
if !ok {
return false, errors.New("VerifySignature: public key is not rsa")
Expand All @@ -105,6 +188,18 @@ func VerifySignature(message string, signatureB64 string, publicKeyPEM string, a
}
return true, nil

case "rsa-pss-sha256":
key, ok := pub.(*rsa.PublicKey)
if !ok {
return false, errors.New("VerifySignature: public key is not rsa")
}
digest := sha256.Sum256([]byte(message))
opts := &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA256}
if err := rsa.VerifyPSS(key, crypto.SHA256, digest[:], sig, opts); err != nil {
return false, nil
}
return true, nil

default:
return false, fmt.Errorf("VerifySignature: unsupported algorithm %q", algorithm)
}
Expand Down
Loading
Loading