diff --git a/lib/linzer/jws.rb b/lib/linzer/jws.rb index 0579e8e..0d1dfff 100644 --- a/lib/linzer/jws.rb +++ b/lib/linzer/jws.rb @@ -3,6 +3,8 @@ require "jwt" require "jwt/eddsa" require "ed25519" +require "digest" +require "base64" module Linzer # JSON Web Signature (JWS) compatible key support. @@ -101,6 +103,40 @@ def verify(signature, data) algo.verify(data: data, signature: signature, verification_key: verify_key) end + # Computes the RFC 7638 JWK SHA-256 Thumbprint for this key's public + # material. + # + # This is computed directly from the exported JWK rather than + # delegating to the underlying jwt-eddsa gem's own thumbprint/kid + # generation: jwt-eddsa (<= 0.9.0) has a bug where its OKP JWK class + # computes that value over the wrong members (an RSA-shaped {kty, n, + # x} instead of the RFC 8037-correct {crv, kty, x}), which silently + # produces a keyid that a spec-compliant verifier will reject. + # + # @return [String] base64url-encoded (no padding) SHA-256 thumbprint + # @raise [Error] if this key's JWK "kty" is not supported + # + # @see https://www.rfc-editor.org/rfc/rfc7638 RFC 7638 - JSON Web Key (JWK) Thumbprint + # @see https://www.rfc-editor.org/rfc/rfc8037 RFC 8037 - EdDSA for JWS/JWK + def jwk_thumbprint + # XXX: drop this method custom implementation and just + # return material.key_digest + # once https://github.com/jwt/ruby-jwt-eddsa/pull/26 is resolved + # + exported = material.export + + members = + case exported[:kty] + when "OKP" + {crv: exported[:crv], kty: exported[:kty], x: exported[:x]} + else + raise Error, "Unsupported JWK kty for thumbprint: #{exported[:kty]}" + end + + digest = Digest::SHA256.digest(JWT::JSON.generate(members)) + Base64.urlsafe_encode64(digest, padding: false) + end + private # @return [Boolean] true if this key can verify signatures diff --git a/lib/linzer/signature/profile/web_bot_auth.rb b/lib/linzer/signature/profile/web_bot_auth.rb index 1b21ada..3ebb947 100644 --- a/lib/linzer/signature/profile/web_bot_auth.rb +++ b/lib/linzer/signature/profile/web_bot_auth.rb @@ -139,7 +139,7 @@ def set_params!(key, components, params) # params[:expires] ||= Time.now.to_i + 3600 params[:tag] ||= "web-bot-auth" - params[:keyid] ||= key.material.key_digest + params[:keyid] ||= key.jwk_thumbprint end # Injects and signs the Signature-Agent header. diff --git a/spec/jws_spec.rb b/spec/jws_spec.rb index b2003cc..4919d34 100644 --- a/spec/jws_spec.rb +++ b/spec/jws_spec.rb @@ -39,6 +39,28 @@ end end +RSpec.describe Linzer::JWS::Key do + describe "#jwk_thumbprint" do + # Test vector from RFC 8037 Appendix A.3 (JWK Thumbprint Canonicalization): + # https://www.rfc-editor.org/rfc/rfc8037#appendix-A.3 + # + # Computed directly here (not delegated to jwt-eddsa's own key_digest) + # because jwt-eddsa (<= 0.9.0) computes that value over the wrong JWK + # members for OKP keys; see Linzer::JWS::Key#jwk_thumbprint docs. + let(:jwk) { {kty: "OKP", crv: "Ed25519", x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"} } + let(:key) { Linzer::JWS.jwk_import(jwk) } + + it "matches the RFC 7638 JWK thumbprint of the crv/kty/x members" do + expect(key.jwk_thumbprint).to eq("kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k") + end + + it "raises for unsupported JWK key types" do + rsa_key = Linzer::JWS.jwk_import(JWT::JWK.new(OpenSSL::PKey::RSA.new(2048))) + expect { rsa_key.jwk_thumbprint }.to raise_error(Linzer::Error, /Unsupported JWK kty/) + end + end +end + RSpec.describe Linzer::Signer do context "with JWS EdDSA algorithm" do let(:request) do diff --git a/spec/signature/profile/web_bot_auth_spec.rb b/spec/signature/profile/web_bot_auth_spec.rb index 20100aa..4282cc7 100644 --- a/spec/signature/profile/web_bot_auth_spec.rb +++ b/spec/signature/profile/web_bot_auth_spec.rb @@ -67,7 +67,7 @@ apply_profile expect(params[:tag]).to eq("web-bot-auth") - expect(params[:keyid]).to eq(key.material.key_digest) + expect(params[:keyid]).to eq(key.jwk_thumbprint) expect(params[:expires]).to eq(Time.now.to_i + 3600) end diff --git a/spec/web_bot_auth_spec.rb b/spec/web_bot_auth_spec.rb index 89634eb..6825572 100644 --- a/spec/web_bot_auth_spec.rb +++ b/spec/web_bot_auth_spec.rb @@ -23,7 +23,7 @@ expect(signature.parameters).to include("created") expect(signature.parameters).to include("expires" => be > Time.now.utc.to_i) expect(signature.parameters["tag"]).to eq("web-bot-auth") - expect(signature.parameters["keyid"]).to eq(key.material.key_digest) + expect(signature.parameters["keyid"]).to eq(key.jwk_thumbprint) expect(signature.parameters).to have_key("nonce") expect(signature.metadata).to include('"@authority"').or include('"@target-uri"') expect(headers["signature-agent"]).to eq("my-sig=\"https://example.com/someagent\"")