apikeys
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
// 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] + "_***"
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateRoundTrip(t *testing.T) {
|
||||
for _, env := range []Environment{Live, Test} {
|
||||
g, err := Generate(env)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate(%s): %v", env, err)
|
||||
}
|
||||
|
||||
got, err := Validate(g.Plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate(%q): %v", g.Plaintext, err)
|
||||
}
|
||||
|
||||
if got != env {
|
||||
t.Errorf("environment round trip: got %q want %q", got, env)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(g.Plaintext, Issuer+"_"+string(env)+"_") {
|
||||
t.Errorf("prefix wrong: %q", g.Plaintext)
|
||||
}
|
||||
|
||||
if len(g.Hash) != 32 {
|
||||
t.Errorf("hash length %d, want 32 (the api.keys CHECK requires it)", len(g.Hash))
|
||||
}
|
||||
|
||||
if !bytes.Equal(g.Hash, Hash(g.Plaintext)) {
|
||||
t.Error("Generate's hash differs from Hash of its own plaintext")
|
||||
}
|
||||
|
||||
if len(g.Display) != displayLen {
|
||||
t.Errorf("display length %d, want %d", len(g.Display), displayLen)
|
||||
}
|
||||
|
||||
if strings.Contains(g.Plaintext[len(g.Display):], "") && g.Display == g.Plaintext {
|
||||
t.Error("display prefix is the whole key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateIsUnique(t *testing.T) {
|
||||
const n = 2000
|
||||
|
||||
seen := make(map[string]bool, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
|
||||
if seen[g.Plaintext] {
|
||||
t.Fatalf("duplicate key after %d generations", i)
|
||||
}
|
||||
|
||||
seen[g.Plaintext] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsUnknownEnvironment(t *testing.T) {
|
||||
if _, err := Generate(Environment("prod")); !errors.Is(err, ErrEnvironment) {
|
||||
t.Errorf("got %v, want ErrEnvironment", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMalformed(t *testing.T) {
|
||||
good, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
want error
|
||||
}{
|
||||
{"empty", "", ErrMalformed},
|
||||
{"no separators", "notakey", ErrMalformed},
|
||||
{"one separator", "hmy_live", ErrMalformed},
|
||||
{"wrong issuer", "xxx_live_" + good.Plaintext[9:], ErrMalformed},
|
||||
{"unknown env", "hmy_prod_" + good.Plaintext[9:], ErrEnvironment},
|
||||
{"body too short", "hmy_live_abc", ErrMalformed},
|
||||
{"body too long", good.Plaintext + "X", ErrMalformed},
|
||||
{"body truncated by one", good.Plaintext[:len(good.Plaintext)-1], ErrMalformed},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, err := Validate(c.key); !errors.Is(err, c.want) {
|
||||
t.Errorf("Validate(%q) = %v, want %v", c.key, err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The point of the checksum: a key with a typo is rejected without a database
|
||||
// round trip.
|
||||
func TestValidateCatchesTampering(t *testing.T) {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := []byte(g.Plaintext)
|
||||
caught := 0
|
||||
|
||||
// Flip one character at each position in the secret and confirm the
|
||||
// checksum notices.
|
||||
for i := len("hmy_live_"); i < len(body)-checksumLen; i++ {
|
||||
orig := body[i]
|
||||
|
||||
if orig == 'A' {
|
||||
body[i] = 'B'
|
||||
} else {
|
||||
body[i] = 'A'
|
||||
}
|
||||
|
||||
if _, err := Validate(string(body)); errors.Is(err, ErrBadChecksum) {
|
||||
caught++
|
||||
}
|
||||
|
||||
body[i] = orig
|
||||
}
|
||||
|
||||
total := len(body) - checksumLen - len("hmy_live_")
|
||||
|
||||
if caught != total {
|
||||
t.Errorf("checksum caught %d/%d single-character corruptions", caught, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashIsStable(t *testing.T) {
|
||||
const key = "hmy_live_0123456789abcdefghijklmnopqrstuvwxyzABCDEFGabcdef"
|
||||
|
||||
a, b := Hash(key), Hash(key)
|
||||
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Error("Hash is not deterministic")
|
||||
}
|
||||
|
||||
if bytes.Equal(a, Hash(key+"x")) {
|
||||
t.Error("Hash collided on different input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedact(t *testing.T) {
|
||||
g, err := Generate(Live)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := Redact(g.Plaintext)
|
||||
|
||||
if strings.Contains(g.Plaintext, r) && len(r) >= len(g.Plaintext) {
|
||||
t.Error("Redact returned the whole key")
|
||||
}
|
||||
|
||||
if len(r) > displayLen+3 {
|
||||
t.Errorf("Redact returned %d chars, too much", len(r))
|
||||
}
|
||||
|
||||
// A secret must never survive redaction.
|
||||
secret := g.Plaintext[len("hmy_live_"):]
|
||||
if strings.Contains(r, secret) {
|
||||
t.Error("Redact leaked the secret")
|
||||
}
|
||||
|
||||
if got := Redact("short"); got != "hmy_***" {
|
||||
t.Errorf("Redact(short) = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user