diff --git a/go/canonicalize_test.go b/go/canonicalize_test.go index e2c4d94..a6b0256 100644 --- a/go/canonicalize_test.go +++ b/go/canonicalize_test.go @@ -9,6 +9,7 @@ import ( "crypto/rand" "crypto/rsa" "crypto/sha256" + "crypto/sha512" "crypto/x509" "encoding/asn1" "encoding/base64" @@ -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 { @@ -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 { @@ -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 { diff --git a/go/endorsement.go b/go/endorsement.go index 43b2953..5d53ed0 100644 --- a/go/endorsement.go +++ b/go/endorsement.go @@ -2,7 +2,9 @@ package canonicalize import ( "context" + "encoding/json" "errors" + "strings" ) // Endorsement is a third-party signed JSON attestation about a specific @@ -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") @@ -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" } diff --git a/go/signature.go b/go/signature.go index 32e129e..39e2f4d 100644 --- a/go/signature.go +++ b/go/signature.go @@ -6,6 +6,7 @@ import ( "crypto/ed25519" "crypto/rsa" "crypto/sha256" + "crypto/sha512" "crypto/x509" "encoding/asn1" "encoding/base64" @@ -13,6 +14,8 @@ import ( "errors" "fmt" "math/big" + "net" + "net/url" "strings" ) @@ -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. @@ -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) } @@ -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") @@ -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) } diff --git a/javascript/index.js b/javascript/index.js index aacc3b8..d5556d3 100644 --- a/javascript/index.js +++ b/javascript/index.js @@ -604,7 +604,12 @@ export async function verifySignature(message, signatureB64, publicKeyPem, algor if (keyType !== "ec") return false; const curve = String(publicKey.asymmetricKeyDetails?.namedCurve || "").toLowerCase(); if (!EC_CURVES[algo].includes(curve)) return false; - return node.verify(EC_PARAMS[algo].nodeHash, msg, publicKey, sig); + return node.verify( + EC_PARAMS[algo].nodeHash, + msg, + { key: publicKey, dsaEncoding: "ieee-p1363" }, + sig, + ); } if (algo === "rsa" || algo === "rsa-pkcs1-sha256") { if (keyType !== "rsa") return false; @@ -670,6 +675,29 @@ function pemToBytes(pem) { return base64ToBytesFlexible(body); } +function spkiBase64ToPem(value) { + const bytes = decodeCanonicalBase64(value); + const encoded = encodeBase64Unpadded(bytes); + const padded = encoded + "===".slice((encoded.length + 3) % 4); + const lines = padded.match(/.{1,64}/g) || []; + return `-----BEGIN PUBLIC KEY-----\n${lines.join("\n")}\n-----END PUBLIC KEY-----\n`; +} + +function pemFromKeyDocument(document) { + if (!document || typeof document !== "object") return null; + if (typeof document.publicKeyPem === "string" && document.publicKeyPem.includes("BEGIN PUBLIC KEY")) { + return document.publicKeyPem; + } + if (typeof document.publicKey === "string") { + if (document.publicKey.includes("BEGIN PUBLIC KEY")) return document.publicKey; + if (document.publicKeyEncoding === "spki-der") return spkiBase64ToPem(document.publicKey); + } + if (typeof document.key === "string" && document.key.includes("BEGIN PUBLIC KEY")) { + return document.key; + } + return null; +} + // === Keyid resolution (spec §2.2) === // // Three pluggable resolvers. None is privileged; callers compose them in @@ -723,7 +751,8 @@ async function fetchJson(url, fetchImpl) { const res = await f(url); if (!res.ok) return null; const ct = res.headers.get?.("content-type") ?? ""; - if (ct.includes("application/json")) return await res.json(); + const mediaType = ct.split(";", 1)[0].trim().toLowerCase(); + if (mediaType === "application/json" || mediaType.endsWith("+json")) return await res.json(); // Treat as raw PEM if content-type is text-ish return { _rawText: await res.text() }; } @@ -792,7 +821,7 @@ export function directUrlResolver(opts = {}) { if (data._rawText) { return { keyid, publicKeyPem: data._rawText.trim(), algorithm: "ed25519" }; } - const pem = data.publicKey || data.publicKeyPem || data.key; + const pem = pemFromKeyDocument(data); if (!pem) return null; return { keyid, @@ -827,7 +856,7 @@ export function trustDirectoryResolver(opts) { if (data._rawText) { return { keyid, publicKeyPem: data._rawText.trim(), algorithm: "ed25519" }; } - const pem = data.publicKey || data.publicKeyPem || data.key; + const pem = pemFromKeyDocument(data); if (!pem) continue; return { keyid, diff --git a/javascript/test.js b/javascript/test.js index 04f40aa..91d8936 100644 --- a/javascript/test.js +++ b/javascript/test.js @@ -218,7 +218,7 @@ function signEcdsa(privateKey, message, hash) { const { createSign } = nodeCrypto; const signer = createSign(hash); signer.update(message); - return signer.sign(privateKey, 'base64').replace(/=+$/, ''); + return signer.sign({ key: privateKey, dsaEncoding: 'ieee-p1363' }, 'base64').replace(/=+$/, ''); } await check('verifySignature ecdsa-p256 round-trip', async () => { @@ -243,6 +243,15 @@ await check('verifySignature pins the ECDSA curve to the declared algorithm', as assert(!ok, 'a P-384 key must not satisfy an ecdsa-p256 signature'); }); +await check('verifySignature rejects DER ECDSA for registry algorithms', async () => { + const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const pem = publicKey.export({ type: 'spki', format: 'pem' }); + const signer = nodeCrypto.createSign('SHA256'); + signer.update('wire format'); + const der = signer.sign(privateKey, 'base64').replace(/=+$/, ''); + assert(!(await verifySignature('wire format', der, pem, 'ecdsa-p256')), 'DER must fail'); +}); + await check('verifySignature rsa-pss-sha256 round-trip', async () => { const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); const pem = publicKey.export({ type: 'spki', format: 'pem' }); @@ -318,7 +327,7 @@ function startFixtureServer(routes) { }); } -const { publicKey: edPub } = generateKeyPairSync('ed25519'); +const { publicKey: edPub, privateKey: edPriv } = generateKeyPairSync('ed25519'); const edPubPem = edPub.export({ type: 'spki', format: 'pem' }); const fixtureServer = await startFixtureServer({ @@ -331,6 +340,10 @@ const fixtureServer = await startFixtureServer({ }, }), '/key.json': () => ({ body: { publicKey: edPubPem, algorithm: 'ed25519' } }), + '/key.vendor-json': () => ({ + headers: { 'content-type': 'application/htmltrust-key+json; charset=utf-8' }, + body: { publicKey: edPubPem, algorithm: 'ed25519' }, + }), '/keys/abc123': () => ({ body: { publicKey: edPubPem, algorithm: 'ed25519' } }), }); const port = fixtureServer.address().port; @@ -363,6 +376,31 @@ await check('directUrlResolver fetches http URL keyid', async () => { assertEq(resolved.algorithm, 'ed25519'); }); +await check('directUrlResolver accepts vendor JSON media types', async () => { + const resolved = await resolveKey(`${base}/key.vendor-json`, [directUrlResolver()]); + assert(resolved, 'expected resolution'); + assertEq(resolved.algorithm, 'ed25519'); + assert(resolved.publicKeyPem.includes('BEGIN PUBLIC KEY'), 'expected parsed key document'); +}); + +await check('directUrlResolver decodes canonical SPKI key documents', async () => { + const der = edPub.export({ type: 'spki', format: 'der' }); + const encoded = der.toString('base64').replace(/=+$/, ''); + const localFixture = await startFixtureServer({ + '/key.json': () => ({ + body: { publicKey: encoded, publicKeyEncoding: 'spki-der', algorithm: 'ed25519' }, + }), + }); + const localPort = localFixture.address().port; + const resolved = await resolveKey(`http://127.0.0.1:${localPort}/key.json`, [directUrlResolver()]); + await new Promise((r) => localFixture.close(r)); + assert(resolved?.publicKeyPem.includes('BEGIN PUBLIC KEY'), 'expected decoded PEM'); + assert( + await verifySignature('resolver-key', encodeBase64Unpadded(nodeSign(null, Buffer.from('resolver-key'), edPriv)), resolved.publicKeyPem, 'ed25519'), + 'decoded key should verify', + ); +}); + await check('trustDirectoryResolver tries each base', async () => { const resolver = trustDirectoryResolver({ baseUrls: ['http://127.0.0.1:1', base] }); const resolved = await resolver.resolve('abc123'); diff --git a/package.json b/package.json index cde6ce4..eab4154 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@htmltrust/canonicalization", - "version": "0.1.0", - "description": "HTMLTrust canonical text normalization \u2014 zero dependencies, browser + Node.js", + "version": "0.2.1", + "description": "HTMLTrust canonical text normalization, signature verification, and key resolution for browsers and Node.js", "type": "module", "main": "javascript/index.js", "exports": { diff --git a/php/src/Signature.php b/php/src/Signature.php index b2ba300..070b1e2 100644 --- a/php/src/Signature.php +++ b/php/src/Signature.php @@ -44,6 +44,7 @@ public static function buildSignatureBinding( if ($domain === '') { throw new InvalidArgumentException('domain must be non-empty'); } + self::validateSerializedOrigin($domain); if ($signedAt === '') { throw new InvalidArgumentException('signedAt must be non-empty'); } @@ -52,23 +53,75 @@ public static function buildSignatureBinding( } /** - * Build the canonical endorsement-binding string per spec §2.5: + * Validate the legacy-named domain field as a serialized Web origin. * - * {endorsement}:{timestamp} - * - * Both fields are required. + * @throws InvalidArgumentException + */ + public static function validateSerializedOrigin(string $origin): string + { + $parts = parse_url($origin); + if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) { + throw new InvalidArgumentException('domain must be a serialized Web origin'); + } + foreach (['user', 'pass', 'path', 'query', 'fragment'] as $forbidden) { + if (array_key_exists($forbidden, $parts)) { + throw new InvalidArgumentException('domain must be a serialized Web origin'); + } + } + $scheme = strtolower((string) $parts['scheme']); + if ($scheme !== 'http' && $scheme !== 'https') { + throw new InvalidArgumentException('domain must use the http or https scheme'); + } + $host = strtolower(trim((string) $parts['host'], '[]')); + $serializedHost = strpos($host, ':') !== false ? '[' . $host . ']' : $host; + $canonical = $scheme . '://' . $serializedHost; + if (isset($parts['port'])) { + $port = (int) $parts['port']; + if (!(($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443))) { + $canonical = $scheme . '://' . $serializedHost . ':' . $port; + } + } + if ($canonical !== $origin) { + throw new InvalidArgumentException('domain must use canonical serialized origin form: ' . $canonical); + } + return $origin; + } + + /** + * Build the canonical endorsement signing payload: deterministic JSON + * with object keys sorted and the signature field omitted. * * @throws InvalidArgumentException */ - public static function buildEndorsementBinding(string $endorsement, string $timestamp): string + public static function buildEndorsementBinding($endorsement, ?string $timestamp = null): string { + if (is_array($endorsement)) { + return self::canonicalizeEndorsementDocument($endorsement); + } if ($endorsement === '') { throw new InvalidArgumentException('endorsement must be non-empty'); } - if ($timestamp === '') { + if ($timestamp === null || $timestamp === '') { throw new InvalidArgumentException('timestamp must be non-empty'); } - return $endorsement . ':' . $timestamp; + return self::canonicalJson([ + 'endorsement' => $endorsement, + 'timestamp' => $timestamp, + ]); + } + + /** + * @param array $endorsement + */ + public static function canonicalizeEndorsementDocument(array $endorsement): string + { + unset($endorsement['signature']); + foreach (['endorser', 'endorsement', 'algorithm', 'timestamp'] as $required) { + if (!isset($endorsement[$required]) || !is_string($endorsement[$required]) || $endorsement[$required] === '') { + throw new InvalidArgumentException("endorsement {$required} must be non-empty"); + } + } + return self::canonicalJson($endorsement); } /** @@ -81,9 +134,7 @@ public static function buildEndorsementBinding(string $endorsement, string $time * - "ecdsa": uses openssl_verify with OPENSSL_ALGO_SHA256. * - "rsa": uses openssl_verify with OPENSSL_ALGO_SHA256. * - * The signature is accepted as either standard (padded) or unpadded - * Base64. Per the spec the wire format is unpadded Base64, but - * permitting padded input keeps things tolerant of well-meaning callers. + * The signature must be canonical unpadded standard Base64. * * @throws InvalidArgumentException for unknown algorithms or malformed * inputs that prevent a meaningful verify attempt. @@ -96,7 +147,7 @@ public static function verifySignature( ): bool { $algo = strtolower(trim($algorithm)); - $signature = self::base64DecodeFlexible($signatureB64); + $signature = self::base64DecodeCanonical($signatureB64); if ($signature === null) { return false; } @@ -106,8 +157,19 @@ public static function verifySignature( return self::verifyEd25519($message, $signature, $publicKeyPem); case 'ecdsa': + return self::verifyOpenssl($message, $signature, $publicKeyPem, OPENSSL_ALGO_SHA256, OPENSSL_KEYTYPE_EC); + case 'rsa': - return self::verifyOpenssl($message, $signature, $publicKeyPem); + return self::verifyOpenssl($message, $signature, $publicKeyPem, OPENSSL_ALGO_SHA256, OPENSSL_KEYTYPE_RSA); + + case 'ecdsa-p256': + return self::verifyEcdsaP1363($message, $signature, $publicKeyPem, 'prime256v1', OPENSSL_ALGO_SHA256, 32); + + case 'ecdsa-p384': + return self::verifyEcdsaP1363($message, $signature, $publicKeyPem, 'secp384r1', OPENSSL_ALGO_SHA384, 48); + + case 'rsa-pkcs1-sha256': + return self::verifyOpenssl($message, $signature, $publicKeyPem, OPENSSL_ALGO_SHA256, OPENSSL_KEYTYPE_RSA); default: throw new InvalidArgumentException("unsupported signature algorithm: {$algorithm}"); @@ -122,44 +184,38 @@ public static function verifySignature( * - "endorsement": the targeted content-hash (signed payload) * - "signature": Base64 signature * - "timestamp": ISO-8601 timestamp - * - "algorithm": optional, default "ed25519" + * - "algorithm": signature algorithm identifier * * Returns true iff the endorser's resolved key validates the signature - * over `{endorsement}:{timestamp}`. + * over the deterministic JSON document with `signature` omitted. * * @param array $endorsement * @param array $resolvers */ public static function verifyEndorsement(array $endorsement, array $resolvers): bool { - foreach (['endorser', 'endorsement', 'signature', 'timestamp'] as $required) { + foreach (['endorser', 'endorsement', 'signature', 'timestamp', 'algorithm'] as $required) { if (!isset($endorsement[$required]) || !is_string($endorsement[$required]) || $endorsement[$required] === '') { return false; } } $endorser = $endorsement['endorser']; - $payload = $endorsement['endorsement']; $signature = $endorsement['signature']; - $timestamp = $endorsement['timestamp']; - $algoOnWire = isset($endorsement['algorithm']) && is_string($endorsement['algorithm']) && $endorsement['algorithm'] !== '' - ? $endorsement['algorithm'] - : 'ed25519'; + $algoOnWire = $endorsement['algorithm']; $resolved = KeyResolution::resolveKey($endorser, $resolvers); if ($resolved === null) { return false; } - // Prefer the algorithm declared in the endorsement; fall back to the - // resolved key's hint if the endorsement omitted it. This mirrors - // the JS reference, where the wire format wins. - $algorithm = $algoOnWire; - - $message = self::buildEndorsementBinding($payload, $timestamp); + if (!self::algorithmsCompatible($resolved->algorithm, $algoOnWire)) { + return false; + } try { - return self::verifySignature($message, $signature, $resolved->publicKeyPem, $algorithm); + $message = self::canonicalizeEndorsementDocument($endorsement); + return self::verifySignature($message, $signature, $resolved->publicKeyPem, $algoOnWire); } catch (InvalidArgumentException $e) { return false; } @@ -170,28 +226,75 @@ public static function verifyEndorsement(array $endorsement, array $resolvers): // ------------------------------------------------------------------ /** - * Decode a Base64 string that may or may not include "=" padding. - * Returns null on malformed input. + * Decode canonical unpadded standard Base64. Returns null on malformed or + * non-canonical input. */ - private static function base64DecodeFlexible(string $input): ?string + private static function base64DecodeCanonical(string $input): ?string { - $input = trim($input); if ($input === '') { return null; } - - // Pad to a multiple of 4 if the caller passed unpadded base64. + if (preg_match('/[^A-Za-z0-9+\/]/', $input) === 1) { + return null; + } $remainder = strlen($input) % 4; if ($remainder === 1) { - // 1 mod 4 is never valid base64. return null; } + $padded = $input; if ($remainder !== 0) { - $input .= str_repeat('=', 4 - $remainder); + $padded .= str_repeat('=', 4 - $remainder); + } + + $decoded = base64_decode($padded, true); + if ($decoded === false) { + return null; + } + if (rtrim(base64_encode($decoded), '=') !== $input) { + return null; } + return $decoded; + } - $decoded = base64_decode($input, true); - return $decoded === false ? null : $decoded; + /** + * @param mixed $value + */ + private static function canonicalJson($value): string + { + if (is_array($value)) { + if ($value === [] || array_keys($value) === range(0, count($value) - 1)) { + $items = array_map([self::class, 'canonicalJson'], $value); + return '[' . implode(',', $items) . ']'; + } + uksort($value, static function ($left, $right): int { + return strcmp( + mb_convert_encoding((string) $left, 'UTF-16BE', 'UTF-8'), + mb_convert_encoding((string) $right, 'UTF-16BE', 'UTF-8') + ); + }); + $items = []; + foreach ($value as $key => $item) { + if ($item === null) { + $items[] = json_encode((string) $key, JSON_UNESCAPED_SLASHES) . ':null'; + } else { + $items[] = json_encode((string) $key, JSON_UNESCAPED_SLASHES) . ':' . self::canonicalJson($item); + } + } + return '{' . implode(',', $items) . '}'; + } + if (is_string($value)) { + return json_encode($value, JSON_UNESCAPED_SLASHES); + } + if (is_int($value) || is_float($value)) { + return json_encode($value, JSON_UNESCAPED_SLASHES); + } + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if ($value === null) { + return 'null'; + } + throw new InvalidArgumentException('unsupported JSON value'); } /** @@ -265,7 +368,13 @@ private static function extractEd25519RawKey(string $publicKey): ?string /** * Verify ECDSA or RSA via OpenSSL using SHA-256. */ - private static function verifyOpenssl(string $message, string $signature, string $publicKeyPem): bool + private static function verifyOpenssl( + string $message, + string $signature, + string $publicKeyPem, + int $digestAlgorithm, + ?int $expectedKeyType = null + ): bool { if (!function_exists('openssl_verify')) { throw new RuntimeException('ext-openssl is required for ecdsa/rsa verification'); @@ -275,7 +384,13 @@ private static function verifyOpenssl(string $message, string $signature, string if ($key === false) { return false; } - $result = openssl_verify($message, $signature, $key, OPENSSL_ALGO_SHA256); + if ($expectedKeyType !== null) { + $details = openssl_pkey_get_details($key); + if (!is_array($details) || $details['type'] !== $expectedKeyType) { + return false; + } + } + $result = openssl_verify($message, $signature, $key, $digestAlgorithm); // PHP < 8.0 may return a resource that needs free; PHP >= 8.0 // garbage-collects the OpenSSLAsymmetricKey automatically. @@ -287,6 +402,79 @@ private static function verifyOpenssl(string $message, string $signature, string return $result === 1; } + private static function verifyEcdsaP1363( + string $message, + string $signature, + string $publicKeyPem, + string $expectedCurve, + int $digestAlgorithm, + int $componentBytes + ): bool { + if (strlen($signature) !== $componentBytes * 2) { + return false; + } + $key = openssl_pkey_get_public($publicKeyPem); + if ($key === false) { + return false; + } + $details = openssl_pkey_get_details($key); + $curve = is_array($details) && isset($details['ec']['curve_name']) + ? strtolower((string) $details['ec']['curve_name']) + : ''; + $acceptedCurves = $expectedCurve === 'prime256v1' + ? ['prime256v1', 'secp256r1'] + : ['secp384r1']; + if (!in_array($curve, $acceptedCurves, true)) { + return false; + } + $der = self::p1363ToDer($signature, $componentBytes); + return openssl_verify($message, $der, $key, $digestAlgorithm) === 1; + } + + private static function p1363ToDer(string $signature, int $componentBytes): string + { + $encodeInteger = static function (string $integer): string { + $integer = ltrim($integer, "\x00"); + if ($integer === '') { + $integer = "\x00"; + } + if ((ord($integer[0]) & 0x80) !== 0) { + $integer = "\x00" . $integer; + } + return "\x02" . chr(strlen($integer)) . $integer; + }; + $r = $encodeInteger(substr($signature, 0, $componentBytes)); + $s = $encodeInteger(substr($signature, $componentBytes)); + return "\x30" . chr(strlen($r) + strlen($s)) . $r . $s; + } + + private static function algorithmsCompatible(string $resolved, string $declared): bool + { + $resolved = strtolower($resolved); + $declared = strtolower($declared); + if ($resolved === $declared) { + return true; + } + $family = static function (string $algorithm): string { + if (strpos($algorithm, 'ecdsa') === 0) { + return 'ecdsa'; + } + if (strpos($algorithm, 'rsa') === 0) { + return 'rsa'; + } + return $algorithm; + }; + $resolvedFamily = $family($resolved); + $declaredFamily = $family($declared); + if ($resolvedFamily !== $declaredFamily) { + return false; + } + if ($resolved === $resolvedFamily || $declared === $declaredFamily) { + return true; + } + return $resolvedFamily === 'rsa'; + } + /** * Build a PEM SubjectPublicKeyInfo from a raw 32-byte Ed25519 public key. * Useful for tests and tooling that bridge libsodium-generated keys to diff --git a/php/tests/EndorsementTest.php b/php/tests/EndorsementTest.php index c136cee..8ec2de6 100644 --- a/php/tests/EndorsementTest.php +++ b/php/tests/EndorsementTest.php @@ -2,7 +2,7 @@ /** * End-to-end tests for verifyEndorsement: an in-memory resolver returns a * PEM key for the endorser, and the endorsement signature is verified over - * `{endorsement}:{timestamp}`. + * deterministic JSON with `signature` omitted. */ namespace HTMLTrust\Canonicalization\Tests; @@ -25,14 +25,14 @@ public function testVerifyEndorsementSucceeds(): void 'timestamp' => '2025-05-01T00:00Z', 'algorithm' => 'ed25519', ]; - $message = $endorsement['endorsement'] . ':' . $endorsement['timestamp']; - $endorsement['signature'] = base64_encode(sodium_crypto_sign_detached($message, $secret)); + $message = Signature::canonicalizeEndorsementDocument($endorsement); + $endorsement['signature'] = rtrim(base64_encode(sodium_crypto_sign_detached($message, $secret)), '='); $resolver = new InMemoryResolver([$endorser => new ResolvedKey($pem, 'ed25519', $endorser)]); $this->assertTrue(Signature::verifyEndorsement($endorsement, [$resolver])); } - public function testVerifyEndorsementDefaultsToEd25519(): void + public function testVerifyEndorsementRequiresAlgorithm(): void { $this->skipIfNoSodium(); [$endorser, $pem, $secret] = $this->makeEndorser(); @@ -43,11 +43,11 @@ public function testVerifyEndorsementDefaultsToEd25519(): void 'timestamp' => '2025-05-01T00:00Z', // no 'algorithm' key — default ed25519 ]; - $message = $endorsement['endorsement'] . ':' . $endorsement['timestamp']; - $endorsement['signature'] = base64_encode(sodium_crypto_sign_detached($message, $secret)); + $message = '{"endorsement":"sha256:CONTENT","timestamp":"2025-05-01T00:00Z"}'; + $endorsement['signature'] = rtrim(base64_encode(sodium_crypto_sign_detached($message, $secret)), '='); $resolver = new InMemoryResolver([$endorser => new ResolvedKey($pem, 'ed25519', $endorser)]); - $this->assertTrue(Signature::verifyEndorsement($endorsement, [$resolver])); + $this->assertFalse(Signature::verifyEndorsement($endorsement, [$resolver])); } public function testVerifyEndorsementFailsForTamperedTimestamp(): void @@ -55,12 +55,19 @@ public function testVerifyEndorsementFailsForTamperedTimestamp(): void $this->skipIfNoSodium(); [$endorser, $pem, $secret] = $this->makeEndorser(); - $signedMessage = 'sha256:CONTENT:2025-05-01T00:00Z'; + $signed = [ + 'endorser' => $endorser, + 'endorsement' => 'sha256:CONTENT', + 'timestamp' => '2025-05-01T00:00Z', + 'algorithm' => 'ed25519', + ]; + $signedMessage = Signature::canonicalizeEndorsementDocument($signed); $endorsement = [ 'endorser' => $endorser, 'endorsement' => 'sha256:CONTENT', 'timestamp' => '2025-05-02T00:00Z', // different from what was signed - 'signature' => base64_encode(sodium_crypto_sign_detached($signedMessage, $secret)), + 'algorithm' => 'ed25519', + 'signature' => rtrim(base64_encode(sodium_crypto_sign_detached($signedMessage, $secret)), '='), ]; $resolver = new InMemoryResolver([$endorser => new ResolvedKey($pem, 'ed25519', $endorser)]); @@ -71,14 +78,14 @@ public function testVerifyEndorsementFailsForUnknownEndorser(): void { $this->skipIfNoSodium(); [$endorser, , $secret] = $this->makeEndorser(); - $message = 'sha256:CONTENT:2025-05-01T00:00Z'; - $endorsement = [ 'endorser' => $endorser, 'endorsement' => 'sha256:CONTENT', 'timestamp' => '2025-05-01T00:00Z', - 'signature' => base64_encode(sodium_crypto_sign_detached($message, $secret)), + 'algorithm' => 'ed25519', ]; + $message = Signature::canonicalizeEndorsementDocument($endorsement); + $endorsement['signature'] = rtrim(base64_encode(sodium_crypto_sign_detached($message, $secret)), '='); $resolver = new InMemoryResolver([]); // empty — won't resolve anything $this->assertFalse(Signature::verifyEndorsement($endorsement, [$resolver])); diff --git a/php/tests/ExtractCanonicalTextTest.php b/php/tests/ExtractCanonicalTextTest.php index 2a2a87e..5f4aea9 100644 --- a/php/tests/ExtractCanonicalTextTest.php +++ b/php/tests/ExtractCanonicalTextTest.php @@ -73,7 +73,10 @@ public function testStripsLinksButPreservesText(): void public function testImagesContributeSignedSemanticAttributes(): void { $html = '

BeforexAfter

'; - $this->assertSame("Before\n@attr:img:alt:x\nAfter", Canonicalize::extractCanonicalText($html)); + $this->assertSame( + "Before\n@attr:img:src:https://example.com/articles/x.png\n@attr:img:alt:x\nAfter", + Canonicalize::extractCanonicalText($html, false, 'https://example.com/articles/post') + ); } public function testEmptyAndAllMarkup(): void diff --git a/php/tests/SignatureTest.php b/php/tests/SignatureTest.php index 2c00e6a..4bdac83 100644 --- a/php/tests/SignatureTest.php +++ b/php/tests/SignatureTest.php @@ -18,8 +18,28 @@ class SignatureTest extends TestCase public function testBuildSignatureBindingFormatsCorrectly(): void { $this->assertSame( - 'sha256:ABC:sha256:DEF:example.com:2025-05-01T00:00Z', - Signature::buildSignatureBinding('sha256:ABC', 'sha256:DEF', 'example.com', '2025-05-01T00:00Z') + 'sha256:ABC:sha256:DEF:https://example.com:2025-05-01T00:00Z', + Signature::buildSignatureBinding('sha256:ABC', 'sha256:DEF', 'https://example.com', '2025-05-01T00:00Z') + ); + } + + public function testBuildSignatureBindingRejectsBareHostnameDomain(): void + { + $this->expectException(InvalidArgumentException::class); + Signature::buildSignatureBinding('sha256:ABC', 'sha256:DEF', 'example.com', '2025-05-01T00:00Z'); + } + + public function testBuildSignatureBindingRejectsNonHttpOrigin(): void + { + $this->expectException(InvalidArgumentException::class); + Signature::buildSignatureBinding('sha256:ABC', 'sha256:DEF', 'ftp://example.com', '2025-05-01T00:00Z'); + } + + public function testBuildSignatureBindingAcceptsIpv6Origin(): void + { + $this->assertSame( + 'sha256:ABC:sha256:DEF:https://[2001:db8::1]:8443:2025-05-01T00:00Z', + Signature::buildSignatureBinding('sha256:ABC', 'sha256:DEF', 'https://[2001:db8::1]:8443', '2025-05-01T00:00Z') ); } @@ -35,10 +55,10 @@ public function testBuildSignatureBindingRejectsEmptyFields(string $contentHash, public function emptyFieldProvider(): array { return [ - 'empty contentHash' => ['', 'b', 'c', 'd'], - 'empty claimsHash' => ['a', '', 'c', 'd'], + 'empty contentHash' => ['', 'b', 'https://example.com', 'd'], + 'empty claimsHash' => ['a', '', 'https://example.com', 'd'], 'empty domain' => ['a', 'b', '', 'd'], - 'empty signedAt' => ['a', 'b', 'c', ''], + 'empty signedAt' => ['a', 'b', 'https://example.com', ''], ]; } @@ -49,11 +69,42 @@ public function emptyFieldProvider(): array public function testBuildEndorsementBinding(): void { $this->assertSame( - 'sha256:XYZ:2025-05-01T00:00Z', + '{"endorsement":"sha256:XYZ","timestamp":"2025-05-01T00:00Z"}', Signature::buildEndorsementBinding('sha256:XYZ', '2025-05-01T00:00Z') ); } + public function testBuildEndorsementBindingFromDocumentOmitsSignature(): void + { + $this->assertSame( + '{"algorithm":"ed25519","endorsement":"sha256:XYZ","endorser":"did:web:alice.example","timestamp":"2025-05-01T00:00Z"}', + Signature::buildEndorsementBinding([ + 'endorser' => 'did:web:alice.example', + 'endorsement' => 'sha256:XYZ', + 'timestamp' => '2025-05-01T00:00Z', + 'algorithm' => 'ed25519', + 'signature' => 'ignored', + ]) + ); + } + + public function testBuildEndorsementBindingSortsKeysByUtf16CodeUnits(): void + { + $astral = "\u{10000}"; + $privateUse = "\u{E000}"; + $binding = Signature::buildEndorsementBinding([ + 'endorser' => 'did:web:alice.example', + 'endorsement' => 'sha256:XYZ', + 'timestamp' => '2025-05-01T00:00Z', + 'algorithm' => 'ed25519', + $privateUse => 2, + $astral => 1, + ]); + $astralEncoded = trim((string) json_encode($astral), '"'); + $privateUseEncoded = trim((string) json_encode($privateUse), '"'); + $this->assertLessThan(strpos($binding, $privateUseEncoded), strpos($binding, $astralEncoded)); + } + public function testBuildEndorsementBindingRejectsEmpty(): void { $this->expectException(InvalidArgumentException::class); @@ -64,16 +115,16 @@ public function testBuildEndorsementBindingRejectsEmpty(): void // verifySignature: ed25519 round trip via libsodium // ------------------------------------------------------------------ - public function testVerifyEd25519RoundTripPaddedSignature(): void + public function testVerifyEd25519RejectsPaddedSignature(): void { $this->skipIfNoSodium(); [$pem, $secret] = $this->makeEd25519KeypairPem(); - $message = 'sha256:ABC:sha256:DEF:example.com:2025-05-01T00:00Z'; + $message = 'sha256:ABC:sha256:DEF:https://example.com:2025-05-01T00:00Z'; $signature = sodium_crypto_sign_detached($message, $secret); $b64 = base64_encode($signature); // padded - $this->assertTrue(Signature::verifySignature($message, $b64, $pem, 'ed25519')); + $this->assertFalse(Signature::verifySignature($message, $b64, $pem, 'ed25519')); } public function testVerifyEd25519RoundTripUnpaddedSignature(): void @@ -94,7 +145,7 @@ public function testVerifyEd25519IsCaseInsensitive(): void [$pem, $secret] = $this->makeEd25519KeypairPem(); $message = 'hello'; - $signature = base64_encode(sodium_crypto_sign_detached($message, $secret)); + $signature = rtrim(base64_encode(sodium_crypto_sign_detached($message, $secret)), '='); $this->assertTrue(Signature::verifySignature($message, $signature, $pem, 'ED25519')); $this->assertTrue(Signature::verifySignature($message, $signature, $pem, 'Ed25519')); @@ -105,7 +156,7 @@ public function testVerifyEd25519RejectsTamperedMessage(): void $this->skipIfNoSodium(); [$pem, $secret] = $this->makeEd25519KeypairPem(); - $signature = base64_encode(sodium_crypto_sign_detached('original', $secret)); + $signature = rtrim(base64_encode(sodium_crypto_sign_detached('original', $secret)), '='); $this->assertFalse(Signature::verifySignature('tampered', $signature, $pem, 'ed25519')); } @@ -117,7 +168,7 @@ public function testVerifyEd25519RejectsBadKey(): void [$pemA, $secretA] = $this->makeEd25519KeypairPem(); [$pemB,] = $this->makeEd25519KeypairPem(); - $signature = base64_encode(sodium_crypto_sign_detached('hello', $secretA)); + $signature = rtrim(base64_encode(sodium_crypto_sign_detached('hello', $secretA)), '='); $this->assertFalse(Signature::verifySignature('hello', $signature, $pemB, 'ed25519')); } @@ -131,7 +182,7 @@ public function testVerifyEd25519AcceptsRawKeyBytes(): void $public = sodium_crypto_sign_publickey($keypair); $message = 'raw-key-test'; - $signature = base64_encode(sodium_crypto_sign_detached($message, $secret)); + $signature = rtrim(base64_encode(sodium_crypto_sign_detached($message, $secret)), '='); // Pass the raw 32-byte key directly (no PEM wrapping). $this->assertTrue(Signature::verifySignature($message, $signature, $public, 'ed25519')); @@ -148,7 +199,7 @@ public function testVerifyRejectsMalformedBase64(): void public function testVerifyUnknownAlgorithmThrows(): void { $this->expectException(InvalidArgumentException::class); - Signature::verifySignature('msg', base64_encode('xx'), 'irrelevant', 'frobnicate'); + Signature::verifySignature('msg', rtrim(base64_encode('xx'), '='), 'irrelevant', 'frobnicate'); } // ------------------------------------------------------------------ @@ -173,12 +224,35 @@ public function testVerifyEcdsaRoundTrip(): void $message = 'ecdsa-test'; $sig = ''; $this->assertTrue(openssl_sign($message, $sig, $key, OPENSSL_ALGO_SHA256)); - $b64 = base64_encode($sig); + $b64 = rtrim(base64_encode($sig), '='); $this->assertTrue(Signature::verifySignature($message, $b64, $pem, 'ecdsa')); $this->assertFalse(Signature::verifySignature('tampered', $b64, $pem, 'ecdsa')); } + public function testVerifyEcdsaP256UsesP1363WireFormat(): void + { + if (!function_exists('openssl_pkey_new')) { + $this->markTestSkipped('openssl extension not available'); + } + $key = openssl_pkey_new([ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ]); + if ($key === false) { + $this->markTestSkipped('this OpenSSL build cannot generate prime256v1 keypairs'); + } + $details = openssl_pkey_get_details($key); + $message = 'ecdsa-p1363-test'; + $der = ''; + $this->assertTrue(openssl_sign($message, $der, $key, OPENSSL_ALGO_SHA256)); + $p1363 = $this->ecdsaDerToP1363($der, 32); + $b64 = rtrim(base64_encode($p1363), '='); + + $this->assertTrue(Signature::verifySignature($message, $b64, $details['key'], 'ecdsa-p256')); + $this->assertFalse(Signature::verifySignature($message, rtrim(base64_encode($der), '='), $details['key'], 'ecdsa-p256')); + } + // ------------------------------------------------------------------ // verifySignature: RSA round trip via openssl // ------------------------------------------------------------------ @@ -201,7 +275,7 @@ public function testVerifyRsaRoundTrip(): void $message = 'rsa-test'; $sig = ''; $this->assertTrue(openssl_sign($message, $sig, $key, OPENSSL_ALGO_SHA256)); - $b64 = base64_encode($sig); + $b64 = rtrim(base64_encode($sig), '='); $this->assertTrue(Signature::verifySignature($message, $b64, $pem, 'rsa')); $this->assertFalse(Signature::verifySignature($message . 'x', $b64, $pem, 'rsa')); @@ -224,7 +298,7 @@ public function testEd25519RawToPemStructure(): void // Round-trips via the verify path: signing with the secret and // verifying via the PEM should succeed. $secret = sodium_crypto_sign_secretkey($keypair); - $signature = base64_encode(sodium_crypto_sign_detached('roundtrip', $secret)); + $signature = rtrim(base64_encode(sodium_crypto_sign_detached('roundtrip', $secret)), '='); $this->assertTrue(Signature::verifySignature('roundtrip', $signature, $pem, 'ed25519')); } @@ -245,6 +319,25 @@ private function skipIfNoSodium(): void } } + private function ecdsaDerToP1363(string $der, int $componentBytes): string + { + $offset = 1; + $length = ord($der[$offset++]); + if (($length & 0x80) !== 0) { + $lengthBytes = $length & 0x7f; + $offset += $lengthBytes; + } + $this->assertSame(2, ord($der[$offset++])); + $rLength = ord($der[$offset++]); + $r = substr($der, $offset, $rLength); + $offset += $rLength; + $this->assertSame(2, ord($der[$offset++])); + $sLength = ord($der[$offset++]); + $s = substr($der, $offset, $sLength); + return str_pad(ltrim($r, "\x00"), $componentBytes, "\x00", STR_PAD_LEFT) + . str_pad(ltrim($s, "\x00"), $componentBytes, "\x00", STR_PAD_LEFT); + } + /** * Generate a fresh Ed25519 keypair and wrap the public key in a PEM SPKI. * diff --git a/python/pyproject.toml b/python/pyproject.toml index 510878e..6c6f63f 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -14,7 +14,6 @@ authors = [ ] keywords = ["htmltrust", "canonicalization", "signing", "html", "unicode"] classifiers = [ - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11",