Skip to content

Commit

Permalink
shared/trust: Add HMAC utilities
Browse files Browse the repository at this point in the history
Signed-off-by: Julian Pelizäus <[email protected]>
  • Loading branch information
roosterfish committed Aug 23, 2024
1 parent 34727be commit 2098a29
Showing 1 changed file with 251 additions and 0 deletions.
251 changes: 251 additions & 0 deletions shared/trust/hmac.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
package trust

import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"hash"
"io"
"net/http"
"strings"

"golang.org/x/crypto/argon2"

"github.com/canonical/lxd/shared/api"
)

// HMACVersion indicates the version used for the authorization header format.
type HMACVersion string

// HMACConf represents the HMAC configuration.
type HMACConf struct {
HashFunc func() hash.Hash
Passphrase []byte
Version HMACVersion
}

// HMACFormat represents arbitrary formats to diplay and parse the actual HMAC.
// Implementations like argon extend the format with an additional salt.
type HMACFormat interface {
Passphrase(passphrase []byte) []byte
Header(version HMACVersion, hmac []byte) string
ParseHeader(header string) (HMACVersion, []byte, error)
}

// HMAC represents the the tooling for creating and validating HMACs.
type HMAC struct {
conf HMACConf
format HMACFormat
}

// HMACFormatDefault represents the default format.
type HMACFormatDefault struct{}

// HMACFormatArgon represents the argon2 format.
type HMACFormatArgon struct {
Salt []byte
Time uint32
Memory uint32
Threads uint8
KeyLength uint32
}

// NewDefaultHMACConf returns the default configuration for HMAC.
func NewDefaultHMACConf(passphrase string, version HMACVersion) HMACConf {
return HMACConf{
Passphrase: []byte(passphrase),
HashFunc: sha256.New,
Version: version,
}
}

// Passphrase accepts the outer HMAC passphrase and returns the same without modification.
func (h *HMACFormatDefault) Passphrase(passphrase []byte) []byte {
return passphrase
}

// Header returns the actual HMAC alongside together with the used version.
func (h *HMACFormatDefault) Header(version HMACVersion, hmac []byte) string {
return fmt.Sprintf("%s %s", version, hex.EncodeToString(hmac))
}

// ParseHeader extracts the actual version and HMAC from the header.
func (h *HMACFormatDefault) ParseHeader(header string) (HMACVersion, []byte, error) {
authHeaderSplit := strings.Split(header, " ")
if len(authHeaderSplit) != 2 {
return "", nil, fmt.Errorf("Authorization header is missing the version: %q", header)
}

hmac, err := hex.DecodeString(authHeaderSplit[1])
if err != nil {
return "", nil, fmt.Errorf("Failed to decode the HMAC: %w", err)
}

return HMACVersion(authHeaderSplit[0]), hmac, nil
}

// NewDefaultHMACFormatArgon returns the default format for argon2.
// Recommended defaults according to https://www.rfc-editor.org/rfc/rfc9106#section-4-6.2.
// We use the second recommended option to not require a system having 2 GiB of memory.
func NewDefaultHMACFormatArgon() (*HMACFormatArgon, error) {
// 128 bit salt.
salt := make([]byte, 16)
_, err := rand.Read(salt)
if err != nil {
return nil, fmt.Errorf("Failed to create salt: %w", err)
}

return &HMACFormatArgon{
Salt: salt,
// 3 iterations.
Time: 3,
// 64 MiB memory.
Memory: 64 * 1024,
// 4 lanes.
Threads: 4,
// 256 bit tag size.
KeyLength: 32,
}, nil
}

// Passphrase accepts the outer HMAC passphrase and runs it through the argon key derivation function.
func (h *HMACFormatArgon) Passphrase(passphrase []byte) []byte {
return argon2.IDKey(passphrase, h.Salt, h.Time, h.Memory, h.Threads, h.KeyLength)
}

// Header returns the actual HMAC alongside it's salt together with the used version.
func (h *HMACFormatArgon) Header(version HMACVersion, hmac []byte) string {
return fmt.Sprintf("%s %s:%s", version, hex.EncodeToString(h.Salt), hex.EncodeToString(hmac))
}

// ParseHeader extracts the actual version, HMAC and it's salt from the header.
func (h *HMACFormatArgon) ParseHeader(header string) (HMACVersion, []byte, error) {
authHeaderSplit := strings.Split(header, " ")
if len(authHeaderSplit) != 2 {
return "", nil, fmt.Errorf("Authorization header is missing the version: %q", header)
}

authHeaderDetails := strings.Split(authHeaderSplit[1], ":")
if len(authHeaderDetails) != 2 {
return "", nil, fmt.Errorf("Authorization header is missing name and/or hash: %q", header)
}

salt, err := hex.DecodeString(authHeaderDetails[0])
if err != nil {
return "", nil, fmt.Errorf("Failed to decode the name: %w", err)
}

h.Salt = salt

hmac, err := hex.DecodeString(authHeaderDetails[1])
if err != nil {
return "", nil, fmt.Errorf("Failed to decode the HMAC: %w", err)
}

return HMACVersion(authHeaderSplit[0]), hmac, nil
}

// NewHMAC returns a new instance of HMAC.
func NewHMAC(conf HMACConf) *HMAC {
return &HMAC{
conf: conf,
format: &HMACFormatDefault{},
}
}

// WithFormat configures the HMAC instance to use the specified format.
// The format can be used to extend the way of displaying and parsing the resulting header.
func (h *HMAC) WithFormat(format HMACFormat) *HMAC {
h.format = format
return h
}

// WriteBytes creates a new HMAC hash using the given bytes.
func (h *HMAC) WriteBytes(b []byte) ([]byte, error) {
mac := hmac.New(h.conf.HashFunc, h.format.Passphrase(h.conf.Passphrase))
_, err := mac.Write(b)
if err != nil {
return nil, fmt.Errorf("Failed to create HMAC: %w", err)
}

return mac.Sum(nil), nil
}

// WriteJSON creates a new HMAC hash using the given struct.
func (h *HMAC) WriteJSON(v any) ([]byte, error) {
payload, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("Failed to marshal payload: %w", err)
}

return h.WriteBytes(payload)
}

// WriteRequest creates a new HMAC hash using the given request.
// It will extract the requests body.
func (h *HMAC) WriteRequest(r *http.Request) ([]byte, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, fmt.Errorf("Failed to read request body: %w", err)
}

defer func() {
// Reset the request body for the actual handler.
r.Body = io.NopCloser(bytes.NewBuffer(body))
}()

err = r.Body.Close()
if err != nil {
return nil, fmt.Errorf("Failed to close the request body: %w", err)
}

return h.WriteBytes(body)
}

// AuthorizationHeader returns the authorization header for the given HMAC format.
func (h *HMAC) AuthorizationHeader(v any) (string, error) {
hmacBytes, err := h.WriteJSON(v)
if err != nil {
return "", err
}

return h.format.Header(h.conf.Version, hmacBytes), nil
}

// equal returns true in case hmac1 is identical to hmac2.
func (h *HMAC) equal(hmac1 []byte, hmac2 []byte) bool {
return hmac.Equal(hmac1, hmac2)
}

// ValidateAuthorizationHeader extracts the HMAC from the Authorization header and
// validates if it matches the HMAC computed over the requests body.
func (h *HMAC) ValidateAuthorizationHeader(r *http.Request) error {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return api.StatusErrorf(http.StatusBadRequest, "Authorization header is missing")
}

version, hmacHeader, err := h.format.ParseHeader(authHeader)
if err != nil {
return api.StatusErrorf(http.StatusBadRequest, "Failed to parse Authorization header: %w", err)
}

if version != h.conf.Version {
return api.StatusErrorf(http.StatusBadRequest, "Authorization header uses version %q but expected %q", version, h.conf.Version)
}

hmacBody, err := h.WriteRequest(r)
if err != nil {
return api.StatusErrorf(http.StatusInternalServerError, "Failed to write request body: %w", err)
}

if !h.equal(hmacHeader, hmacBody) {
return api.StatusErrorf(http.StatusForbidden, "Invalid HMAC")
}

return nil
}

0 comments on commit 2098a29

Please sign in to comment.