diff --git a/docs/content/en/functions/crypto/Hash.md b/docs/content/en/functions/crypto/Hash.md new file mode 100644 index 000000000..44ebc6204 --- /dev/null +++ b/docs/content/en/functions/crypto/Hash.md @@ -0,0 +1,36 @@ +--- +title: crypto.Hash +description: Hashes the given input with the given algorithm and returns its checksum encoded to a hexadecimal string. +categories: [] +keywords: [] +params: + functions_and_methods: + aliases: [] + returnType: string + signatures: ['crypto.Hash [ALGORITHM] INPUT'] +--- + +The `ALGORITHM` is one of `md5`, `sha1`, `sha256` (the default), `sha384`, or `sha512`: + +```go-html-template +{{ crypto.Hash "sha256" "Hello world" }} → 64ec88ca00b268e5ba1a35678a1b5316d212f4f366b2477232534a8aeca37f3c +{{ "Hello world" | crypto.Hash "sha512" }} → b7f783baed8297f0db917462184ff4f08e69c2d5e5f79a942600f9725f58ce1f29c18139bf80b06c0fff2bdd34738452ecf40c488c22a7e3d80cdf6f9c1c0d47 +``` + +If you omit the algorithm, it defaults to `sha256`: + +```go-html-template +{{ "Hello world" | crypto.Hash }} → 64ec88ca00b268e5ba1a35678a1b5316d212f4f366b2477232534a8aeca37f3c +``` + +The supported algorithms match those used for the [Subresource Integrity] hash in [`.Data.Integrity`] on a fingerprinted resource. Combine `crypto.Hash` with [`encoding.HexDecode`] and [`encoding.Base64Encode`] to construct an SRI hash from a string: + +```go-html-template +{{ $algo := "sha256" }} +{{ $integrity := printf "%s-%s" $algo ("Hello world" | crypto.Hash $algo | encoding.HexDecode | encoding.Base64Encode) }} +``` + +[Subresource Integrity]: https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity +[`.Data.Integrity`]: /methods/resource/data/ +[`encoding.HexDecode`]: /functions/encoding/hexdecode/ +[`encoding.Base64Encode`]: /functions/encoding/base64encode/ diff --git a/docs/content/en/functions/encoding/HexDecode.md b/docs/content/en/functions/encoding/HexDecode.md new file mode 100644 index 000000000..f76095107 --- /dev/null +++ b/docs/content/en/functions/encoding/HexDecode.md @@ -0,0 +1,15 @@ +--- +title: encoding.HexDecode +description: Returns the hexadecimal decoding of the given content. +categories: [] +keywords: [] +params: + functions_and_methods: + aliases: [] + returnType: string + signatures: [encoding.HexDecode INPUT] +--- + +```go-html-template +{{ "48656c6c6f20776f726c64" | encoding.HexDecode }} → Hello world +``` diff --git a/docs/content/en/functions/encoding/HexEncode.md b/docs/content/en/functions/encoding/HexEncode.md new file mode 100644 index 000000000..6c2845a85 --- /dev/null +++ b/docs/content/en/functions/encoding/HexEncode.md @@ -0,0 +1,15 @@ +--- +title: encoding.HexEncode +description: Returns the hexadecimal encoding of the given content. +categories: [] +keywords: [] +params: + functions_and_methods: + aliases: [] + returnType: string + signatures: [encoding.HexEncode INPUT] +--- + +```go-html-template +{{ "Hello world" | encoding.HexEncode }} → 48656c6c6f20776f726c64 +``` diff --git a/tpl/crypto/crypto.go b/tpl/crypto/crypto.go index b2952e97e..23b8f79d6 100644 --- a/tpl/crypto/crypto.go +++ b/tpl/crypto/crypto.go @@ -68,6 +68,62 @@ func (ns *Namespace) SHA256(v any) (string, error) { return hex.EncodeToString(hash[:]), nil } +// Hash returns the hex-encoded checksum of v using the given algorithm; one of +// md5, sha1, sha256 (the default), sha384 or sha512. +// +// The supported algorithms match those used for the Subresource Integrity (SRI) +// hash in .Data.Integrity on fingerprinted resources, so an SRI hash can be +// constructed by combining this with encoding.HexDecode and encoding.Base64Encode. +func (ns *Namespace) Hash(args ...any) (string, error) { + var algo, v any + switch len(args) { + case 1: + algo, v = "sha256", args[0] + case 2: + algo, v = args[0], args[1] + default: + return "", fmt.Errorf("crypto.Hash: expected 1 or 2 arguments, got %d", len(args)) + } + + conv, err := cast.ToStringE(v) + if err != nil { + return "", err + } + + algoS, err := cast.ToStringE(algo) + if err != nil { + return "", err + } + + h, err := newHash(algoS) + if err != nil { + return "", err + } + + if _, err := h.Write([]byte(conv)); err != nil { + return "", err + } + + return hex.EncodeToString(h.Sum(nil)), nil +} + +func newHash(algo string) (hash.Hash, error) { + switch algo { + case "md5": + return md5.New(), nil + case "sha1": + return sha1.New(), nil + case "sha256": + return sha256.New(), nil + case "sha384": + return sha512.New384(), nil + case "sha512": + return sha512.New(), nil + default: + return nil, fmt.Errorf("crypto.Hash: %q is not a supported hash algorithm", algo) + } +} + // HMAC returns a cryptographic hash that uses a key to sign a message. func (ns *Namespace) HMAC(h any, k any, m any, e ...any) (string, error) { ha, err := cast.ToStringE(h) diff --git a/tpl/crypto/crypto_integration_test.go b/tpl/crypto/crypto_integration_test.go new file mode 100644 index 000000000..003a6f101 --- /dev/null +++ b/tpl/crypto/crypto_integration_test.go @@ -0,0 +1,46 @@ +// Copyright 2026 The Hugo Authors. All rights reserved. +// +// 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 crypto_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +// crypto.Hash combined with encoding.HexDecode and encoding.Base64Encode should +// reproduce the SRI hash in .Data.Integrity on a fingerprinted resource. +// See issue 15072. +func TestHashIntegrity(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +-- layouts/home.html -- +{{ $content := "hello world" }} +{{ range $algo := slice "sha256" "sha384" "sha512" }} +{{ $integrity := $content | resources.FromString "data.txt" | fingerprint $algo }} +{{ $composed := printf "%s-%s" $algo ($content | crypto.Hash $algo | encoding.HexDecode | encoding.Base64Encode) }} +{{ $algo }}: {{ eq $integrity.Data.Integrity $composed }} +{{ end }} +` + + b := hugolib.Test(t, files) + + b.AssertFileContent("public/index.html", + "sha256: true", + "sha384: true", + "sha512: true", + ) +} diff --git a/tpl/crypto/crypto_test.go b/tpl/crypto/crypto_test.go index b6b2a6915..4d7403512 100644 --- a/tpl/crypto/crypto_test.go +++ b/tpl/crypto/crypto_test.go @@ -101,6 +101,42 @@ func TestSHA256(t *testing.T) { } } +func TestHash(t *testing.T) { + t.Parallel() + c := qt.New(t) + ns := New() + + const in = "Hello world, gophers!" + + for i, test := range []struct { + args []any + expect any + }{ + // Default algo is sha256, matching ns.SHA256. + {[]any{in}, "6ec43b78da9669f50e4e422575c54bf87536954ccd58280219c393f2ce352b46"}, + {[]any{"md5", in}, "b3029f756f98f79e7f1b7f1d1f0dd53b"}, + {[]any{"sha1", in}, "c8b5b0e33d408246e30f53e32b8f7627a7a649d4"}, + {[]any{"sha256", in}, "6ec43b78da9669f50e4e422575c54bf87536954ccd58280219c393f2ce352b46"}, + {[]any{"sha384", in}, "e914b060e06f1115fd98b494257d652403305b585c29a54636e7262b5e44adfc61f195f03d4192d89a006b28192fdd25"}, + {[]any{"sha512", in}, "e2b74589547d8954a47321e19e2987ffce366317e3843be7da7eae3090a0eacb46393b52978933afa65c8bc365c329e55950b6106119a382a3b4f4cd5886ddcf"}, + {[]any{"unsupported", in}, false}, + {[]any{}, false}, + {[]any{"sha256", in, "extra"}, false}, + } { + errMsg := qt.Commentf("[%d] %v", i, test.args) + + result, err := ns.Hash(test.args...) + + if b, ok := test.expect.(bool); ok && !b { + c.Assert(err, qt.Not(qt.IsNil), errMsg) + continue + } + + c.Assert(err, qt.IsNil, errMsg) + c.Assert(result, qt.Equals, test.expect, errMsg) + } +} + func TestHMAC(t *testing.T) { t.Parallel() c := qt.New(t) diff --git a/tpl/crypto/init.go b/tpl/crypto/init.go index b31237045..2f10d7ff2 100644 --- a/tpl/crypto/init.go +++ b/tpl/crypto/init.go @@ -1,4 +1,4 @@ -// Copyright 2017 The Hugo Authors. All rights reserved. +// Copyright 2026 The Hugo Authors. All rights reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -60,6 +60,16 @@ func init() { }, ) + ns.AddMethodMapping(ctx.Hash, + nil, + [][2]string{ + {`{{ crypto.Hash "sha256" "Hello world, gophers!" }}`, `6ec43b78da9669f50e4e422575c54bf87536954ccd58280219c393f2ce352b46`}, + {`{{ "Hello world" | crypto.Hash "sha256" }}`, `64ec88ca00b268e5ba1a35678a1b5316d212f4f366b2477232534a8aeca37f3c`}, + {`{{ "Hello world" | crypto.Hash }}`, `64ec88ca00b268e5ba1a35678a1b5316d212f4f366b2477232534a8aeca37f3c`}, + {`{{ "Hello world" | crypto.Hash "sha512" }}`, `b7f783baed8297f0db917462184ff4f08e69c2d5e5f79a942600f9725f58ce1f29c18139bf80b06c0fff2bdd34738452ecf40c488c22a7e3d80cdf6f9c1c0d47`}, + }, + ) + return ns }