feat(idcodec): add hashid obfuscation derived from jwt secret

New pkg/idcodec wraps sqids with an alphabet derived from the JWT
secret via HKDF-SHA256 (info="nezha/idcodec/alphabet/v1"). Rotating
NZ_JWTSECRETKEY automatically reshuffles the alphabet, which doubles
as a kill switch for outstanding hashids without touching the encoder
itself.

The base alphabet drops visually-confusable characters (0/O/o/I/l/1)
and MinLength=8 hides small integer ids. Decode round-trips through
Encode to reject inputs that decode by accident under the same
alphabet.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
naiba
2026-05-26 03:50:36 +00:00
co-authored by cloudcode
parent 6811f730e9
commit 304afd9e09
4 changed files with 236 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
package idcodec
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"errors"
"io"
"sync"
"github.com/sqids/sqids-go"
"golang.org/x/crypto/hkdf"
)
const (
baseAlphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
hkdfInfo = "nezha/idcodec/alphabet/v1"
minLength = 8
minMasterKey = 32
)
var (
ErrNotInitialized = errors.New("idcodec: not initialized")
ErrInvalidCode = errors.New("idcodec: invalid id code")
ErrMasterKeyShort = errors.New("idcodec: master key too short")
mu sync.RWMutex
encoder *sqids.Sqids
)
func Init(masterKey []byte) error {
if len(masterKey) < minMasterKey {
return ErrMasterKeyShort
}
alphaKey := make([]byte, 32)
if _, err := io.ReadFull(hkdf.New(sha256.New, masterKey, nil, []byte(hkdfInfo)), alphaKey); err != nil {
return err
}
enc, err := sqids.New(sqids.Options{
Alphabet: keyedShuffle(baseAlphabet, alphaKey),
MinLength: minLength,
Blocklist: []string{},
})
if err != nil {
return err
}
mu.Lock()
encoder = enc
mu.Unlock()
return nil
}
func Encode(id uint64) (string, error) {
mu.RLock()
enc := encoder
mu.RUnlock()
if enc == nil {
return "", ErrNotInitialized
}
return enc.Encode([]uint64{id})
}
func Decode(code string) (uint64, error) {
mu.RLock()
enc := encoder
mu.RUnlock()
if enc == nil {
return 0, ErrNotInitialized
}
nums := enc.Decode(code)
if len(nums) != 1 {
return 0, ErrInvalidCode
}
if got, err := enc.Encode(nums); err != nil || got != code {
return 0, ErrInvalidCode
}
return nums[0], nil
}
func keyedShuffle(alphabet string, key []byte) string {
runes := []rune(alphabet)
mac := hmac.New(sha256.New, key)
var counter uint64
var pool []byte
next := func() byte {
if len(pool) == 0 {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, counter)
counter++
mac.Reset()
mac.Write(buf)
pool = mac.Sum(nil)
}
b := pool[0]
pool = pool[1:]
return b
}
for i := len(runes) - 1; i > 0; i-- {
j := int(next()) % (i + 1)
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}