Skip to content

Commit

Permalink
Steps toward an RFC 9241 HTTP Message Signatures implementation
Browse files Browse the repository at this point in the history
We want to use request signing to authenticate service-to-service
traffic within Replicate. Request signing is an attractive option for a
number of reasons. Two important ones:

1. we authenticate individual requests, not a communication channel
   shared between many requests (looking at you, mTLS)
2. we have access to authentication data, signature parameters, etc., at
   the HTTP layer, which makes enforcing per-endpoint requirements much
   easier

This commit starts to lay the groundwork for an implementation of HTTP
Message Signatures in compliance with RFC 9241. This is by no means a
complete implementation of the spec, but it should already cover almost
everything needed for deployment at Replicate.

Notably, there is currently no support for signing responses, only
requests.

Currently only signing is implemented. Verification code will initially
only be needed in Python, although we'll likely want to add it here so
we can more effectively test this.
  • Loading branch information
nickstenning committed Apr 7, 2024
1 parent bf4d48e commit af84ae0
Show file tree
Hide file tree
Showing 7 changed files with 790 additions and 0 deletions.
272 changes: 272 additions & 0 deletions http/signing/components.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
package signing

import (
"fmt"
"net/http"
"net/textproto"
"regexp"
"strings"
)

const (
// sf-string from [RFC 8941]
//
// sf-string = DQUOTE *chr DQUOTE
// chr = unescaped / escaped
// unescaped = %x20-21 / %x23-5B / %x5D-7E
// escaped = "\" ( DQUOTE / "\" )
//
// [RFC 8941]: https://www.rfc-editor.org/rfc/rfc8941
reSFString = `"(?:[\x20-\x21\x23-\x5B\x5D-\x7E]|\\"|\\\\)*"`

// We don't need to implement a full scanner for parameters, as only a small
// subset of parameters are currently permitted by [RFC 9421 Section 6.5.2].
//
// [RFC 9421 Section 6.5.2]: https://www.rfc-editor.org/rfc/rfc9421#section-6.5.2
reComponentParameter = `(?:(?:sf|bs|tr|req|key=` + reSFString + `|name=` + reSFString + `))`
)

var (
// The ABNF for component identifiers is as follows
//
// component-identifier = component-name parameters
// component-name = sf-string
//
pattComponentIdentifier = regexp.MustCompile(
`\A` +
// component-name = sf-string
`(` + reSFString + `)` +
// parameters = *( ";" parameter )
`((?:;` + reComponentParameter + `)*)` +
`\z`,
)
pattComponentParameter = regexp.MustCompile(`;` + reComponentParameter)

// Obsolete line folding from [RFC 7230]
//
// [RFC 7230]: https://www.rfc-editor.org/rfc/rfc7230
pattObsFold = regexp.MustCompile(`\r\n[ \t]+`)
)

var derivedComponents = map[string]bool{
"@method": true,
"@target-uri": true,
"@authority": true,
"@scheme": true,
"@request-target": true,
"@path": true,
"@query": true,
"@query-param": true,
"@status": true,
}

type ValidatedComponents []component

func (cs ValidatedComponents) Base(req *http.Request) (string, error) {
var b strings.Builder
for _, c := range cs {
b.WriteString(c.Identifier())
b.WriteRune(':')
b.WriteRune(' ')
v, err := c.Value(req)
if err != nil {
return "", err
}
b.WriteString(v)
b.WriteRune('\n')
}
return b.String(), nil
}

func (cs ValidatedComponents) Identifiers() []string {
ids := make([]string, len(cs))
for i, c := range cs {
ids[i] = c.Identifier()
}
return ids
}

type component interface {
Identifier() string
Value(req *http.Request) (string, error)
}

type param struct {
Key string
Value string
}

func Components(spec []string) (ValidatedComponents, error) {
cs := make([]component, len(spec))
for i, s := range spec {
c, err := validateComponent(s)
if err != nil {
return nil, err
}
cs[i] = c
}
return cs, nil
}

func MustComponents(spec []string) ValidatedComponents {
cs, err := Components(spec)
if err != nil {
panic(err)
}
return cs
}

func validateComponent(s string) (component, error) {
matches := pattComponentIdentifier.FindStringSubmatch(s)
if len(matches) != 3 {
return nil, fmt.Errorf("%w: malformed identifier %q", ErrInvalidComponent, s)
}

nameStr := matches[1]
paramStr := matches[2]

var params []param

// Validate parameters
if paramStr != "" {
paramMatches := pattComponentParameter.FindAllString(paramStr, -1)

paramKeys := make(map[string]bool)
params = make([]param, len(paramMatches))

for i, p := range paramMatches {
pk, pv, _ := strings.Cut(p[1:], "=")
if _, ok := paramKeys[pk]; ok {
return nil, fmt.Errorf("%w: repeated parameter %s for %s is not permitted", ErrInvalidComponent, pk, nameStr)
}

paramKeys[pk] = true
params[i] = param{Key: pk, Value: pv}
}

// TODO: validate cross-compatibility of parameters
// TODO: validate that `req` parameter is not supplied
}

// It's not clear whether this is actually required by the spec, but it's hard
// to see a valid case for providing a blank component name.
if nameStr == `""` {
return nil, fmt.Errorf("%w: component names may not be blank", ErrInvalidComponent)
}

// Remove outer quotes
name := nameStr[1 : len(nameStr)-1]

if name[0] == '@' {
if _, ok := derivedComponents[name]; !ok {
return nil, fmt.Errorf("%w: unknown derived component name %s", ErrInvalidComponent, name)
}
return derivedComponent{
Name: name,
Params: params,
}, nil
}

return fieldComponent{
Name: name,
Params: params,
}, nil
}

type derivedComponent struct {
Name string
Params []param
}

func (c derivedComponent) Identifier() string {
return makeIdentifier(c.Name, c.Params)
}

func (c derivedComponent) Value(req *http.Request) (string, error) {
// For now, treat any parameters as ErrNotImplemented.
if len(c.Params) > 0 {
return "", fmt.Errorf("%w: parameters are not yet supported (field %s)", ErrNotImplemented, c.Name)
}

switch c.Name {
case "@method":
return req.Method, nil
case "@target-uri":
return req.URL.String(), nil
case "@authority":
return req.Host, nil
case "@scheme":
return req.URL.Scheme, nil
case "@request-target":
return req.URL.RequestURI(), nil
case "@path":
result := req.URL.EscapedPath()
if result == "" {
result = "/"
}
return result, nil
case "@query":
return req.URL.RawQuery, nil
case "@query-param":
return "", fmt.Errorf("%w: @query-param is not yet implemented", ErrNotImplemented)
default:
return "", fmt.Errorf("%w: unknown derived component %s", ErrSigningFailure, c.Name)
}
}

type fieldComponent struct {
Name string
Params []param
}

func (c fieldComponent) Identifier() string {
return makeIdentifier(c.Name, c.Params)
}

func (c fieldComponent) Value(req *http.Request) (string, error) {
key := textproto.CanonicalMIMEHeaderKey(c.Name)
vals := req.Header[key]

// For now, treat any parameters as ErrNotImplemented.
if len(c.Params) > 0 {
return "", fmt.Errorf("%w: parameters are not yet supported (field %s)", ErrNotImplemented, c.Name)
}

// If the field has been requested for signing and there are no values
// available, signing must fail.
if len(vals) == 0 {
return "", fmt.Errorf("%w: request lacks requested field %s", ErrSigningFailure, c.Name)
}

canonicalVals := make([]string, len(vals))
for i, v := range vals {
// Strip leading and trailing whitespace from each item in the list.
s := strings.TrimSpace(v)
// Remove any obsolete line folding within the line, and replace it with a
// single space (" "), as discussed in [Section 5.2 of HTTP/1.1].
//
// [Section 5.2 of HTTP/1.1]: https://rfc-editor.org/rfc/rfc9112#section-5.2
s = pattObsFold.ReplaceAllString(v, " ")

canonicalVals[i] = s
}
// Concatenate the list of values with a single comma (",") and a single space
// (" ") between each item.
return strings.Join(canonicalVals, ", "), nil
}

func makeIdentifier(name string, params []param) string {
var b strings.Builder
b.WriteRune('"')
b.WriteString(name)
b.WriteRune('"')
for _, p := range params {
b.WriteRune(';')
b.WriteString(p.Key)
if p.Value != "" {
b.WriteRune('=')
b.WriteString(p.Value)
}
}
return b.String()
}
Loading

0 comments on commit af84ae0

Please sign in to comment.