Skip to content
Merged
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
259 changes: 259 additions & 0 deletions yahash/yahash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
// Package yahash provides a small, generic helper around hashing functions that
// makes it trivial to combine arbitrary user‑supplied data with a secret (salt)
// **and** a rolling time component.
//
// ## Typical use‑case
//
// - Build short‑lived tokens or "request signatures" that must be recomputed on
// the server side and validated within an allowed time window.
//
// - Generate cache‑keys that expire automatically when the configured
// `interval` elapses.
//
// - Quickly protect a webhook or URL with a deterministic but time‑bounded
// hash without the overhead of full‑blown JWT or HMAC libraries.
//
// The API is intentionally minimal: you bring **any** hashing algorithm (as a
// `HashFunc`) and the helper takes care of salting it with the secret and with
// a truncated Unix‑timestamp.
//
// # Example (basic, secret‑only)
//
// The simplest scenario hashes an arbitrary string together with a secret:
//
// package main
//
// import (
// "fmt"
// "time"
//
// "github.com/YaCodeDev/GoYaCodeDevUtils/yahash"
// )
//
// func main() {
// hasher := yahash.NewHash[yahash.HashableType, int64](
// yahash.FNVStringToInt64,
// "my‑super‑secret", // salt
// time.Minute, // irrelevant here, no time component
// 0, // no backwards validation window
// )
//
// h := hasher.Hash("payload")
// fmt.Println(h)
// }
//
// # Example (time‑bound validation)
//
// secret := "yanesupertestsecret"
// data := []string{"yadatetestlolkek", "polliizz", "yanevlad_"}
//
// // Create a token valid for five one‑hour periods back.
// hasher := yahash.NewHash(yahash.FNVStringToInt64, secret, time.Hour, 5)
//
// // The client computes a hash for "now".
// expected := hasher.HashWithTime(time.Now(), data...)
//
// // The server receives `expected` and validates it – it will compare against
// // the current period and the previous five.
// if ok := hasher.Validate(expected, data...); !ok {
// // reject request
// }
//
// ----------------------------------------------------------------------------------
package yahash

import (
"hash/fnv"
"strconv"
"time"

"github.com/YaCodeDev/GoYaCodeDevUtils/valueparser"
)

// HashableType describes the set of types that can be parsed from / converted to
// a string by the *valueparser* package **and** that you intend to feed into the
// supplied hashing function. In practice it will usually resolve to `string`,
// `int64`, or another scalar type supported by parser.
type HashableType valueparser.ParsableType

// HashFunc is the signature every hashing algorithm must satisfy in order to be
// used with `Hash`.
//
// - *I* – the input type (usually `string`).
// - *O* – the output type **must** be `comparable` so that we can check equality
// when validating.
//
// A hash function receives the main *data* plus zero or more *args* that are
// already salted with the secret (see implementation) – so it can simply write
// them into its internal state.
//
// For example, `FNVStringToInt64` below fulfils this contract.
//
// # Example
//
// custom := func(s string, args ...string) uint32 {
// h := fnv.New32()
// h.Write([]byte(s))
// for _, a := range args { h.Write([]byte(a)) }
// return h.Sum32()
// }
//
// _ = yahash.NewHash(custom, "secret", time.Second, 3)
//
// (note that `uint32` is *comparable*, so it is allowed).
type HashFunc[I HashableType, O comparable] func(data I, args ...I) O

// Hash bundles a hashing function together with:
//
// - *secret* – an extra argument automatically appended to every call so the
// output cannot be reproduced without knowing it (simple salting).
//
// - *interval* – the size of a time‑window;
//
// - *back* – how many *previous* windows are accepted during validation
//
// In other words, the triple *interval / back / secret* defines the security
// model while `HashFunc` defines the actual mathematical transform.
//
// The zero value is **not** usable; always construct via `NewHash`.
type Hash[I HashableType, O comparable] struct {
hasher HashFunc[I, O]
stepInterval time.Duration // ≥ 1s after constructor check
secret I
backStepCount uint16
}

// NewHash returns an initialised Hash helper.
//
// • `stepInterval` shorter than one second is automatically promoted to exactly one
// second – sub-second windows rarely make sense and can break when system
// clocks are not precise.
//
// • `backStepCount` accepts *N* previous windows for validation
//
// # Panics
//
// The function does **not** panic; all parameters are sanitised.
//
// # Example
//
// hasher := yahash.NewHash(yahash.FNVStringToInt64, "secret", time.Minute, 5)
// hash := hasher.HashWithTime(time.Now())
// if !hasher.Validate(hash) { /* reject */ }
func NewHash[I HashableType, O comparable](
hasher HashFunc[I, O],
secret I,
stepInterval time.Duration,
backStepCount uint16,
) Hash[I, O] {
if stepInterval < time.Second {
stepInterval = time.Second
}

return Hash[I, O]{
hasher: hasher,
secret: secret,
stepInterval: stepInterval,
backStepCount: backStepCount,
}
}

// Hash hashes *data* together with optional extra arguments **and** the secret.
//
// The secret is always appended as the last argument so that callers do not have
// to remember to pass it explicitly – a common pitfall when the hashing happens
// in several places.
//
// # Example
//
// hash := hasher.Hash("yadata", "ya_args1", "ya_args2")
func (h *Hash[I, O]) Hash(data I, args ...I) O {
return h.hasher(data, append(args, h.secret)...)
}

// HashWithTime is identical to `Hash` but replaces *data* with a
// Unix‑timestamp. This effectively rolls the secret every
// *interval* and makes tokens time‑bound.
//
// # Example
//
// // within request handler:
// hash := hasher.HashWithTime(time.Now(), userID)
func (h *Hash[I, O]) HashWithTime(inputTime time.Time, args ...I) O {
parsedTime, _ := valueparser.
ParseValue[I](
strconv.FormatInt(inputTime.Unix()/int64(h.stepInterval/time.Second), 10)) // SAFETY: This cannot return error

return h.hasher(parsedTime, append(args, h.secret)...)
}

// ValidateWithoutTime recomputes a hash **without** the time component and
// compares it to *expected*.
//
// This is useful when you only need salting (secret) but still want a unified
// API together with the time‑aware helpers.
//
// # Example
//
// if !hasher.ValidateWithoutTime(expected, payload) {
// // tampered
// }
func (h *Hash[I, O]) ValidateWithoutTime(expected O, data I, args ...I) bool {
return h.Hash(data, args...) == expected
}

// Validate recomputes the hash for the **current** time‑window and for *back*
// previous ones (inclusive) and returns whether any of them match *expected*.
//
// # Example
//
// expected := hasher.HashWithTime(time.Now().Add(-2*time.Hour), "ya_args")
//
// if ok := hasher.Validate(expected, "ya_args"); !ok {
// // expired
// }
func (h *Hash[I, O]) Validate(expected O, args ...I) bool {
return h.ValidateWithCustomBackStepCount(expected, h.backStepCount, args...)
}

// ValidateWithCustomBackStepCount behaves like `Validate` but lets the caller specify a
// custom *back* window on a per‑call basis.
//
// This is handy when the acceptable drift is not known at construction time or
// when different endpoints require different policies.
func (h *Hash[I, O]) ValidateWithCustomBackStepCount(expected O, backStepCount uint16, args ...I) bool {
now := time.Now()

for i := 0; i <= int(backStepCount); i++ {
date := now.Add(h.stepInterval * -time.Duration(i))
generated := h.HashWithTime(date, args...)

if generated == expected {
return true
}
}

return false
}

// FNVStringToInt64 is a ready‑to‑use 64‑bit FNV‑1a implementation compatible
// with the `HashFunc` signature.
//
// It concatenates *data* and *args* (already salted) and returns the 64‑bit
// digest as a signed integer (`int64`).
//
// # Example
//
// hasher := yahash.NewHash(yahash.FNVStringToInt64, "secret", time.Minute, 3)
// code := hasher.HashWithTime(time.Now(), "ya_args")
// fmt.Println(code)
func FNVStringToInt64(data string, args ...string) int64 {
hasher := fnv.New64()
hasher.Write([]byte(data))

for _, arg := range args {
hasher.Write([]byte(arg))
}

return int64(hasher.Sum64())
}
101 changes: 101 additions & 0 deletions yahash/yahash_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package yahash_test

import (
"fmt"
"strconv"
"testing"
"time"

"github.com/YaCodeDev/GoYaCodeDevUtils/yahash"
"github.com/stretchr/testify/assert"
)

var (
testDataForHash = []string{"yadatetestlolkek", "polliizz", "yanevlad_"}
secret = "yanesupertestsecret"
testHash = yahash.NewHash(yahash.FNVStringToInt64, secret, time.Hour, 5)
)

func TestHash64_DeterministicWorks(t *testing.T) {
data := "yadata"

hash1 := testHash.Hash(data, testDataForHash...)
hash2 := testHash.Hash(data, testDataForHash...)

assert.Equal(t, hash1, hash2, fmt.Sprintf("Hash not deterministic: got %d and %d", hash1, hash2))
}

func TestHash64WithTime_DeterministicWorks(t *testing.T) {
now := time.Now()

hash1 := testHash.HashWithTime(now, testDataForHash...)
hash2 := testHash.HashWithTime(now, testDataForHash...)

assert.Equal(t, hash1, hash2, fmt.Sprintf("Hash64WithTime not deterministic: got %d and %d", hash1, hash2))
}

func TestHash_Matches_HashWithTime(t *testing.T) {
now := time.Now()

hash := testHash.Hash(
strconv.FormatInt(now.Unix()/int64(time.Hour/time.Second), 10), testDataForHash...,
)
hashWithTime := testHash.HashWithTime(now, testDataForHash...)

assert.Equal(t, hash, hashWithTime,
fmt.Sprintf("Hash64 doesn't match to Hash64WithTime. hash64: %d, hash64WithTime: %d", hash, hashWithTime))
}

func TestValidateHash_Works(t *testing.T) {
t.Parallel()

t.Run("[Validate] Works", func(t *testing.T) {
hash := yahash.NewHash(yahash.FNVStringToInt64, secret, time.Second, 5)

t.Run("True", func(t *testing.T) {
expected := hash.HashWithTime(time.Now().Add(-time.Second*4), testDataForHash...)

assert.True(t, hash.Validate(expected, testDataForHash...),
"Got `True` by valid hash with correct date")
})

t.Run("False", func(t *testing.T) {
expected := hash.HashWithTime(time.Now().Add(-time.Second*7), testDataForHash...)

assert.False(t, hash.Validate(expected, testDataForHash...),
"Got `True` by invalid hash with non correct date")
})
})

t.Run("[ValidateWithoutTime] Works", func(t *testing.T) {
data := "brizzinck"

expected := testHash.Hash(data, testDataForHash...)

t.Run("True", func(t *testing.T) {
assert.True(t, testHash.ValidateWithoutTime(expected, data, testDataForHash...),
"Got `False` by valid hash without time")
})

t.Run("False", func(t *testing.T) {
assert.False(t, testHash.ValidateWithoutTime(expected, data+"s", testDataForHash...),
"Got `True` by invalid hash without time")
})
})

t.Run("[ValidateWithCustomBackStepCount]", func(t *testing.T) {
t.Run("True", func(t *testing.T) {
expected := testHash.HashWithTime(time.Now().Add(-time.Hour*6), testDataForHash...)

assert.True(t, testHash.ValidateWithCustomBackStepCount(expected, 7, testDataForHash...),
"Got `False` by valid hash with correct date")
})

t.Run("False", func(t *testing.T) {
expected := testHash.HashWithTime(time.Now().Add(-time.Hour*16), testDataForHash...)

assert.False(t, testHash.ValidateWithCustomBackStepCount(expected, 10, testDataForHash...),
"Got `True` by invalid hash with non correct date")
})
})
}