diff --git a/draft-richer-oauth-httpsig.md b/draft-richer-oauth-httpsig.md index 29edfde..c9b2b3e 100644 --- a/draft-richer-oauth-httpsig.md +++ b/draft-richer-oauth-httpsig.md @@ -42,6 +42,7 @@ normative: DYNREG: RFC7591 JSON: RFC8259 HTTPAUTH: RFC7235 + RFC8032: informative: I-D.ietf-oauth-signed-http-request: @@ -103,6 +104,8 @@ A client pre-registering its keys for {{HTTPSIG}} binding MUST include the key i A pre-registered key MAY be a shared secret (such as for use in an HMAC signature), but public key cryptography is RECOMMENDED. +If the key is pre-registered, the signature algorithm MUST be derived from the indicated key and the `alg` signature parameter MUST NOT be used. + Note that pre-registration can occur statically or dynamically (such as by using {{DYNREG}}), as long as the key is associated with the client's `client_id` before the token request is made. ### Example Client Registration @@ -132,37 +135,15 @@ A client can publish the key binding parameters as part of a {{I-D.ietf-oauth-cl ## Token Request Key Introduction {#runtime} -Instead of pre-registering a key, a client can introduce its key during the token request in the same fashion as {{DPOP}}. - -The client MUST present its public key in the Signature-Key header field. The field is an HTTP Structured Field consisting of a Binary value containing the bytes of the {{JSON}} serialized {{JWK}} form of the key material. - -The JWK MUST have a `kid` field. The key MUST be a public key (and neither a private key nor a shared secret key). The JWK MUST have an `alg` value that indicates a signature algorithm. +Instead of pre-registering a key, a client can introduce its key during the token request in a similar fashion as {{DPOP}}. -For example, the following JWK public key: - -~~~ json -{ - "kty": "OKP", - "use": "sig", - "crv": "Ed25519", - "kid": "j-0Ny45NWmqGq6GQ", - "x": "iuemcj_GhRHmY_yCsMlDNp3BQgPZDdG00VRsg_BgU3s", - "alg": "EdDSA" -} -~~~ - -Can be encoded to the following Signature-Key field value (this example uses a compact JSON serialization that removes whitespace): - -~~~ -NOTE: '\' line wrapping per RFC 8792 +To use this mode, the client MUST: -Signature-Key: :eyJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJjcnYiOiJFZDI1NTE5I\ - iwia2lkIjoiai0wTnk0NU5XbXFHcTZHNFV4TGpHak51bG9rdHVndE9XNGpmR0NDZ2Vm\ - USIsIngiOiJpdWVtY2pfR2hSSG1ZX3lDc01sRE5wM0JRZ1BaRGRHMDBWUnNnX0JnVTN\ - zIiwiYWxnIjoiRWREU0EifQ==: -~~~ +* Include the `alg` signature parameter with a valid value from the HTTP Message Signatures Algorithms Registry indicating an asymmetric signature algorithm. +* Include its public key in the key type specific parameters as described in {{embed-keys}}. +* Include the `keyid` signature parameter uniquely identifying the key. -\[\[ Editor's note: this is a really awkward way to encode a JWK. We could try to break apart the JSON but there's not a 1:1 map to HTTP Structured Fields we can rely on. We could just put the minified JSON into a string but the double quotes would need to be escaped. This is the least bad version I could come up with right now. \]\] +The included key MUST be appropriate for the indicated algorithm. ## Token Request {#request} @@ -173,16 +154,13 @@ Additionally, the client MUST calculate and include the digest of the request bo For example, a form-encoded request body consisting of: ~~~ -NOTE: '\' line wrapping per RFC 8792 - -grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA\ -&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb +{::include tools/examples/token-request-body.form} ~~~ Would create the following Content-Digest header: ~~~ -Content-Digest: sha-256=:4fEzRVTGqfZg7lqf/d3oxXu837pvb3L0GN24+F1VkZk=: +{::include tools/examples/content-digest.hdr} ~~~ A client using this method MUST sign the token endpoint request using {{HTTPSIG}} with the appropriate key. The covered components MUST include: @@ -191,10 +169,6 @@ A client using this method MUST sign the token endpoint request using {{HTTPSIG} - `@target-uri` the full request URI of the request (note that this includes the scheme, authority, path, and query) - `content-digest` the digest of the request body -If a signature key is presented at runtime as described in {{runtime}}, the covered components MUST include: - -- `signature-key` the encoded public key used to sign this request - The covered components MUST include the client's authentication, if available. If using HTTP Basic, this means including the `authorization` field. The signature MUST include the following parameters: @@ -202,33 +176,44 @@ The signature MUST include the following parameters: - `created` a timestamp for signature creation; this MUST be within a small number of seconds of issuance (e.g. 30 seconds to account for clock skew) - `nonce` a random unique value that the AS can use to prevent signature replay within the small validity time window - `tag` a string indicating that this is being used for requesting a bound token, MUST be the value "httpsig-oauth-token-request" -- `keyid` the `kid` value for the key to be used for binding the token; if client uses pre-registered keys as in {{preregister}}, the value MUST match the `httpsig_bound_access_token_kid` value; if the key is presented at runtime as in {{runtime}}, the value MUST match the `kid` of the JWK in the Signature-Key field +- `keyid` the identifier for the key to be used for binding the token (matching `kid` in the token); if client uses pre-registered keys as in {{preregister}}, the value MUST match the `httpsig_bound_access_token_kid` value -The signature algorithm MUST be derived from the indicated key. The `alg` signature parameter MUST NOT be used. +Additionally, if the key is presented at runtime, the parameters and public key MUST be included as signature parameters as defined in {{runtime}}. An example request to the token endpoint (using a runtime-provided key here) can look like the following: ~~~ http-message -POST /token HTTP/1.1 -Host: server.example.com -Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW -Content-Type: application/x-www-form-urlencoded -Signature-Key: :eyJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJjcnYiOiJFZDI1NTE5I\ - iwia2lkIjoiai0wTnk0NU5XbXFHcTZHNFV4TGpHak51bG9rdHVndE9XNGpmR0NDZ2Vm\ - USIsIngiOiJpdWVtY2pfR2hSSG1ZX3lDc01sRE5wM0JRZ1BaRGRHMDBWUnNnX0JnVTN\ - zIiwiYWxnIjoiRWREU0EifQ==: -Content-Digest: sha-256=:4fEzRVTGqfZg7lqf/d3oxXu837pvb3L0GN24+F1VkZk=: -Signature-Input: sig1=("@method" "@target-uri" "content-digest" \ - "signature-key" "authorization");created=1618884473\ - ;keyid="j-0Ny45NWmqGq6G4UxLjGjNuloktugtOW4jfGCCgefQ"\ - ;nonce="b3k2pp5k7z-50gnX1b06";tag="httpsig-oauth-token-request" -Signature: sig1=:AWyxebrJ6u8CMi0B3TyX9G1G3XT45UW5zIn8mhsyXdmjTUtGS+1M\ - XiydKv5z0GLCrMhVSFe691jF98DRNNSPAg==: - -grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&\ -redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb +{::include tools/examples/token-request-signed.http} ~~~ +# Embedding a Public Key Value {#embed-keys} + +When encoding a public key value in a runtime request as in {{runtime}}, the client includes the public key material appropriate to the signature algorithm being used. + +## Elliptic Curve + +If the `alg` value is `ecdsa-p256-sha256` or `ecdsa-p384-sha384`, the public key is encoded in two additional signature parameters and values attached to the signature input: + +- `pub_key_x`: the big-endian encoded, zero-padded bytes of the key's X value, encoded as a Byte Sequence +- `pub_key_y`: the big-endian encoded, zero-padded bytes of the key's Y value, encoded as a Byte Sequence + +For `ecdsa-p256-sha256`, the key values are padded to exactly 32 bytes each. For `ecdsa-p384-sha384`, the key values are padded to exactly 48 bytes each. + +## Edwards Elliptic Curve + +If the `alg` value is `ed25519`, the public key is encoded in one additional signature parameter and value attached to the signature input: + +- `pub_key_a`: the little-endian encoded compressed Edwards point defined in {{RFC8032}}, encoded as a Byte Sequence + +The key value is exactly 32 bytes in length. + +## RSA + +If the `alg` value is `rsa-pss-sha512` or `rsa-v1_5-sha256`, the public key is encoded in two additional signature parameters and values attached to the signature input: + +- `pub_key_n`: the big-endian encoded unsigned modulus of the key (no sign byte and no leading 0x00 octet), encoded as a Byte Sequence +- `pub_key_e`: the big-endian encoded exponent of the key, encoded as a Byte Sequence + # Issuing an HTTP Message Signature Bound Access Token {#issuing} The AS MUST validate the signature of the token request sent in {{request}} against the identified key and the algorithm associated with that key. @@ -297,36 +282,28 @@ The signature MUST include the following parameters: - `created` a timestamp for signature creation; this MUST be within a small number of seconds of issuance (e.g. 30 seconds to account for clock skew) - `nonce` a random unique value that the AS can use to prevent signature replay within the small validity time window - `tag` a string indicating that this is being used for requesting a bound token, MUST be the value "httpsig-oauth" -- `keyid` the `kid` value for the key used to sign the request +- `keyid` the identifier for the key used to sign the request The client MUST NOT include an `alg` signature parameter. For example, the following signed request includes a signature with the needed parameters: ~~~ http-message -NOTE: '\' line wrapping per RFC 8792 - -GET /foo HTTP/1.1 -Host: example.com -Date: Mon, 20 Apr 2026 02:07:55 GMT -Authorization: HTTPSig 2340897.34j123-134uh2345n -Signature-Input: sig1=("@method" "@target-uri" "authorization")\ - ;created=1776650875;keyid="j-0Ny45NWmqGq6G4UxLjGjNuloktugtOW4jfGCCg\ - efQ";nonce="k9Jyxempel2305Nmx7Rk";tag="httpsig-oauth" -Signature: sig1=:kFJC2WoBbrQc8tsKiowIb8oeIA533qmKvzdKf8kndJ7kaLxGmm2v\ - 9+IPB8kLE0WUea8KryJGSV7ji1apLkeKBg==: +{::include tools/examples/present-request-signed.http} ~~~ -# Validating an HTTP Message Signature Bound Access Token Request {#validating} +# Validating an HTTP Message Signature Bound Resource Request {#validating} In order for a request protected by an HTTP Message Signature bound access token to be considered valid, the RS MUST perform the following checks: -- The presented signature validates using the key associated with the token -- The signature validates using the algorithm associated with the key +- The presented signature validates using the key bound to the token +- The signature validates using the HTTP_VERIFY algorithm associated with the key +- The `keyid` value matches the identifier for the key bound to the token - The `created` value is not too far in the past (e.g. 30 seconds to account for clock skew and network delays) - The `nonce` value has not been previously used within the time validity window of this request - The `tag` value is "httpsig-oauth" -- The covered components and parameters include all items enumerated in {{presenting}} +- The covered components and parameters include all items enumerated in {{presenting}}, including the Authorization header field +- The `alg` parameter is not present If the request includes an entity body (such as a POST, PUT, or QUERY) and a digest as per {{DIGEST}}, the RS MUST validate the digest. @@ -335,52 +312,18 @@ If the request includes multiple signatures tagged "httpsig-oauth", all signatur For example, to validate the request: ~~~ http-message -NOTE: '\' line wrapping per RFC 8792 - -GET /foo HTTP/1.1 -Host: example.com -Date: Mon, 20 Apr 2026 02:07:55 GMT -Authorization: HTTPSig 2340897.34j123-134uh2345n -Signature-Input: sig1=("@method" "@target-uri" "authorization")\ - ;created=1776650875;keyid="j-0Ny45NWmqGq6G4UxLjGjNuloktugtOW4jfGCCg\ - efQ";nonce="k9Jyxempel2305Nmx7Rk";tag="httpsig-oauth" -Signature: sig1=:kFJC2WoBbrQc8tsKiowIb8oeIA533qmKvzdKf8kndJ7kaLxGmm2v\ - 9+IPB8kLE0WUea8KryJGSV7ji1apLkeKBg==: +{::include tools/examples/rs-request-signed.http} ~~~ -The RS determines the key bound to the token and validates the `kid` value against that key. The RS determines the algorithm from the key and performs signature validation per {{HTTPSIG}} on the +The RS determines the key bound to the token (in this example, assume the RS introspects the token to get the key material) and validates the `kid` value against that the `keyid` in the signature input. The RS determines the algorithm from the key material. -In this example, the client has a key with the `kid` value of `test-key-rsa-pss` which uses the JWA `alg` value of `PS512`. The signature input string is: +In this example, the client has a key with the `kid` value of `test-key-ecdsa-p256`. The signature input string is: ~~~ -"@request-target": get /foo -"host": example.org -"authorization": HTTPSig 2340897.34j123-134uh2345n -"@signature-params": ("@request-target" "host" "authorization")\ - ;created=1618884475;keyid="test-key-rsa-pss" -~~~ - -This results in the following signed HTTP message, including the access token. - -~~~ http-message -NOTE: '\' line wrapping per RFC 8792 - -GET /foo HTTP/1.1 -Host: example.com -Date: Tue, 20 Apr 2021 02:07:55 GMT -Authorization: HTTPSig 2340897.34j123-134uh2345n -Signature-Input: sig1=("@request-target" "host" "authorization")\ - ;created=1618884475;keyid="test-key-rsa-pss" -Signature: sig1=:o+Fy/a6IIWhHwnMFhsHqfXEpheWGBMOU3pheT50zA8rL5F8Nur\ - xBKAPylMGBWYCKH5Bd+TB0Co6vqANlXyOCM9Zr5c/UmR5WGex5/OgJJmfN7gOVOH5\ - pB2Zxa233xsohfwo9liBlctukN5//E3F04rKjIkoeTFJiS+hMcOzn29esgFSEl4Jy\ - oO5Q8snMIsC56ZAPYwU7rJis1Wvl6Y9/9tpW6gIn/SHwArhPQSAb0zZy6mCiw654n\ - CaKw5NYJ9S0DZlnV4T7nJtdZsHOkddF6kH4WVka3ev0xONI5kYkEdR1Gw0VAE9thi\ - p+3/aFoUVTJ/1J6JfehZpXqehwv3KNoQ==: +{::include tools/examples/rs-sig-base.sigbase} ~~~ -An RS receiving such a signed message and a bound access token MUST verify the HTTP Message Signature as described in {{HTTPSIG}}. The RS MUST verify that all required portions of the HTTP request are covered by the signature by examining the contents of the signature parameters. - +The RS then calculates the signature validation against the signature base using the key using the algorithm appropriate HTTP_VERIFY function, per {{HTTPSIG}}. # Acknowledgements {#Acknowledgements} @@ -408,6 +351,11 @@ An RS receiving such a signed message and a bound access token MUST verify the H # Document History {#history} + +- -03 + - Added co-authors + - Changed inline key presentation from header to signature parameter + - -02 - Editorial fixes - Added example of client registration metadata parameter in CIMD @@ -445,3 +393,8 @@ Keys for this purpose could be introduced during a {{PAR}} request phase, as par ## Accept-Signature Support The `Accept-Signature` mechanism in {{HTTPSIG}} allows for runtime discovery of not only the applicability of signatures but also the expected coverage, for particular uses. + +# Test Vectors + +\[\[ Editor's note: we really should have end-to-end test vectors with keys and stuff all in here, not just inline. \]\] + diff --git a/tools/examples/content-digest.hdr b/tools/examples/content-digest.hdr new file mode 100644 index 0000000..172d2db --- /dev/null +++ b/tools/examples/content-digest.hdr @@ -0,0 +1 @@ +Content-Digest: sha-256=:4fEzRVTGqfZg7lqf/d3oxXu837pvb3L0GN24+F1VkZk=: diff --git a/tools/examples/present-request-signed.http b/tools/examples/present-request-signed.http new file mode 100644 index 0000000..97bc86f --- /dev/null +++ b/tools/examples/present-request-signed.http @@ -0,0 +1,11 @@ +NOTE: '\' line wrapping per RFC 8792 + +GET /foo HTTP/1.1 +Host: example.com +Date: Mon, 20 Apr 2026 02:07:55 GMT +Authorization: HTTPSig 2340897.34j123-134uh2345n +Signature-Input: sig1=("@method" "@target-uri" "authorization")\ + ;created=1776650875;keyid="j-0Ny45NWmqGq6G4UxLjGjNuloktugtOW4jfGCCg\ + efQ";nonce="k9Jyxempel2305Nmx7Rk";tag="httpsig-oauth" +Signature: sig1=:kFJC2WoBbrQc8tsKiowIb8oeIA533qmKvzdKf8kndJ7kaLxGmm2v\ + 9+IPB8kLE0WUea8KryJGSV7ji1apLkeKBg==: diff --git a/tools/examples/rs-request-signed.http b/tools/examples/rs-request-signed.http new file mode 100644 index 0000000..39414d0 --- /dev/null +++ b/tools/examples/rs-request-signed.http @@ -0,0 +1,11 @@ +NOTE: '\' line wrapping per RFC 8792 + +GET /foo HTTP/1.1 +Host: example.com +Date: Mon, 20 Apr 2026 02:07:55 GMT +Authorization: HTTPSig 2340897.34j123-134uh2345n +Signature-Input: sig1=("@method" "@target-uri" "authorization")\ + ;created=1776650875;keyid="test-key-ecdsa-p256"\ + ;nonce="k9Jyxempel2305Nmx7Rk";tag="httpsig-oauth" +Signature: sig1=:ggBLokBFCW7NcE4z1P5zV1wn7vmnRADiMcpAVFb1q8kOQ3nlhXqQ\ + c9LPWdz1h4FGRtImK9LqNdDdpn9G4o9zRw==: diff --git a/tools/examples/rs-sig-base.sigbase b/tools/examples/rs-sig-base.sigbase new file mode 100644 index 0000000..72eb120 --- /dev/null +++ b/tools/examples/rs-sig-base.sigbase @@ -0,0 +1,8 @@ +NOTE: '\' line wrapping per RFC 8792 + +"@method": GET +"@target-uri": https://example.com/foo +"authorization": HTTPSig 2340897.34j123-134uh2345n +"@signature-params": ("@method" "@target-uri" "authorization")\ + ;created=1776650875;keyid="test-key-ecdsa-p256"\ + ;nonce="k9Jyxempel2305Nmx7Rk";tag="httpsig-oauth" diff --git a/tools/examples/test-key-ecdsa-p256.pem b/tools/examples/test-key-ecdsa-p256.pem new file mode 100644 index 0000000..7908406 --- /dev/null +++ b/tools/examples/test-key-ecdsa-p256.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgr6cV0JZjiFW+CiCi +EjFMglhrROIrFzwYQYkx3YsvoSWhRANCAARHhhPXdFrh+ssIfvkF0znc4QdiL9hj +Z5CBHjcFZZClXFBVRTwRjg6JGFtOjlwqg7JIWUlN0aVCOn0fPzgs9naK +-----END PRIVATE KEY----- diff --git a/tools/examples/token-request-body.form b/tools/examples/token-request-body.form new file mode 100644 index 0000000..e25e6b5 --- /dev/null +++ b/tools/examples/token-request-body.form @@ -0,0 +1,4 @@ +NOTE: '\' line wrapping per RFC 8792 + +grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA\ +&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb diff --git a/tools/examples/token-request-signed.http b/tools/examples/token-request-signed.http new file mode 100644 index 0000000..73b7249 --- /dev/null +++ b/tools/examples/token-request-signed.http @@ -0,0 +1,17 @@ +NOTE: '\' line wrapping per RFC 8792 + +POST /token HTTP/1.1 +Host: server.example.com +Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW +Content-Type: application/x-www-form-urlencoded +Content-Digest: sha-256=:4fEzRVTGqfZg7lqf/d3oxXu837pvb3L0GN24+F1VkZk=: +Signature-Input: sig1=("@method" "@target-uri" "content-digest" \ + "authorization");created=1618884473;keyid="j-0Ny45NWmqGq6G4UxLjGjNu\ + loktugtOW4jfGCCgefQ";alg="ed25519";nonce="b3k2pp5k7z-50gnX1b06"\ + ;tag="httpsig-oauth-token-request";pub_key_a=:iuemcj/GhRHmY/yCsMlDN\ + p3BQgPZDdG00VRsg/BgU3s=: +Signature: sig1=:ikilCUkL+BjDRXdC7m4eNgU/4dQtijp4BTgehqYRxJX+sPr394dx\ + qGAPzV99eUVDhN1cgAk2VFXvN2pNfpFTBA==: + +grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_ur\ + i=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb diff --git a/tools/examples/token-request.http b/tools/examples/token-request.http index 15d20e9..bd09618 100644 --- a/tools/examples/token-request.http +++ b/tools/examples/token-request.http @@ -2,7 +2,5 @@ POST /token HTTP/1.1 Host: server.example.com Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW Content-Type: application/x-www-form-urlencoded -Signature-Key: :eyJrdHkiOiJPS1AiLCJ1c2UiOiJzaWciLCJjcnYiOiJFZDI1NTE5Iiwia2lkIjoiai0wTnk0NU5XbXFHcTZHNFV4TGpHak51bG9rdHVndE9XNGpmR0NDZ2VmUSIsIngiOiJpdWVtY2pfR2hSSG1ZX3lDc01sRE5wM0JRZ1BaRGRHMDBWUnNnX0JnVTNzIiwiYWxnIjoiRWREU0EifQ==: -Content-Digest: sha-256=:4fEzRVTGqfZg7lqf/d3oxXu837pvb3L0GN24+F1VkZk=: grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb diff --git a/tools/genexamples.sh b/tools/genexamples.sh new file mode 100755 index 0000000..f11710b --- /dev/null +++ b/tools/genexamples.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Regenerate the generatable example include files used by the draft. +# +# Each invocation drives tools/gensig.py from a raw HTTP source and a key, +# writing clean include content (RFC 8792 note prefixed when wrapped, no +# fences) directly to the files referenced by {::include ...} in the draft. +# +# Fixed created/nonce values keep the rendered examples stable across runs. + +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +ex="$here/examples" +gensig="$here/gensig.py" + +py() { python3 "$gensig" "$@"; } + +# --- Token request (runtime Ed25519 key) ----------------------------------- +# #2 Content-Digest of the form body and #3 the signed token request. +# The runtime key is embedded as pub_key_a + keyid params (no Signature-Key +# header); the alg param is forced on by --runtime-key. +py "$ex/token-request.http" \ + --key "$ex/token-key.jwk" \ + --covered @method @target-uri content-digest authorization \ + --content-digest --runtime-key \ + --tag httpsig-oauth-token-request \ + --nonce b3k2pp5k7z-50gnX1b06 \ + --created 1618884473 \ + --out-digest "$ex/content-digest.hdr" \ + --out-signed "$ex/token-request-signed.http" + +# --- Presenting the bound token (Ed25519, keyid only) ---------------------- +# #7 (also reused verbatim for #8). Token is already bound, so no key is +# embedded here. +py "$ex/rs-request.http" \ + --key "$ex/token-key.jwk" \ + --covered @method @target-uri authorization \ + --tag httpsig-oauth \ + --nonce k9Jyxempel2305Nmx7Rk \ + --created 1776650875 \ + --out-signed "$ex/present-request-signed.http" + +# --- Presenting with an EC P-256 key (sig base + signed message) ----------- +# #9 signature base and #10 signed message, from the same plain message as #7. +py "$ex/rs-request.http" \ + --key "$ex/test-key-ecdsa-p256.pem" \ + --key-id test-key-ecdsa-p256 \ + --covered @method @target-uri authorization \ + --tag httpsig-oauth \ + --nonce k9Jyxempel2305Nmx7Rk \ + --created 1776650875 \ + --show-sig-base \ + --out-sig-base "$ex/rs-sig-base.sigbase" \ + --out-signed "$ex/rs-request-signed.http" + +echo "Regenerated example include files in $ex" >&2 diff --git a/tools/gensig.py b/tools/gensig.py old mode 100644 new mode 100755 index c4f0a6a..d86c199 --- a/tools/gensig.py +++ b/tools/gensig.py @@ -478,15 +478,54 @@ def parse_http_request(text: str) -> HTTPRequest: # Inspectable signer — captures signature base before returning # --------------------------------------------------------------------------- +def runtime_key_params(public_pem: bytes, algorithm: type) -> "collections.OrderedDict": + """Map a public key into HTTP Message Signature parameters per {#embed-keys}. + + Returns an OrderedDict of parameter name -> bytes (serialized as a Byte + Sequence by http_sfv). Covers EC (pub_key_x/pub_key_y), Ed25519 + (pub_key_a), and RSA (pub_key_n/pub_key_e). + """ + from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa + + pub = load_pem_public_key(public_pem) + params: "collections.OrderedDict[str, bytes]" = collections.OrderedDict() + + if algorithm in (sig_algorithms.ECDSA_P256_SHA256,) or isinstance(pub, ec.EllipticCurvePublicKey): + numbers = pub.public_numbers() + size = (pub.curve.key_size + 7) // 8 + params["pub_key_x"] = numbers.x.to_bytes(size, "big") + params["pub_key_y"] = numbers.y.to_bytes(size, "big") + elif isinstance(pub, ed25519.Ed25519PublicKey): + params["pub_key_a"] = pub.public_bytes(Encoding.Raw, PublicFormat.Raw) + elif isinstance(pub, rsa.RSAPublicKey): + numbers = pub.public_numbers() + n_len = (numbers.n.bit_length() + 7) // 8 + e_len = (numbers.e.bit_length() + 7) // 8 + params["pub_key_n"] = numbers.n.to_bytes(n_len, "big") + params["pub_key_e"] = numbers.e.to_bytes(e_len, "big") + else: + raise ValueError(f"Cannot embed public key of type {type(pub).__name__}") + + return params + + class InspectableSigner(HTTPMessageSigner): - """HTTPMessageSigner that stores the last signature base for inspection.""" + """HTTPMessageSigner that stores the last signature base for inspection. + + If ``extra_params`` is set, those parameters are merged into every + signature's parameters so they appear in Signature-Input and are covered + by the signature base (used for runtime public-key embedding). + """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.last_sig_base: Optional[str] = None self.last_sig_elements: Optional[dict] = None + self.extra_params: Optional[dict] = None def _build_signature_base(self, message, *, covered_component_ids, signature_params): + if self.extra_params: + signature_params.update(self.extra_params) result = super()._build_signature_base( message, covered_component_ids=covered_component_ids, @@ -501,11 +540,35 @@ def _build_signature_base(self, message, *, covered_component_ids, signature_par # Output formatting # --------------------------------------------------------------------------- +RFC8792_NOTE = "NOTE: '\\' line wrapping per RFC 8792" + + +def content_digest_sha256(body: bytes) -> str: + """Return the Content-Digest header value for a SHA-256 body digest.""" + import hashlib + digest = hashlib.sha256(body).digest() + cd = http_sfv.Dictionary() + cd["sha-256"] = digest + return str(cd) + + def _artwork_block(content: str, label: str = "") -> str: fence = f"~~~ {label}".rstrip() return f"{fence}\n{content}\n~~~" +def _with_wrap_note(wrapped: str) -> str: + """Prepend the RFC 8792 note if the content contains line wrapping. + + Rendered outputs that use the single-backslash fold must carry the note + (and a following blank line) so the wrapping is self-describing. + """ + if any(line.endswith("\\") and not line.endswith("\\\\") + for line in wrapped.splitlines()): + return f"{RFC8792_NOTE}\n\n{wrapped}" + return wrapped + + def format_output( request_original: HTTPRequest, request_signed: HTTPRequest, @@ -573,6 +636,10 @@ def build_arg_parser() -> argparse.ArgumentParser: help="Application-specific tag for the signature") p.add_argument("--include-alg-param", action="store_true", help="Include the 'alg' parameter in Signature-Input") + p.add_argument("--runtime-key", action="store_true", + help="Embed the public key as signature parameters per the " + "draft's Embedding a Public Key Value section " + "(pub_key_*). Implies --include-alg-param.") p.add_argument("--output", "-o", type=Path, default=None, help="Output file (default: stdout)") p.add_argument("--width", "-w", type=int, default=69, @@ -583,6 +650,19 @@ def build_arg_parser() -> argparse.ArgumentParser: help="Include public key material in the output") p.add_argument("--show-original", action="store_true", help="Include the original (unsigned) request in the output") + # Direct output targets: each writes only that artifact's content, with no + # section label or fence, prefixed with the RFC 8792 note when wrapped. + # Suitable for kramdown-rfc {::include ...} files. + p.add_argument("--content-digest", action="store_true", + help="Compute a SHA-256 Content-Digest of the request body " + "and add it to the message before signing") + p.add_argument("--out-signed", type=Path, default=None, + help="Write the signed HTTP message to this file") + p.add_argument("--out-sig-base", type=Path, default=None, + help="Write the signature base to this file") + p.add_argument("--out-digest", type=Path, default=None, + help="Write the Content-Digest header line to this file " + "(implies --content-digest)") return p @@ -597,12 +677,27 @@ def main() -> None: request = parse_http_request(request_text) original = parse_http_request(request_text) # keep a clean copy + # Content-Digest: compute over the body and add before signing so it can + # be a covered component. --out-digest implies computing the digest. + digest_value: Optional[str] = None + if args.content_digest or args.out_digest is not None: + digest_value = content_digest_sha256(request.body or b"") + request.headers["Content-Digest"] = digest_value + + # --runtime-key forces the alg parameter to be present + include_alg = args.include_alg_param or args.runtime_key + # Build signer signer = InspectableSigner( signature_algorithm=key.algorithm, key_resolver=key.as_resolver(), ) + if args.runtime_key: + if key.public_pem is None: + raise SystemExit("--runtime-key requires a key with public material") + signer.extra_params = runtime_key_params(key.public_pem, key.algorithm) + created_dt = ( datetime.datetime.fromtimestamp(args.created) if args.created is not None @@ -616,12 +711,37 @@ def main() -> None: nonce=args.nonce, tag=args.tag, label=args.label, - include_alg=args.include_alg_param, + include_alg=include_alg, covered_component_ids=args.covered, ) sig_base = signer.last_sig_base or "" + # Direct artifact outputs: clean content, NOTE-prefixed when wrapped. + if args.out_signed is not None: + content = _with_wrap_note(wrap_http_block(request.render(), args.width)) + args.out_signed.write_text(content + "\n") + print(f"Wrote signed message to {args.out_signed}", file=sys.stderr) + + if args.out_sig_base is not None: + content = _with_wrap_note(wrap_sig_base(sig_base, args.width)) + args.out_sig_base.write_text(content + "\n") + print(f"Wrote signature base to {args.out_sig_base}", file=sys.stderr) + + if args.out_digest is not None: + content = _with_wrap_note( + wrap_line("Content-Digest: " + (digest_value or ""), + args.width, is_binary=True)) + args.out_digest.write_text(content + "\n") + print(f"Wrote content-digest to {args.out_digest}", file=sys.stderr) + + # If any direct output target was given, skip the combined stdout report + # unless an explicit --output was also requested. + any_target = any(t is not None for t in + (args.out_signed, args.out_sig_base, args.out_digest)) + if any_target and args.output is None: + return + output = format_output( request_original=original, request_signed=request,