Files
2026-09-06 14:56:17 +02:00

144 lines
2.8 KiB
Go

// Key structure: hmy_live_<43 secret><6 checksum>
package apikey
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"hash/crc32"
"strings"
)
const (
Issuer = "hmy"
secretBytes = 32 // 256 bit
secretLen = 43
checksumLen = 6
bodyLen = secretLen + checksumLen
// Enough to identify, not enough to brute-force
displayLen = 16
)
var (
ErrMalformed = errors.New("apikey: malformed key")
ErrBadChecksum = errors.New("apikey: checksum mismatch")
ErrEnvironment = errors.New("apikey: unknown environment")
)
// Environment for live and test keys
type Environment string
const (
Live Environment = "live"
Test Environment = "test"
)
func (e Environment) valid() bool {
return e == Live || e == Test
}
// Generated is the result of issuing a key.
// Plaintext shown to owner and never stored.
type Generated struct {
Plaintext string
Hash []byte
Display string
Env Environment
}
// Castagnoli instead of IEEE table
var crcTable = crc32.MakeTable(crc32.Castagnoli)
func checksum(secret string) string {
sum := crc32.Checksum([]byte(secret), crcTable)
b := []byte{
byte(sum >> 24),
byte(sum >> 16),
byte(sum >> 8),
byte(sum),
}
return base64.RawURLEncoding.EncodeToString(b)
}
// Generate issues a ney key. Store Hash and Display, show Plaintext once
func Generate(env Environment) (Generated, error) {
if !env.valid() {
return Generated{}, ErrEnvironment
}
buf := make([]byte, secretBytes)
if _, err := rand.Read(buf); err != nil {
return Generated{}, fmt.Errorf("apikey: reading entropy: %w", err)
}
secret := base64.RawURLEncoding.EncodeToString(buf)
plaintext := Issuer + "_" + string(env) + "_" + secret + checksum(secret)
display := plaintext
if len(display) > displayLen {
display = display[:displayLen]
}
return Generated{
Plaintext: plaintext,
Hash: Hash(plaintext),
Display: display,
Env: env,
}, nil
}
// Hash is what is stored and looked up
func Hash(key string) []byte {
sum := sha256.Sum256([]byte(key))
return sum[:]
}
// Validate checks the structure and checksum. Call before hashing.
func Validate(key string) (Environment, error) {
parts := strings.SplitN(key, "_", 3)
if len(parts) != 3 {
return "", ErrMalformed
}
if parts[0] != Issuer {
return "", ErrMalformed
}
env := Environment(parts[1])
if !env.valid() {
return "", ErrEnvironment
}
body := parts[2]
if len(body) != bodyLen {
return "", ErrMalformed
}
secret, want := body[:secretLen], body[secretLen:]
if _, err := base64.RawURLEncoding.DecodeString(secret); err != nil {
return "", ErrMalformed
}
if checksum(secret) != want {
return "", ErrBadChecksum
}
return env, nil
}
// Redact makes the key safe for logging
func Redact(key string) string {
if len(key) <= displayLen {
return Issuer + "_***"
}
return key[:displayLen] + "_***"
}