apikey

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 1, 2026 License: MIT Imports: 9 Imported by: 0

README

apikey

API key lifecycle for the HOROS ecosystem — generate, resolve, revoke, expire.

Keys are SHA-256 hashed before storage. The clear key is returned exactly once on creation and never persisted. Service scoping and dossier scoping restrict what a key can access.

Install

import "github.com/hazyhaar/pkg/apikey"

Quick start

// Open a dedicated store.
store, err := apikey.OpenStore("/data/keys.db")

// Or share an existing database.
store, err := apikey.OpenStoreWithDB(existingDB)

// Generate — clear key returned once, never stored.
clearKey, key, err := store.Generate(
    "key_"+uuid.Must(uuid.NewV7()).String(),
    userID,
    "My automation key",
    []string{"sas_ingester"},  // nil = all services
    60,                         // rate limit (req/min), 0 = unlimited
    apikey.WithDossier(dossierID), // optional: scope to one dossier
)
// Save clearKey now — it won't be available again.

// Resolve — validate and get metadata.
key, err := store.Resolve(clearKey)
if err != nil {
    // invalid format, unknown, revoked, or expired
}
if !key.HasService("sas_ingester") {
    // unauthorized for this service
}
if key.IsDossierScoped() && key.DossierID != targetDossier {
    // wrong dossier
}

// Revoke — irreversible.
err = store.Revoke(keyID)

// Lifecycle operations (refuse on revoked/non-existent keys).
err = store.SetExpiry(keyID, time.Now().Add(30*24*time.Hour).Format(time.RFC3339))
err = store.UpdateServices(keyID, []string{"sas_ingester", "veille"})

// List.
keys, err := store.List(ownerID)
keys, err := store.ListByDossier(dossierID)

Key format

hk_7f3a9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a
^^^
prefix "hk_" + 64 hex chars (32 random bytes) = 67 chars total
  • Prefix (hk_7f3a9): first 8 chars stored for visual identification
  • Hash: SHA-256 hex of the full clear key stored in DB
  • Clear key: returned once by Generate(), never persisted
  • Entropy: 256 bits via crypto/rand

Database schema

Single table api_keys with 11 columns. Auto-migrated on OpenStore/OpenStoreWithDB.

CREATE TABLE api_keys (
    id          TEXT PRIMARY KEY,
    prefix      TEXT NOT NULL,
    hash        TEXT NOT NULL UNIQUE,
    owner_id    TEXT NOT NULL,
    name        TEXT NOT NULL DEFAULT '',
    services    TEXT NOT NULL DEFAULT '[]',
    rate_limit  INTEGER NOT NULL DEFAULT 0,
    dossier_id  TEXT NOT NULL DEFAULT '',
    created_at  TEXT NOT NULL,
    expires_at  TEXT NOT NULL DEFAULT '',
    revoked_at  TEXT NOT NULL DEFAULT ''
);

Indexes: hash (unique), owner_id, prefix, dossier_id.

Service scoping

Services is a JSON array of authorized service names. An empty or nil list means wildcard — the key has access to all services.

key.HasService("sas_ingester") // true if services contains it or is empty

Dossier scoping

Keys can be bound to a single dossier via WithDossier(dossierID).

key.IsDossierScoped()  // true if DossierID != ""
key.DossierID          // the bound dossier, or "" for legacy/wildcard

Legacy keys (DossierID = "") have access to all dossiers owned by their owner.

Resolve guarantees

Resolve(clearKey) checks in order:

  1. Format validation (hk_ prefix)
  2. Hash lookup (SHA-256 → single row)
  3. Revocation check (revoked_at != "" → error)
  4. Expiration check (expires_at in the past → error)
  5. Service deserialization (json.Unmarshal)

Test

CGO_ENABLED=0 go test -v -count=1 ./apikey/...

28 tests including 12 audit tests covering JSON injection, duplicate IDs, operations on revoked/non-existent keys, PRAGMA convention compliance, and shared DB lifecycle.

Documentation

Overview

CLAUDE:SUMMARY API key lifecycle: generate, resolve, revoke, list. SHA-256 hashed storage, service-scoped, dossier-scoped, rate-limited. CLAUDE:DEPENDS modernc.org/sqlite, github.com/hazyhaar/pkg/trace CLAUDE:EXPORTS Store, Key, Generate, Resolve, Revoke, List, ListByDossier, Count, Option, WithDossier, StoreOption, WithMaxKeys, WithAudit, AuditFunc

Index

Constants

View Source
const Prefix = "hk_"

Prefix for all horoskeys.

Variables

This section is empty.

Functions

This section is empty.

Types

type AuditFunc

type AuditFunc func(event, keyID, ownerID string)

AuditFunc is called after successful key operations with the event name, key ID, and owner ID. For Revoke, ownerID is empty (not looked up).

type Key

type Key struct {
	ID        string   `json:"id"`
	Prefix    string   `json:"prefix"`               // first 8 chars of the clear key (for identification)
	Hash      string   `json:"-"`                    // SHA-256 of the full clear key — never exposed
	OwnerID   string   `json:"owner_id"`             // user_id of the key owner (for billing)
	Name      string   `json:"name"`                 // human label ("Mon LLM Claude", "Script backup")
	Services  []string `json:"services"`             // authorized services ["sas_ingester", "veille"]
	RateLimit int      `json:"rate_limit"`           // requests per minute (0 = unlimited)
	DossierID string   `json:"dossier_id,omitempty"` // scoped dossier (empty = legacy/wildcard)
	CreatedAt string   `json:"created_at"`
	ExpiresAt string   `json:"expires_at,omitempty"` // empty = never expires
	RevokedAt string   `json:"revoked_at,omitempty"` // non-empty = revoked
}

Key represents an API key record in the database.

func (*Key) HasService

func (k *Key) HasService(service string) bool

HasService checks if a resolved key is authorized for a given service.

func (*Key) IsDossierScoped

func (k *Key) IsDossierScoped() bool

IsDossierScoped returns true if this key is bound to a specific dossier.

type Option

type Option func(*generateOpts)

Option configures optional parameters for Generate.

func WithDossier

func WithDossier(dossierID string) Option

WithDossier binds the generated key to a specific dossier.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store wraps an SQLite database for API key management.

func OpenStore

func OpenStore(path string, opts ...StoreOption) (*Store, error)

OpenStore opens (or creates) the SQLite database at path and runs migrations.

func OpenStoreWithDB

func OpenStoreWithDB(db *sql.DB, opts ...StoreOption) (*Store, error)

OpenStoreWithDB wraps an existing *sql.DB (e.g. shared with another service). Runs migrations on the provided DB. Close() on the returned Store is a no-op to avoid closing the shared DB.

func (*Store) Close

func (s *Store) Close() error

Close closes the underlying database connection. If the store was created via OpenStoreWithDB (shared DB), Close is a no-op to avoid breaking other consumers of the same *sql.DB.

func (*Store) Count

func (s *Store) Count(ownerID string) (int, error)

Count returns the number of non-revoked keys for the given owner.

func (*Store) DB

func (s *Store) DB() *sql.DB

DB returns the underlying *sql.DB.

func (*Store) Generate

func (s *Store) Generate(id, ownerID, name string, services []string, rateLimit int, opts ...Option) (clearKey string, key *Key, err error)

Generate creates a new API key, stores its hash, and returns the clear key exactly once. The clear key is never stored — only its SHA-256 hash.

Format: "hk_" + 32 random bytes hex = 67 chars total. Prefix stored: first 8 chars ("hk_7f3a9") for identification without exposure.

func (*Store) List

func (s *Store) List(ownerID string) ([]*Key, error)

List returns all API keys for an owner (excluding the hash).

func (*Store) ListByDossier

func (s *Store) ListByDossier(dossierID string) ([]*Key, error)

ListByDossier returns all active (non-revoked) keys scoped to a specific dossier.

func (*Store) Resolve

func (s *Store) Resolve(clearKey string) (*Key, error)

Resolve validates a clear API key and returns the associated Key record. Returns an error if the key is invalid, expired, or revoked.

func (*Store) Revoke

func (s *Store) Revoke(keyID string) error

Revoke marks an API key as revoked. It can no longer be used for authentication.

func (*Store) SetExpiry

func (s *Store) SetExpiry(keyID string, expiresAt string) error

SetExpiry sets or clears the expiration date for a key. Returns an error if the key does not exist or is revoked.

func (*Store) UpdateServices

func (s *Store) UpdateServices(keyID string, services []string) error

UpdateServices updates the authorized services for a key (no key rotation needed). Returns an error if the key does not exist or is revoked.

type StoreOption

type StoreOption func(*Store)

StoreOption configures store-level behavior. Distinct from Option (which configures Generate).

func WithAudit

func WithAudit(fn AuditFunc) StoreOption

WithAudit registers a hook called after successful Generate, Resolve, and Revoke operations.

func WithMaxKeys

func WithMaxKeys(n int) StoreOption

WithMaxKeys sets the maximum number of non-revoked keys per owner. 0 means unlimited (default).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL