Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
/node_modules/
/versioned/
Gemfile.lock
__pycache__/
archive.json
draft-richer-oauth-httpsig.xml
package-lock.json
Expand Down
251 changes: 196 additions & 55 deletions draft-richer-oauth-httpsig.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion tools/examples/content-digest.hdr
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
Content-Digest: sha-256=:4fEzRVTGqfZg7lqf/d3oxXu837pvb3L0GN24+F1VkZk=:
NOTE: '\' line wrapping per RFC 8792

Content-Digest: sha-256=:4fEzRVTGqfZg7lqf/d3oxXu837pvb3L0GN24+F\
1VkZk=:
8 changes: 4 additions & 4 deletions tools/examples/present-request-signed.http
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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==:
;created=1776650875;nonce="k9Jyxempel2305Nmx7Rk"\
;tag="httpsig-oauth"
Signature: sig1=:uvIxaaJED8BxpYUL6cBd1v5uy24SptQnVQ35oKxWflg8GumiH77\
60va6P2qWxadrLodjGmIFna3bS70KxYv7CQ==:
8 changes: 4 additions & 4 deletions tools/examples/rs-request-signed.http
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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==:
;created=1776650875;nonce="k9Jyxempel2305Nmx7Rk"\
;tag="httpsig-oauth"
Signature: sig1=:iIwwgqVdmaneSVYJaLAMCf9yz8SnvNfUPZgeyWdkEjsO3+29XB0\
OLVBsR0w9Q+uw04yOVV1JeogdVvpyBhw3ZA==:
4 changes: 2 additions & 2 deletions tools/examples/rs-sig-base.sigbase
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ NOTE: '\' line wrapping per RFC 8792
"@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"
;created=1776650875;nonce="k9Jyxempel2305Nmx7Rk"\
;tag="httpsig-oauth"
16 changes: 16 additions & 0 deletions tools/examples/token-request-prereg-signed.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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+F\
1VkZk=:
Signature-Input: sig1=("@method" "@target-uri" "content-digest" \
"authorization");created=1618884473;keyid="j-0Ny45NWmqGq6GQ"\
;nonce="b3k2pp5k7z-50gnX1b06";tag="httpsig-oauth-token-request"
Signature: sig1=:blPeRn3D3nkYqc1RNzCzge9E0n/Guf9yA1BBOeMFOOeSwuDdrVN\
cc5KSUKSOabdVHXVExQT0v6J+e3QDc3zzBA==:

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_u\
ri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
18 changes: 9 additions & 9 deletions tools/examples/token-request-signed.http
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ 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=:
Content-Digest: sha-256=:4fEzRVTGqfZg7lqf/d3oxXu837pvb3L0GN24+F\
1VkZk=:
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==:
"authorization");created=1618884473;alg="ed25519"\
;nonce="b3k2pp5k7z-50gnX1b06";tag="httpsig-oauth-token-request"\
;pub=:iuemcj/GhRHmY/yCsMlDNp3BQgPZDdG00VRsg/BgU3s=:
Signature: sig1=:W1jjnCpI59MI13gajmWP9DViwQHXhQdQ8r8q+h+Drp2JbJyu/md\
QrMMkGuiMvkWWY/noYbi9L4cW/lTOZigsCQ==:

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_ur\
i=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_u\
ri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb
25 changes: 20 additions & 5 deletions tools/genexamples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ 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.
# The runtime key is embedded in the pub param; --runtime-key forces the alg
# param on and drops keyid.
py "$ex/token-request.http" \
--key "$ex/token-key.jwk" \
--covered @method @target-uri content-digest authorization \
Expand All @@ -29,12 +29,26 @@ py "$ex/token-request.http" \
--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.
# --- Token request (pre-registered Ed25519 key) ----------------------------
# The key is selected by keyid from the client's registered key set, so no
# alg or pub parameter appears; the algorithm comes from the JWK.
py "$ex/token-request.http" \
--key "$ex/token-key.jwk" \
--key-id j-0Ny45NWmqGq6GQ \
--covered @method @target-uri content-digest authorization \
--content-digest \
--tag httpsig-oauth-token-request \
--nonce b3k2pp5k7z-50gnX1b06 \
--created 1618884473 \
--out-signed "$ex/token-request-prereg-signed.http"

# --- Presenting the bound token (Ed25519) ----------------------------------
# #7 (also reused verbatim for #8). The token is already bound, so neither the
# key nor an identifier for it appears on the wire.
py "$ex/rs-request.http" \
--key "$ex/token-key.jwk" \
--covered @method @target-uri authorization \
--no-keyid \
--tag httpsig-oauth \
--nonce k9Jyxempel2305Nmx7Rk \
--created 1776650875 \
Expand All @@ -46,6 +60,7 @@ py "$ex/rs-request.http" \
--key "$ex/test-key-ecdsa-p256.pem" \
--key-id test-key-ecdsa-p256 \
--covered @method @target-uri authorization \
--no-keyid \
--tag httpsig-oauth \
--nonce k9Jyxempel2305Nmx7Rk \
--created 1776650875 \
Expand Down
51 changes: 30 additions & 21 deletions tools/gensig.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ def _wrap_at_column(text: str, max_len: int) -> str:
indent = CONTINUATION_INDENT
lines = []
while len(text) > max_len:
bp = _adjust_for_short_tail(max_len, text)
# -1 leaves room for the trailing "\" that joins the fold
bp = _adjust_for_short_tail(max_len - 1, text)
if bp >= len(text):
break # single trailing character: don't break
lines.append(text[:bp])
Expand Down Expand Up @@ -142,7 +143,8 @@ def wrap_structured_field(line: str, max_len: int = 69) -> str:
indent = CONTINUATION_INDENT
parts = []
while len(line) > max_len:
bp = _adjust_for_short_tail(_find_structured_break(line, max_len), line)
# -1 leaves room for the trailing "\" that joins the fold
bp = _adjust_for_short_tail(_find_structured_break(line, max_len - 1), line)
if bp >= len(line):
break # single trailing character: don't break
parts.append(line[:bp])
Expand Down Expand Up @@ -479,30 +481,23 @@ def parse_http_request(text: str) -> HTTPRequest:
# ---------------------------------------------------------------------------

def runtime_key_params(public_pem: bytes, algorithm: type) -> "collections.OrderedDict":
"""Map a public key into HTTP Message Signature parameters per {#embed-keys}.
"""Map a public key into the `pub` signature parameter 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).
Sequence by http_sfv). EC keys use the SEC1 uncompressed point form
(0x04 || X || Y); Ed25519 uses the raw 32-octet public key A. RSA is out
of scope for the inline representation.
"""
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
from cryptography.hazmat.primitives.asymmetric import ec, ed25519

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")
if isinstance(pub, ec.EllipticCurvePublicKey):
params["pub"] = pub.public_bytes(Encoding.X962,
PublicFormat.UncompressedPoint)
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")
params["pub"] = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
else:
raise ValueError(f"Cannot embed public key of type {type(pub).__name__}")

Expand All @@ -515,15 +510,22 @@ class InspectableSigner(HTTPMessageSigner):
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).

If ``drop_keyid`` is set, the keyid parameter the library always emits is
removed. The draft omits keyid for runtime key introduction and for token
presentation.
"""

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
self.drop_keyid: bool = False

def _build_signature_base(self, message, *, covered_component_ids, signature_params):
if self.drop_keyid:
signature_params.pop("keyid", None)
if self.extra_params:
signature_params.update(self.extra_params)
result = super()._build_signature_base(
Expand Down Expand Up @@ -637,9 +639,11 @@ def build_arg_parser() -> argparse.ArgumentParser:
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.")
help="Embed the public key in the 'pub' signature parameter "
"per the draft's Embedding a Public Key Value section. "
"Implies --include-alg-param and --no-keyid.")
p.add_argument("--no-keyid", action="store_true",
help="Omit the 'keyid' parameter from Signature-Input")
p.add_argument("--output", "-o", type=Path, default=None,
help="Output file (default: stdout)")
p.add_argument("--width", "-w", type=int, default=69,
Expand Down Expand Up @@ -693,6 +697,11 @@ def main() -> None:
key_resolver=key.as_resolver(),
)

# The draft omits keyid whenever the key is not identified by reference:
# runtime key introduction carries pub instead, and presentation to the RS
# relies on the access token binding.
signer.drop_keyid = args.no_keyid or args.runtime_key

if args.runtime_key:
if key.public_pem is None:
raise SystemExit("--runtime-key requires a key with public material")
Expand Down