diff --git a/internal/apikey/apikey.go b/internal/apikey/apikey.go new file mode 100644 index 0000000..da497dd --- /dev/null +++ b/internal/apikey/apikey.go @@ -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] + "_***" +} diff --git a/internal/apikey/apikey_test.go b/internal/apikey/apikey_test.go new file mode 100644 index 0000000..fce3b5b --- /dev/null +++ b/internal/apikey/apikey_test.go @@ -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) + } +}