Skip to content

feat(auth): add native rfc9421 HTTP Message Signatures filter (#3952) - #4209

Open
shardool-patil wants to merge 1 commit into
zalando:masterfrom
shardool-patil:feat/rfc9421-signatures
Open

feat(auth): add native rfc9421 HTTP Message Signatures filter (#3952)#4209
shardool-patil wants to merge 1 commit into
zalando:masterfrom
shardool-patil:feat/rfc9421-signatures

Conversation

@shardool-patil

Copy link
Copy Markdown
Contributor

Description

Implements native support for RFC 9421 (HTTP Message Signatures) and adds the rfc9421 egress signing filter.

Changes

  • RFC 9421 Engine (rfc/rfc9421/):
    • Zero third-party dependencies; implemented using Go standard library crypto packages.
    • Component canonicalization for derived components (@method, @path, @query, @authority, @scheme, etc.) and HTTP headers.
    • Structured fields formatting for Signature-Input and Signature headers.
    • Signing support for hmac-sha256, ed25519, rsa-pss-sha512, rsa-v1_5-sha256, and ecdsa-p256-sha256.
  • Egress Filter (filters/auth/rfc9421.go):
    • Reads private keys dynamically via x-rfc9421-private-key header and strips the header before forwarding upstream.
    • Supports PKCS#8, PKCS#1 RSA, EC, Ed25519, and raw HMAC keys.
  • Registry & Docs: Registered in filters/builtin/builtin.go and added filter reference documentation to docs/reference/filters.md.

Verification

  • Unit tests for HMAC, Ed25519, RSA-PSS, missing components, and filter parameter parsing.
  • Verified go test passes across rfc/rfc9421, filters/auth, and filters/builtin.

Related Issues

@szuecs I have implemented the code as explained just take a look at it when you get time.

@szuecs

szuecs commented Aug 24, 2026

Copy link
Copy Markdown
Member

@shardool-patil can you add the egress filter also to docs/reference/egress.md ?
One or more sentence(s) and a link to docs/reference.md#rfc9421 somewhere should be enough.

Comment thread docs/reference/filters.md Outdated

Signs outgoing HTTP requests according to [RFC 9421 (HTTP Message Signatures)](https://www.rfc-editor.org/rfc/rfc9421). It computes the signature across specified HTTP fields and attaches the `Signature-Input` and `Signature` headers.

The private key must be supplied via the temporary `x-rfc9421-private-key` request header. The filter reads the key into memory and deletes the header before forwarding the request upstream.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a bad idea but I would like to have this integrated via ./secrets module, instead.
The people who create the filter are likely not the ones that run skipper, so they have no idea where to read the key from and should not have access to the key. They could easily get the key:

setRequestHeaderFromSecret("x-rfc9421-private-key", "/tmp/secrets/api-secret")
-> logHeader("request")

If the operator is serious about this kind of problem then they can disable the filter setRequestHeaderFromSecret to ensure that people that can create a route and be able to read logs are not able to read the secret key.

Comment thread filters/auth/rfc9421.go Outdated

// NewRFC9421 creates a new filter specification for RFC 9421 HTTP Message Signatures.
func NewRFC9421() filters.Spec {
return &rfc9421Spec{}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spec should get the reference to the secret key and you will need to add a config flag in config/config.go and pass it to skipper.Options and initiate NewRFC9421(secretPath) in ./skipper.go , there are a bunch of similar cases that are done like this.

Comment thread filters/auth/rfc9421.go Outdated
return
}

signer, err := rfc9421.NewSigner(rfc9421.SignerConfig{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have the secret reference this can be created once in NewRFC9421() and passed to the filter instance in CreateFilter() without arguments read from the user.
It will be much more fast and less CPU consumption if we do it like this.

Comment thread filters/builtin/builtin.go Outdated
accesslog.NewEnableAccessLog(),
auth.NewForwardToken(),
auth.NewForwardTokenField(),
auth.NewRFC9421(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this has to be moved to skipper.go

Comment thread rfc/rfc9421/crypto.go Outdated

// signData computes the cryptographic signature over the provided data
// using the algorithm and private key configured on the Signer.
func (s *Signer) signData(data []byte) (string, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all functions are on "*Signer" so I would say we should move this to to signer.go instead of having a separate file

Comment thread rfc/rfc9421/signer.go Outdated
}
}

params := fmt.Sprintf("(%s);created=%d", strings.Join(compStrings, " "), created.Unix())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

params is likely better to be a StringBuilder

Comment thread rfc/rfc9421/signer.go Outdated

// Signer orchestrates HTTP message signing according to RFC 9421.
type Signer struct {
config SignerConfig

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks weird to me that we have a SignerConfig. Just move everything to Signer instead.

@shardool-patil
shardool-patil force-pushed the feat/rfc9421-signatures branch from 6e26247 to 5adad57 Compare August 25, 2026 05:18
@shardool-patil

Copy link
Copy Markdown
Contributor Author

I've updated the PR to address the feedback:

  • Secrets Integration: Migrated key loading to Skipper's secrets.SecretsReader (sp) via auth.NewRFC9421(sp) in skipper.go, removing the temporary header
  • Performance: Pre-parsed private keys once during NewSigner() / CreateFilter() at route creation rather than per request
  • Signer Consolidation: Merged crypto.go into signer.go, flattened configuration into Signer, and switched parameter formatting to strings.Builder
  • Filter Registration: Registered auth.NewRFC9421(sp) in skipper.go alongside other secrets-backed filters
  • Documentation: Updated docs/reference/filters.md with secretRef configuration details and added egress usage to docs/reference/egress.md
    Take a look at the changes whenever you have time @szuecs

Comment thread docs/reference/egress.md Outdated
- Circuit breaker filters
- [consecutiveBreaker](filters.md#consecutivebreaker)
- [rateBreaker](filters.md#ratebreaker)
- [disableBreaker](filters.md#disablebreaker)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is a whitespace missing, there is a need for 3x " " in order to make markdown lists a sub-list

Comment thread docs/reference/egress.md Outdated
- [bearerinjector](filters.md#bearerinjector) filter, that injects tokens for an app
- [rfc9421](filters.md#rfc9421) filter, that signs outgoing HTTP requests according to RFC 9421 HTTP Message Signatures using secrets managed by the secrets module
- The secrets module that does:
- automated secrets rotation read from files used by the `bearerinjector` filter and `rfc9421` filter

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here is also the 3rd whitespace missing

@szuecs

szuecs commented Aug 25, 2026

Copy link
Copy Markdown
Member

@shardool-patil thanks for the changes! Can you please revert the egress.md changes that are not your additions?

Some of these are breaking markdown and I don't want to see changes that are not part of the feature in this PR.

Rest of the changes lgtm.

Thanks!

@shardool-patil
shardool-patil force-pushed the feat/rfc9421-signatures branch from 5adad57 to 57bcf0a Compare August 25, 2026 17:19
@shardool-patil

Copy link
Copy Markdown
Contributor Author

@szuecs i have updated the requested changes so you can take a look.

@szuecs

szuecs commented Aug 25, 2026

Copy link
Copy Markdown
Member

Do we need to pass the keyfile in the filter?
I would expect that it's fine if only the operator has to know this by providing a flag and we use always the same key to sign.
I think that it would be beneficial for the user that would use the filter.

@szuecs

szuecs commented Aug 26, 2026

Copy link
Copy Markdown
Member

There is also a linter error detected:

staticcheck -checks "all,-ST1003,-ST1020" ./...
Error: rfc/rfc9421/base.go:1:1: at least one file in a package should have a package comment (ST1000)
Error: rfc/rfc9421/signer.go:1:1: at least one file in a package should have a package comment (ST1000)

Can you add a "doc.go" that has a godoc style documentation for the package?
Thanks!

@shardool-patil

Copy link
Copy Markdown
Contributor Author

Do we need to pass the keyfile in the filter? I would expect that it's fine if only the operator has to know this by providing a flag and we use always the same key to sign. I think that it would be beneficial for the user that would use the filter.

The initial design took the keyfile argument inside the filter to:

  1. Follow the pattern used in bearerinjector("/path/to/secret").
  2. Support multi-target egress routing where different routes call different external partners that require distinct signing keys.

However, I agree that abstracting the file system path away from route authors provides a cleaner interface and aligns better with operator/developer separation.

We have two ways:

  • Lookup by Key ID: Use the keyId argument directly to resolve the key file from the -credentials-paths directory (e.g., rfc9421("my-key-id", "hmac-sha256", ...) resolves to <credentials-path>/my-key-id).
  • Global Operator Flag: Introduce a global flag (e.g., -rfc9421-key-path) to supply a single signing key across all routes.

@szuecs take a look at this, i have fixed the linter failing ci tests and the codeql one is not from the code that i have worked on.

@szuecs

szuecs commented Aug 28, 2026

Copy link
Copy Markdown
Member

Do we need to pass the keyfile in the filter? I would expect that it's fine if only the operator has to know this by providing a flag and we use always the same key to sign. I think that it would be beneficial for the user that would use the filter.

The initial design took the keyfile argument inside the filter to:

1. Follow the pattern used in `bearerinjector("/path/to/secret")`.

2. Support multi-target egress routing where different routes call different external partners that require distinct signing keys.

However, I agree that abstracting the file system path away from route authors provides a cleaner interface and aligns better with operator/developer separation.

We have two ways:

* **Lookup by Key ID:** Use the `keyId` argument directly to resolve the key file from the `-credentials-paths` directory (e.g., `rfc9421("my-key-id", "hmac-sha256", ...)` resolves to `<credentials-path>/my-key-id`).

* **Global Operator Flag:** Introduce a global flag (e.g., `-rfc9421-key-path`) to supply a single signing key across all routes.

From what I see from bearerinjector and also oidc filter vs oauthGrant it's not worth the complexity to have the user reference something that they do not know. Therefore I think the best would be to have the file only in the flags and not in the filter. So also drop keyid from the filter input.

@szuecs take a look at this, i have fixed the linter failing ci tests and the codeql one is not from the code that i have worked on.

staticcheck says you need a godoc for the package:

$ staticcheck -checks "all,-ST1003,-ST1020" ./...
Error: rfc/rfc9421/base.go:1:1: at least one file in a package should have a package comment (ST1000)
Error: rfc/rfc9421/signer.go:1:1: at least one file in a package should have a package comment (ST1000)
make: *** [Makefile:167: staticcheck] Error 1
Error: Process completed with exit code 2.

To fix it create rfc/rfc9421/doc.go with content:

// Package rfc9421 implements [RFC9421](link to rfc) 
package rfc9421

@shardool-patil
shardool-patil force-pushed the feat/rfc9421-signatures branch from 57bcf0a to 2ec46b6 Compare August 30, 2026 13:05
@shardool-patil

Copy link
Copy Markdown
Contributor Author

I was away for a while, now i have updated the code just take a look @szuecs

  • Package Documentation: Added rfc/rfc9421/doc.go with the package doc linking to RFC 9421, resolving the staticcheck ST1000 issue.
  • Simplified Filter Signature: Removed keyFile and keyID from the rfc9421() filter arguments so route authors only need to configure the signature details (rfc9421("algorithm", "components"[, "label"])).
  • Global Operator Flags: Added -rfc9421-key-file and -rfc9421-key-id CLI flags in config/config.go and wired them through skipper.Options.
  • Tests & Verification: Updated filters/auth/rfc9421_test.go to match the new signature and verified that all unit tests and static analysis pass cleanly.

@shardool-patil
shardool-patil force-pushed the feat/rfc9421-signatures branch from 2ec46b6 to ad20358 Compare September 1, 2026 02:05
@shardool-patil

Copy link
Copy Markdown
Contributor Author

As i was making doc.go a newline was not added which was the cause for the failing ci tests, now that has been fixed, so take a look at this @szuecs

@szuecs szuecs added the minor no risk changes, for example new filters label Sep 2, 2026
szuecs
szuecs previously requested changes Sep 3, 2026
Comment thread filters/auth/rfc9421.go Outdated
}

if s.keyFile == "" || s.keyID == "" {
return nil, errors.New("rfc9421: key file and key ID must be configured via flags")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fmt.Errorf("your-msg: %w", filters.ErrInvalidFilterParameters)

Comment thread filters/auth/rfc9421.go Outdated
}

var components []string
for _, part := range strings.Split(componentsStr, ",") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SplitSeq() instead of Split

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@szuecs both requested changes have been addressed

…o#3952)

Signed-off-by: Shardool Patil <shardoolpatil999@gmail.com>
@shardool-patil
shardool-patil force-pushed the feat/rfc9421-signatures branch from ad20358 to 3726bc1 Compare September 3, 2026 09:05
@szuecs

szuecs commented Sep 3, 2026

Copy link
Copy Markdown
Member

👍

@shardool-patil

Copy link
Copy Markdown
Contributor Author

@szuecs it looks like check-race_proxy failed due to a timeout in TestPHCNoHealthyEndpoints, how do we proceed next

@szuecs

szuecs commented Sep 4, 2026

Copy link
Copy Markdown
Member

@szuecs it looks like check-race_proxy failed due to a timeout in TestPHCNoHealthyEndpoints, how do we proceed next

no worries I take care, retry helps and the problem is known caused by throttled CI env
It's a statistical test that we have no good solution for throttled env

@shardool-patil

Copy link
Copy Markdown
Contributor Author

@szuecs danke schon

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation minor no risk changes, for example new filters

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP signature rfc9421

2 participants