mirror of
https://github.com/fleetdm/fleet
synced 2026-05-06 14:58:33 +00:00
For #30473 This change adds a vendored `httpsig-go` library to our repo. We cannot use the upstream library because it has not merged the change we need: https://github.com/remitly-oss/httpsig-go/pull/25 Thus, we need our own copy at this point. The instructions for keeping this library up to date (if needed) are in `UPDATE_INSTRUCTIONS`. None of the coderabbitai review comments are relevant to the code/features we are going to use for HTTP message signatures. We will use this library in subsequent PRs for the TPM-backed HTTP message signature feature. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a Go library for HTTP message signing and verification, supporting multiple cryptographic algorithms (RSA, ECDSA, Ed25519, HMAC). * Added utilities for key management, including JWK and PEM key handling. * Provided HTTP client and server helpers for automatic request signing and signature verification. * Implemented structured error handling and metadata extraction for signatures. * **Documentation** * Added comprehensive README, usage examples, and update instructions. * Included license and configuration files for third-party and testing tools. * **Tests** * Added extensive unit, integration, and fuzz tests covering signing, verification, and key handling. * Included official RFC test vectors and various test data files for robust validation. * **Chores** * Integrated continuous integration workflows and ignore files for code quality and security analysis. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
332 lines
8.8 KiB
Go
332 lines
8.8 KiB
Go
package httpsig
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
sfv "github.com/dunglas/httpsfv"
|
|
)
|
|
|
|
type Algorithm string
|
|
type Digest string
|
|
|
|
// Metadata are the named signature metadata parameters
|
|
type Metadata string
|
|
|
|
type CreatedScheme int
|
|
type ExpiresScheme int
|
|
type NonceScheme int
|
|
|
|
const (
|
|
// Supported signing algorithms
|
|
Algo_RSA_PSS_SHA512 Algorithm = "rsa-pss-sha512"
|
|
Algo_RSA_v1_5_sha256 Algorithm = "rsa-v1_5-sha256"
|
|
Algo_HMAC_SHA256 Algorithm = "hmac-sha256"
|
|
Algo_ECDSA_P256_SHA256 Algorithm = "ecdsa-p256-sha256"
|
|
Algo_ECDSA_P384_SHA384 Algorithm = "ecdsa-p384-sha384"
|
|
Algo_ED25519 Algorithm = "ed25519"
|
|
|
|
DigestSHA256 Digest = "sha-256"
|
|
DigestSHA512 Digest = "sha-512"
|
|
|
|
// Signature metadata parameters
|
|
MetaCreated Metadata = "created"
|
|
MetaExpires Metadata = "expires"
|
|
MetaNonce Metadata = "nonce"
|
|
MetaAlgorithm Metadata = "alg"
|
|
MetaKeyID Metadata = "keyid"
|
|
MetaTag Metadata = "tag"
|
|
|
|
// DefaultSignatureLabel is the label that will be used for a signature if not label is provided in the parameters.
|
|
// A request can contain multiple signatures therefore each signature is labeled.
|
|
DefaultSignatureLabel = "sig1"
|
|
|
|
// Nonce schemes
|
|
NonceRandom32 = iota // 32 bit random nonce. Base64 encoded
|
|
)
|
|
|
|
// SigningProfile is the set of fields, metadata, and the label to include in a signature.
|
|
type SigningProfile struct {
|
|
Algorithm Algorithm
|
|
Digest Digest // The http digest algorithm to apply. Defaults to sha-256.
|
|
Fields []SignedField // Fields and Derived components to sign.
|
|
Metadata []Metadata // Metadata parameters to add to the signature.
|
|
Label string // The signature label. Defaults to DefaultSignatureLabel.
|
|
ExpiresDuration time.Duration // Current time plus this duration. Default duration 5 minutes. Used only if included in Metadata.
|
|
Nonce NonceScheme // Scheme to use for generating the nonce if included in Metadata.
|
|
}
|
|
|
|
// SignedField indicates which part of the request or response to use for signing.
|
|
// This is the 'message component' in the specification.
|
|
type SignedField struct {
|
|
Name string
|
|
Parameters map[string]any // Parameters are modifiers applied to the field that changes the way the signature is calculated.
|
|
}
|
|
|
|
// Fields turns a list of fields into the full specification. Used when the signed fields/components do not need to specify any parameters
|
|
func Fields(fields ...string) []SignedField {
|
|
all := []SignedField{}
|
|
for _, field := range fields {
|
|
all = append(all, SignedField{
|
|
Name: strings.ToLower(field),
|
|
Parameters: map[string]any{},
|
|
})
|
|
}
|
|
return all
|
|
}
|
|
|
|
type SigningKey struct {
|
|
Key crypto.PrivateKey // private key for asymmetric algorithms
|
|
Secret []byte // Secret to use for symmetric algorithms
|
|
// Meta fields
|
|
MetaKeyID string // 'keyid' - Only used if 'keyid' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile. Metadata.
|
|
MetaTag string // 'tag'. Only used if 'tag' is set in the SigningProfile. A value must be provided if the parameter is required in the SigningProfile.
|
|
}
|
|
|
|
type Signer struct {
|
|
profile SigningProfile
|
|
skey SigningKey
|
|
}
|
|
|
|
func NewSigner(profile SigningProfile, skey SigningKey) (*Signer, error) {
|
|
err := profile.validate(skey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
opts := profile.withDefaults()
|
|
s := &Signer{
|
|
profile: opts,
|
|
skey: skey,
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func Sign(req *http.Request, params SigningProfile, skey SigningKey) error {
|
|
s, err := NewSigner(params, skey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.Sign(req)
|
|
}
|
|
|
|
// Sign signs the request and adds the signature headers to the request.
|
|
// If the signature fields includes Content-Digest and Content-Digest is not already included in the request then Sign will read the request body to calculate the digest and set the header. The request body will be replaced with a new io.ReaderCloser.
|
|
func (s *Signer) Sign(req *http.Request) error {
|
|
// Add the content-digest if covered by the signature and not already present
|
|
if signedFields(s.profile.Fields).includes("content-digest") && req.Header.Get("Content-Digest") == "" {
|
|
di, err := digestBody(s.profile.Digest, req.Body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Body = di.NewBody
|
|
digestValue, err := createDigestHeader(s.profile.Digest, di.Digest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Digest", digestValue)
|
|
}
|
|
|
|
baseParams, err := s.baseParameters()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return sign(
|
|
httpMessage{
|
|
Req: req,
|
|
}, sigParameters{
|
|
Base: baseParams,
|
|
Algo: s.profile.Algorithm,
|
|
PrivateKey: s.skey.Key,
|
|
Secret: s.skey.Secret,
|
|
Label: s.profile.Label,
|
|
})
|
|
}
|
|
|
|
func (s *Signer) SignResponse(resp *http.Response) error {
|
|
baseParams, err := s.baseParameters()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return sign(
|
|
httpMessage{
|
|
IsResponse: true,
|
|
Resp: resp,
|
|
}, sigParameters{
|
|
Base: baseParams,
|
|
Algo: s.profile.Algorithm,
|
|
PrivateKey: s.skey.Key,
|
|
Secret: s.skey.Secret,
|
|
Label: s.profile.Label,
|
|
})
|
|
}
|
|
|
|
func (s *Signer) baseParameters() (sigBaseInput, error) {
|
|
bp := sigBaseInput{
|
|
Components: componentsIDs(s.profile.Fields),
|
|
MetadataParams: s.profile.Metadata,
|
|
MetadataValues: s,
|
|
}
|
|
|
|
return bp, nil
|
|
}
|
|
|
|
func (s *Signer) Created() (int, error) {
|
|
return int(time.Now().Unix()), nil
|
|
}
|
|
|
|
func (s *Signer) Expires() (int, error) {
|
|
return int(time.Now().Add(s.profile.ExpiresDuration).Unix()), nil
|
|
}
|
|
|
|
func (s *Signer) Nonce() (string, error) {
|
|
switch s.profile.Nonce {
|
|
case NonceRandom32:
|
|
return nonceRandom32()
|
|
}
|
|
return "", fmt.Errorf("Invalid nonce scheme '%d'", s.profile.Nonce)
|
|
}
|
|
|
|
func (s *Signer) Alg() (string, error) {
|
|
return string(s.profile.Algorithm), nil
|
|
}
|
|
|
|
func (s *Signer) KeyID() (string, error) {
|
|
return s.skey.MetaKeyID, nil
|
|
}
|
|
|
|
func (s *Signer) Tag() (string, error) {
|
|
return s.skey.MetaTag, nil
|
|
}
|
|
|
|
func (so SigningProfile) validate(skey SigningKey) error {
|
|
if so.Algorithm == "" {
|
|
return fmt.Errorf("Missing required signing option 'Algorithm'")
|
|
}
|
|
if so.Algorithm.symmetric() && len(skey.Secret) == 0 {
|
|
return newError(ErrInvalidSignatureOptions, "Missing required 'Secret' value in SigningKey")
|
|
}
|
|
if !so.Algorithm.symmetric() && skey.Key == nil {
|
|
return newError(ErrInvalidSignatureOptions, "Missing required 'Key' value in SigningKey")
|
|
}
|
|
if !isSafeString(so.Label) {
|
|
return fmt.Errorf("Invalid label name '%s'", so.Label)
|
|
}
|
|
for _, sf := range so.Fields {
|
|
if !isSafeString(sf.Name) {
|
|
return fmt.Errorf("Invalid signing field name '%s'", sf.Name)
|
|
}
|
|
}
|
|
|
|
for _, md := range so.Metadata {
|
|
switch md {
|
|
case MetaKeyID:
|
|
if skey.MetaKeyID == "" {
|
|
return fmt.Errorf("'keyid' metadata parameter was listed but missing MetaKeyID value'")
|
|
}
|
|
if !isSafeString(skey.MetaKeyID) {
|
|
return fmt.Errorf("'keyid' metadata parameter can only contain printable characters'")
|
|
}
|
|
case MetaTag:
|
|
if skey.MetaTag == "" {
|
|
return fmt.Errorf("'tag' metadata parameter was listed but missing MetaTag value'")
|
|
}
|
|
if !isSafeString(skey.MetaTag) {
|
|
return fmt.Errorf("'tag' metadata parameter can only contain printable characters'")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (sp SigningProfile) withDefaults() SigningProfile {
|
|
final := SigningProfile{
|
|
Algorithm: sp.Algorithm,
|
|
Digest: sp.Digest,
|
|
Fields: sp.Fields,
|
|
Metadata: sp.Metadata,
|
|
Label: sp.Label,
|
|
ExpiresDuration: sp.ExpiresDuration,
|
|
Nonce: NonceRandom32,
|
|
}
|
|
// Defaults
|
|
if final.Label == "" {
|
|
final.Label = DefaultSignatureLabel
|
|
}
|
|
if final.ExpiresDuration == 0 {
|
|
final.ExpiresDuration = time.Minute * 5
|
|
}
|
|
if final.Digest == "" {
|
|
final.Digest = DigestSHA256
|
|
}
|
|
|
|
return final
|
|
}
|
|
|
|
func (sf SignedField) componentID() componentID {
|
|
item := sfv.NewItem(sf.Name)
|
|
for key, param := range sf.Parameters {
|
|
item.Params.Add(key, param)
|
|
}
|
|
return componentID{
|
|
Name: strings.ToLower(sf.Name),
|
|
Item: item,
|
|
}
|
|
}
|
|
|
|
type signedFields []SignedField
|
|
|
|
func (sf signedFields) includes(field string) bool {
|
|
target := strings.ToLower(field)
|
|
for _, fld := range sf {
|
|
if fld.Name == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (a Algorithm) symmetric() bool {
|
|
switch a {
|
|
case Algo_HMAC_SHA256:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
func componentsIDs(sfs []SignedField) []componentID {
|
|
cIDs := []componentID{}
|
|
for _, sf := range sfs {
|
|
cIDs = append(cIDs, sf.componentID())
|
|
}
|
|
|
|
return cIDs
|
|
}
|
|
|
|
func nonceRandom32() (string, error) {
|
|
nonce := make([]byte, 32)
|
|
n, err := rand.Read(nonce)
|
|
if err != nil || n < 32 {
|
|
return "", fmt.Errorf("could not generate nonce")
|
|
}
|
|
return base64.StdEncoding.EncodeToString(nonce), nil
|
|
}
|
|
|
|
func isSafeString(s string) bool {
|
|
for _, c := range s {
|
|
if !unicode.IsPrint(c) {
|
|
return false
|
|
}
|
|
if c > unicode.MaxASCII {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|