class WebPackage::SignedHttpExchange

Builds headers and body of SXG format for a given pair of HTTP request-response. SXG format allows a browser to trust that a single HTTP request/response pair was generated by the origin it claims.

Current implementation is lazy, meaning that signing is performed upon the invocation of the `body` method.

Constants

HEADERS_MAX_SIZE
MOCK_RESP
MOCK_URL

Mock request-response pair just in case:

SIGNATURE_MAX_SIZE

Public Class Methods

new(url = MOCK_URL, response = MOCK_RESP) click to toggle source

Accepts two args representing a request-response pair:

url      - request url (string)
response - an array, equivalent to Rack's one: [status_code, headers, body]
# File lib/web_package/signed_http_exchange.rb, line 23
def initialize(url = MOCK_URL, response = MOCK_RESP)
  @uri    = build_uri_from url
  @url    = @uri.to_s
  @inner  = InnerResponse.new(*response)
  @signer = Signer.take

  @digest, @payload_body = MICE.new.encode @inner.payload
  @inner.headers.merge! 'digest' => "mi-sha256-03=#{base64(@digest || '')}"
end

Public Instance Methods

body() click to toggle source

tools.ietf.org/html/draft-yasskin-http-origin-signed-responses-05#section-5.3

# File lib/web_package/signed_http_exchange.rb, line 38
def body
  return @body if @body
  buffer = ''

  # 1. 8 bytes consisting of the ASCII characters "sxg1" followed by 4
  #    0x00 bytes, to serve as a file signature.  This is redundant with
  #    the MIME type, and recipients that receive both MUST check that
  #    they match and stop parsing if they don't.
  # TODO: The implementation of the final RFC MUST use the following line:
  # buffer << "sxg1\x00\x00\x00\x00"
  buffer << "sxg1-b3\x00"

  # 2.  2 bytes storing a big-endian integer "fallbackUrlLength".
  buffer << [@url.bytesize].pack('S>')

  # 3.  "fallbackUrlLength" bytes holding a "fallbackUrl", which MUST be
  #     an absolute URL with a scheme of "https".
  buffer << @url

  # 4.  3 bytes storing a big-endian integer "sigLength".  If this is
  #     larger than 16384 (16*1024), parsing MUST fail.
  if signature.bytesize > SIGNATURE_MAX_SIZE
    raise Errors::BodyEncodingError, 'Structured Signature Header length is too large: '\
          "#{signature.bytesize} bytes, max: #{SIGNATURE_MAX_SIZE} bytes."
  end
  buffer << [signature.bytesize].pack('L>').byteslice(-3, 3)

  # 5.  3 bytes storing a big-endian integer "headerLength".  If this is
  #     larger than 524288 (512*1024), parsing MUST fail.
  if cbor_encoded_headers.bytesize > HEADERS_MAX_SIZE
    raise Errors::BodyEncodingError, 'Response Headers length is too large: '\
          "#{cbor_encoded_headers.bytesize} bytes, max: #{HEADERS_MAX_SIZE} bytes."
  end
  buffer << [cbor_encoded_headers.bytesize].pack('L>').byteslice(-3, 3)

  # 6.  "sigLength" bytes holding the "Signature" header field's value
  #     (Section 3.1).
  buffer << signature

  # 7.  "headerLength" bytes holding "signedHeaders", the canonical
  #     serialization (Section 3.4) of the CBOR representation of the
  #     response headers of the exchange represented by the "application/
  #     signed-exchange" resource (Section 3.2), excluding the
  #     "Signature" header field.
  buffer << cbor_encoded_headers

  # 8.  The payload body (Section 3.3 of [RFC7230]) of the exchange
  #     represented by the "application/signed-exchange" resource.
  #     Note that the use of the payload body here means that a
  #     "Transfer-Encoding" header field inside the "application/signed-
  #     exchange" header block has no effect.  A "Transfer-Encoding"
  #     header field on the outer HTTP response that transfers this
  #     resource still has its normal effect.
  buffer << @payload_body

  @body = buffer
end
headers() click to toggle source
# File lib/web_package/signed_http_exchange.rb, line 33
def headers
  Settings.headers
end
to_rack_response() click to toggle source
# File lib/web_package/signed_http_exchange.rb, line 96
def to_rack_response
  [200, headers, [body]]
end

Private Instance Methods

build_uri_from(url) click to toggle source
# File lib/web_package/signed_http_exchange.rb, line 227
def build_uri_from(url)
  u = url.is_a?(URI) ? url : URI(url)
  raise '[SignedHttpExchange] Request host is required' if u.host.nil?

  u
end
cbor_encoded_headers() click to toggle source
# File lib/web_package/signed_http_exchange.rb, line 164
def cbor_encoded_headers
  @cbor_encoded_headers ||= CBOR.new.generate @inner.headers
end
expires_at() click to toggle source
# File lib/web_package/signed_http_exchange.rb, line 210
def expires_at
  @expires_at ||= begin
    lifetime = case Settings.expires_in
               when Integer then Settings.expires_in
               when Proc then Settings.expires_in[@uri].to_i
               else raise 'Settings.expires_in is allowed to be Integer or Proc only'
               end

    # valid lifetime is within (0, 7.days] range
    if lifetime <= 0 || lifetime > DEFAULTS[:expires_in]
      raise "expires_in (#{lifetime}) is out of permitted range (0, #{DEFAULTS[:expires_in]}]"
    end

    signed_at + lifetime
  end
end
message() click to toggle source
# File lib/web_package/signed_http_exchange.rb, line 102
def message
  return @message if @message
  buffer = ''

  # Help in debugging "VerifyFinal failed." error, source code:
  #   https://github.com/chromium/chromium/blob/8f0bd6c8be04f0dd556d42820f1eec0963dfe10b/
  #   content/browser/web_package/signed_exchange_signature_verifier.cc#L120
  # It may look as if something is wrong with the certificate or signing algorithm, but in fact
  # the error is caused by the message being composed incorrectly.
  # So, if you get such an error - please check the `message` first.

  # From specs:
  # https://tools.ietf.org/html/draft-yasskin-http-origin-signed-responses-05#section-3.5
  #
  # Let "message" be the concatenation of the following byte
  # strings.  This matches the [RFC8446] format to avoid cross-
  # protocol attacks if anyone uses the same key in a TLS
  # certificate and an exchange-signing certificate.

  # 1.  A string that consists of octet 32 (0x20) repeated 64 times.
  buffer << "\x20" * 64

  # 2.  A context string: the ASCII encoding of "HTTP Exchange 1".
  #     ... but implementations of drafts MUST NOT use it and MUST use another
  #     draft-specific string beginning with "HTTP Exchange 1 " instead.
  # TODO: The implementation of the final RFC MUST use the following line:
  # buffer << "HTTP Exchange 1"
  buffer << 'HTTP Exchange 1 b3'

  # 3.  A single 0 byte which serves as a separator.
  buffer << "\x00"

  # 4.  If "cert-sha256" is set, a byte holding the value 32
  #     followed by the 32 bytes of the value of "cert-sha256".
  #     Otherwise a 0 byte.
  buffer << (@signer.cert_sha256 ? "\x20#{@signer.cert_sha256}" : "\x00")

  # 5.  The 8-byte big-endian encoding of the length in bytes of
  #     "validity-url", followed by the bytes of "validity-url".
  buffer << [validity_url.bytesize].pack('Q>')
  buffer << validity_url

  # 6.  The 8-byte big-endian encoding of "date".
  buffer << [signed_at.to_i].pack('Q>')

  # 7.  The 8-byte big-endian encoding of "expires".
  buffer << [expires_at.to_i].pack('Q>')

  # 8.  The 8-byte big-endian encoding of the length in bytes of
  #     "requestUrl", followed by the bytes of "requestUrl".
  buffer << [@url.bytesize].pack('Q>')
  buffer << @url

  # 9.  The 8-byte big-endian encoding of the length in bytes of
  #     "responseHeaders", followed by the bytes of
  #     "responseHeaders".
  buffer << [cbor_encoded_headers.bytesize].pack('Q>')
  buffer << cbor_encoded_headers

  @message = buffer
end
signature() click to toggle source

tools.ietf.org/html/draft-ietf-httpbis-header-structure-09

# File lib/web_package/signed_http_exchange.rb, line 190
def signature
  # Example of a signature:
  #   label;cert-sha256=*+DoXYlCX+bFRyW65R3bFA2ICIz8Tyu54MLFUFo5tziA=*;cert-url="https://exampl
  #   e.com/cert.cbor";date=1555925114;expires=1555928714;integrity="digest/mi-sha256-03";sig=*
  #   MEQCIBgsnVxmRqzjeFczuXnQClf2bwtHdGeGGSMOz6y5EH7HAiAu1lt2ERsWIRcOmszB3XneSWoGKrMD7wvalVfPp
  #   4tb9Q==*;validity-url="https://example.com/resource.validity.msg"
  @signature ||=
    structured_header_for 'label', 'cert-sha256':  @signer.cert_sha256.bytes,
                                   'cert-url':     Settings.cert_url,
                                   'date':         signed_at.to_i,
                                   'expires':      expires_at.to_i,
                                   'integrity':    'digest/mi-sha256-03',
                                   'sig':          @signer.sign(message).bytes,
                                   'validity-url': validity_url
end
signed_at() click to toggle source
# File lib/web_package/signed_http_exchange.rb, line 206
def signed_at
  @signed_at ||= Time.now
end
structured_header_for(label, params) click to toggle source

returns a string representing serialized label + params

# File lib/web_package/signed_http_exchange.rb, line 169
def structured_header_for(label, params)
  if params[:'cert-url'].to_s.empty?
    raise '[SignedHttpExchange] No certificate url provided - please use `SXG_CERT_URL` '\
          'env var. Endpoint should respond with `application/cert-chain+cbor` content type.'
  end

  res = [label]

  params.sort.each do |key, value|
    # https://tools.ietf.org/html/draft-ietf-httpbis-header-structure-09#section-4.1.10
    res << "#{key}=" + case value
                       when Integer then value.to_s
                       when String  then %("#{value}")                   # a text string
                       when Array   then "*#{base64(value.pack('C*'))}*" # a byte string
                       end
  end

  res.join(?;)
end
validity_url() click to toggle source
# File lib/web_package/signed_http_exchange.rb, line 234
def validity_url
  @validity_url ||= begin
    path = @uri.path
    fi = path.index(?.)
    no_format_path = fi ? path[0...fi] : path # path without format, i.e. default :html

    URI::HTTPS.build(host: @uri.host, path: no_format_path, query: @uri.query).to_s
  end
end