Skip to content
Open
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
34 changes: 34 additions & 0 deletions ext/security/hmac/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")

package(
default_visibility = ["//visibility:public"],
licenses = ["notice"], # Apache 2.0
)

go_library(
name = "go_default_library",
srcs = [
"hmac.go",
],
importpath = "github.com/google/cel-go/ext/security/hmac",
deps = [
"//cel:go_default_library",
"//common/types:go_default_library",
"//common/types/ref:go_default_library",
],
)

go_test(
name = "go_default_test",
size = "small",
srcs = [
"hmac_test.go",
],
embed = [
":go_default_library",
],
deps = [
"//cel:go_default_library",
"//ext:go_default_library",
],
)
359 changes: 359 additions & 0 deletions ext/security/hmac/hmac.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,359 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package hmac implements CEL extension functions for Hash-based Message Authentication Code (HMAC) verification and computation.
package hmac

import (
"crypto"
"crypto/hmac"
_ "crypto/md5"
_ "crypto/sha1"
_ "crypto/sha256"
_ "crypto/sha512"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"

"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
)

// Library returns a cel.EnvOption to configure extended functions for HMAC signature verification and computation.
func Library(options ...Option) cel.EnvOption {
l := &hmacLib{
version: ^uint32(0),
customAlgorithms: make(map[string]crypto.Hash),
}
for _, o := range options {
l = o(l)
}
if len(l.customAlgorithms) == 0 {
l = CommonAlgorithms()(l)
}
return cel.Lib(l)
}

// Option declares a functional operator for configuring HMAC extension library behavior.
type Option func(*hmacLib) *hmacLib

// Version sets the library version for HMAC extensions.
func Version(version uint32) Option {
return func(l *hmacLib) *hmacLib {
l.version = version
return l
}
}

// MaxPrefixLength sets the maximum signature prefix length to parse during verification.
// Defaults to 20.
func MaxPrefixLength(limit int) Option {
return func(l *hmacLib) *hmacLib {
l.maxPrefixLength = limit
return l
}
}

// Algorithm registers a crypto.Hash algorithm with optional aliases
// (e.g. Algorithm(crypto.SHA256, "HS256")),
// exposing constant declarations (e.g., hmac.SHA256, hmac.HS256) in CEL and enabling it for HMAC operations.
func Algorithm(h crypto.Hash, aliases ...string) Option {
return func(l *hmacLib) *hmacLib {
if l.customAlgorithms == nil {
l.customAlgorithms = make(map[string]crypto.Hash)
}
name := h.String()
normName := normalizeAlgName(name)
l.customAlgorithms[normName] = h
l.customAlgorithms[name] = h
for _, alias := range aliases {
l.customAlgorithms[normalizeAlgName(alias)] = h
l.customAlgorithms[alias] = h
}

if normName != "" {
l.addConstant("hmac."+normName, normName)
}
for _, alias := range aliases {
constAlias := normalizeAlgName(alias)
if constAlias != "" {
l.addConstant("hmac."+constAlias, normName)
}
}

return l
}
}

// CommonAlgorithms registers the most common HMAC hash algorithms (SHA256, SHA384, SHA512, SHA224, SHA512/256, SHA512/224)
// along with their JOSE/JWT aliases (HS256, HS384, HS512, HS224, HS512/256, HS512/224) using Algorithm options by proxy.
func CommonAlgorithms() Option {
return func(l *hmacLib) *hmacLib {
opts := []Option{
Algorithm(crypto.SHA256, "HS256"),
Algorithm(crypto.SHA384, "HS384"),
Algorithm(crypto.SHA512, "HS512"),
Algorithm(crypto.SHA224, "HS224"),
Algorithm(crypto.SHA512_256, "HS512_256"),
Algorithm(crypto.SHA512_224, "HS512_224"),
}
for _, opt := range opts {
l = opt(l)
}
return l
}
}

type celConstant struct {
name string
val string
}

type hmacLib struct {
version uint32
maxPrefixLength int
customAlgorithms map[string]crypto.Hash
constants []celConstant
}

func (l *hmacLib) addConstant(name, val string) {
for _, c := range l.constants {
if c.name == name {
return
}
}
l.constants = append(l.constants, celConstant{name: name, val: val})
}

// LibraryName returns the CEL library identifier string.
func (*hmacLib) LibraryName() string {
return "cel.lib.ext.security.hmac"
}

// CompileOptions returns environment options for declaring CEL functions and constants.
func (l *hmacLib) CompileOptions() []cel.EnvOption {
var opts []cel.EnvOption

for _, c := range l.constants {
opts = append(opts, cel.Constant(c.name, cel.StringType, types.String(c.val)))
}

opts = append(opts,
cel.Function("hmac.verify",
cel.Overload("hmac_verify_bytes_bytes_bytes_string",
[]*cel.Type{cel.BytesType, cel.BytesType, cel.BytesType, cel.StringType},
cel.BoolType,
cel.FunctionBinding(func(args ...ref.Val) ref.Val {
msg := args[0].(types.Bytes)
sig := args[1].(types.Bytes)
secret := args[2].(types.Bytes)
alg := args[3].(types.String)
return types.Bool(l.verifyBytes(msg, sig, secret, string(alg)))
}),
),
cel.Overload("hmac_verify_string_string_string_string",
[]*cel.Type{cel.StringType, cel.StringType, cel.StringType, cel.StringType},
cel.BoolType,
cel.FunctionBinding(func(args ...ref.Val) ref.Val {
msg := args[0].(types.String)
sig := args[1].(types.String)
secret := args[2].(types.String)
alg := args[3].(types.String)
return types.Bool(l.verifyString(string(msg), string(sig), string(secret), string(alg)))
}),
),
),

cel.Function("hmac.compute",
cel.Overload("hmac_compute_bytes_bytes_string",
[]*cel.Type{cel.BytesType, cel.BytesType, cel.StringType},
cel.BytesType,
cel.FunctionBinding(func(args ...ref.Val) ref.Val {
msg := args[0].(types.Bytes)
secret := args[1].(types.Bytes)
alg := args[2].(types.String)
mac, err := l.compute(msg, secret, string(alg))
if err != nil {
return types.ValOrErr(args[0], "%v", err)
}
return types.Bytes(mac)
}),
),
cel.Overload("hmac_compute_string_string_string",
[]*cel.Type{cel.StringType, cel.StringType, cel.StringType},
cel.BytesType,
cel.FunctionBinding(func(args ...ref.Val) ref.Val {
msg := args[0].(types.String)
secret := args[1].(types.String)
alg := args[2].(types.String)
mac, err := l.compute([]byte(string(msg)), []byte(string(secret)), string(alg))
if err != nil {
return types.ValOrErr(args[0], "%v", err)
}
return types.Bytes(mac)
}),
),
),
)

return opts
}

// ProgramOptions returns program options for HMAC extensions.
func (l *hmacLib) ProgramOptions() []cel.ProgramOption {
return nil
}

func (l *hmacLib) compute(msg, secret []byte, alg string) ([]byte, error) {
hType, err := l.resolveHash(alg)
if err != nil {
return nil, err
}
return computeHMAC(msg, secret, hType)
}

func (l *hmacLib) verifyBytes(msg, sig, secret []byte, alg string) bool {
hType, err := l.resolveHash(alg)
if err != nil {
return false
}
expectedMAC, err := computeHMAC(msg, secret, hType)
if err != nil {
return false
}
return hmac.Equal(expectedMAC, sig)
}

func (l *hmacLib) verifyString(msgStr, sigStr, secretStr, alg string) bool {
sigStr = strings.TrimSpace(sigStr)
detectedAlg, cleanSig := l.parseSignaturePrefix(sigStr)
effectiveAlg := alg
if detectedAlg != "" {
effectiveAlg = detectedAlg
}

hType, err := l.resolveHash(effectiveAlg)
if err != nil {
return false
}

expectedMAC, err := computeHMAC([]byte(msgStr), []byte(secretStr), hType)
if err != nil {
return false
}

// Try hex decoding
if hexBytes, err := hex.DecodeString(cleanSig); err == nil && len(hexBytes) == len(expectedMAC) {
if hmac.Equal(expectedMAC, hexBytes) {
return true
}
}

// Try base64 standard decoding
if b64Bytes, err := decodeBase64StdSegment(cleanSig); err == nil && len(b64Bytes) == len(expectedMAC) {
if hmac.Equal(expectedMAC, b64Bytes) {
return true
}
}

// Try base64 URL decoding
if b64URLBytes, err := decodeBase64URLSegment(cleanSig); err == nil && len(b64URLBytes) == len(expectedMAC) {
if hmac.Equal(expectedMAC, b64URLBytes) {
return true
}
}

// Fallback raw string comparison
return hmac.Equal(expectedMAC, []byte(cleanSig))
}

func (l *hmacLib) parseSignaturePrefix(sig string) (string, string) {
sig = strings.TrimSpace(sig)
limit := l.maxPrefixLength
if limit <= 0 {
limit = 20
}
if idx := strings.Index(sig, "="); idx > 0 && idx < limit {
prefix := strings.TrimSpace(sig[:idx])
rest := strings.TrimSpace(sig[idx+1:])

normPrefix := normalizeAlgName(prefix)
if _, ok := l.customAlgorithms[normPrefix]; ok {
for name := range l.customAlgorithms {
if normalizeAlgName(name) == normPrefix {
return name, rest
}
}
}
if _, ok := l.customAlgorithms[prefix]; ok {
return prefix, rest
}

if strings.EqualFold(prefix, "v1") || strings.EqualFold(prefix, "v0") {
return "", rest
}
}
return "", sig
}

func normalizeAlgName(alg string) string {
s := strings.TrimSpace(alg)
s = strings.ReplaceAll(s, "-", "_")
s = strings.ReplaceAll(s, "/", "_")
s = strings.ToUpper(s)
if after, ok := strings.CutPrefix(s, "SHA_"); ok {
s = "SHA" + after
}
return s
}

func (l *hmacLib) resolveHash(alg string) (crypto.Hash, error) {
norm := normalizeAlgName(alg)
for name, h := range l.customAlgorithms {
if strings.EqualFold(alg, name) || norm == normalizeAlgName(name) {
return h, nil
}
}

return 0, fmt.Errorf("unsupported HMAC hash algorithm: %q", alg)
}

func computeHMAC(msg, secret []byte, hType crypto.Hash) ([]byte, error) {
if !hType.Available() {
return nil, fmt.Errorf("hash algorithm %v is not available", hType)
}
mac := hmac.New(hType.New, secret)
mac.Write(msg)
return mac.Sum(nil), nil
}

// decodeBase64URLSegment decodes a URL-safe base64 string with or without padding.
func decodeBase64URLSegment(seg string) ([]byte, error) {
seg = strings.TrimSpace(seg)
if data, err := base64.RawURLEncoding.DecodeString(seg); err == nil {
return data, nil
}
return base64.URLEncoding.DecodeString(seg)
}

// decodeBase64StdSegment decodes a standard base64 string with or without padding.
func decodeBase64StdSegment(seg string) ([]byte, error) {
seg = strings.TrimSpace(seg)
if data, err := base64.RawStdEncoding.DecodeString(seg); err == nil {
return data, nil
}
return base64.StdEncoding.DecodeString(seg)
}
Loading
Loading