Class: KnoxCall::DpopKeyPair

Inherits:
Object
  • Object
show all
Defined in:
lib/knoxcall/dpop.rb

Overview

DPoP proof generation (RFC 9449) — client side. Ruby port of knoxcall-node/src/auth/dpop.ts (see ../../PARITY.md §7).

Generates an ES256 (P-256) keypair and signs a fresh proof JWT per request (new jti/iat every call, htu stripped of query and fragment, ath bound to the access token when one is presented). Uses the openssl stdlib already required by the client — no new gem. The private key never leaves the process.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(key) ⇒ DpopKeyPair

Returns a new instance of DpopKeyPair.



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/knoxcall/dpop.rb', line 21

def initialize(key)
  @key = key
  point = key.public_key.to_bn.to_s(2) # uncompressed: 0x04 || X || Y
  unless point.bytesize == 65 && point.getbyte(0) == 4
    raise Error, "unexpected EC public key encoding"
  end
  # Member order crv, kty, x, y matches the RFC 7638 canonical order, so
  # the same hash serves both the proof header and the thumbprint input.
  @public_jwk = {
    "crv" => "P-256",
    "kty" => "EC",
    "x" => self.class.b64url(point.byteslice(1, 32)),
    "y" => self.class.b64url(point.byteslice(33, 32))
  }.freeze
end

Instance Attribute Details

#public_jwk ⇒ Object (readonly)

Returns the value of attribute public_jwk.



15
16
17
# File 'lib/knoxcall/dpop.rb', line 15

def public_jwk
  @public_jwk
end

Class Method Details

.b64url(bytes) ⇒ Object

Unpadded base64url via pack, not the base64 gem — base64 left the stdlib default set in Ruby 3.4, and the README promises stdlib only.



67
68
69
# File 'lib/knoxcall/dpop.rb', line 67

def self.b64url(bytes)
  [bytes].pack("m0").tr("+/", "-_").delete("=")
end

.der_to_p1363(der) ⇒ Object

Convert an ASN.1/DER ECDSA signature to JOSE P1363 (r||s, 64 bytes).

Raises:



72
73
74
75
76
77
# File 'lib/knoxcall/dpop.rb', line 72

def self.der_to_p1363(der)
  seq = OpenSSL::ASN1.decode(der)
  r, s = seq.value.map { |int| int.value.to_s(2) } # BN → big-endian binary, no leading zeros
  raise Error, "invalid DER signature" if r.bytesize > 32 || s.bytesize > 32
  r.rjust(32, "\0") + s.rjust(32, "\0")
end

.generate ⇒ Object



17
18
19
# File 'lib/knoxcall/dpop.rb', line 17

def self.generate
  new(OpenSSL::PKey::EC.generate("prime256v1"))
end

Instance Method Details

#sign(method, url, access_token: nil, nonce: nil) ⇒ Object

Sign a DPoP proof JWT for one request (RFC 9449 §4.2). The signature is ECDSA P-256 + SHA-256 in JOSE P1363 form (r||s, 64 bytes), matching the server verifier in src/lib/dpop-verifier.ts.



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/knoxcall/dpop.rb', line 40

def sign(method, url, access_token: nil, nonce: nil)
  htu = url.split("#", 2).first.split("?", 2).first
  header = { "alg" => "ES256", "typ" => "dpop+jwt", "jwk" => @public_jwk }
  payload = {
    "htm" => method.to_s.upcase,
    "htu" => htu,
    "iat" => Time.now.to_i,
    "jti" => self.class.b64url(SecureRandom.random_bytes(16))
  }
  if access_token && !access_token.empty?
    payload["ath"] = self.class.b64url(OpenSSL::Digest::SHA256.digest(access_token))
  end
  payload["nonce"] = nonce if nonce && !nonce.to_s.empty?

  signing_input = "#{self.class.b64url(JSON.generate(header))}.#{self.class.b64url(JSON.generate(payload))}"
  der = @key.sign(OpenSSL::Digest.new("SHA256"), signing_input)
  "#{signing_input}.#{self.class.b64url(self.class.der_to_p1363(der))}"
end

#thumbprint ⇒ Object

RFC 7638 JWK thumbprint of the public key — the value the server binds tokens to as cnf.jkt.



61
62
63
# File 'lib/knoxcall/dpop.rb', line 61

def thumbprint
  self.class.b64url(OpenSSL::Digest::SHA256.digest(JSON.generate(@public_jwk)))
end