Files
openyield/x/hub/keeper/keyring_mem.go
T
cloudinit-bot 3c52aa1bb2
docs-build / go test ./... (lexicon firewall + all x/* tests) (push) Has been cancelled
docs-build / mkdocs build (docs site artifact) (push) Has been cancelled
Merge phase/04 into milestone/v0.5-bearers-runtime (P4 complete → v0.4.4)
---ci---
project: oy
phase: 4
milestone: v0.5
status: complete
requirements:
  covered: [REQ-036]
  partial: []
---/ci---
2026-08-18 00:49:16 +00:00

218 lines
7.6 KiB
Go

package keeper
// keyring_mem.go holds the in-memory test-only memKeyring impl of the
// CustodyKeyring interface (D-058, P4-02-01). data-engineer territory (P4
// phase-specific — removed after P4 per PERSONAS.md).
//
// D-054: simtest-grade — NO real MPC, NO real HSM, NO real hardware. The
// memKeyring signs with a throwaway ed25519 key per assetID (generated
// in-process; the seed is not persisted). Real MPC/HSM backing is deferred
// (operational, Year 3+). This impl exists so the x/hub custody handlers
// can be exercised end-to-end in simtest without a custody vendor.
//
// Rotation: Rotate(assetID) swaps the keymap entry for assetID with a fresh
// ed25519 keypair and bumps the version (monotonic uint64). A subsequent
// Status reports the new active key version; a subsequent Sign uses the new
// key (D-058: no cross-block caching — the handler consults Status/Sign per
// operation, so rotation is observed immediately). The previous key is
// retained as a Rotated entry so Derive can still return the historical
// pubkey for verification of prior signatures.
//
// Revocation: Revoke(assetID) marks the active key Revoked (terminal).
// Subsequent Sign/Derive against the assetID return ErrKeyringRevoked/
// ErrKeyringInactive. The key material is wiped (defensive — simtest grade).
//
// Thread safety: the simtest is single-threaded per-block (SDK store
// semantics); the memKeyring uses a mutex so concurrent test paths are
// safe (mirrors x/partner/types/types.go Keeper stub pattern).
//
// Lexicon note (REQ-012): "memKeyring", "Sign", "Derive", "rotation",
// "revocation", "ed25519" are all lexicon-clean. No banned terms.
import (
"context"
"crypto/ed25519"
"crypto/rand"
"fmt"
"sync"
"github.com/oy/openyield/x/hub/types"
)
// memKeyring is the in-memory test-only CustodyKeyring impl (D-058).
// NOT for production use — real MPC/HSM backing is deferred (Year 3+).
type memKeyring struct {
mu sync.Mutex
keys map[string]*keyEntry // assetID → active key entry
}
// keyEntry is the per-assetID key record. The active key is the one used
// for Sign/Derive; the rotated keys are retained for historical Derive
// (verification of prior signatures).
type keyEntry struct {
priv ed25519.PrivateKey
pub ed25519.PublicKey
status types.KeyringStatus
version uint64
rotated []*keyEntry // historical (Rotated) entries, newest-first
}
// NewMemKeyring returns a fresh empty in-memory CustodyKeyring (D-058).
// Keys are generated lazily on the first Register/Sign/Derive for an
// assetID (or explicitly via Register).
func NewMemKeyring() types.CustodyKeyring {
return &memKeyring{keys: make(map[string]*keyEntry)}
}
// Register ensures an active key exists for assetID. If one already exists
// and is Active, this is a no-op (returns the existing version). If the
// assetID is unknown, a fresh ed25519 keypair is generated (version 1).
// Register is a convenience for test setup; the handler does not require
// explicit registration (Sign/Derive auto-register on first use).
func (m *memKeyring) Register(ctx context.Context, assetID string) (types.PubKey, uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if e, ok := m.keys[assetID]; ok && e.status == types.KeyringActive {
return types.PubKey(e.pub), e.version, nil
}
e, err := newActiveEntry(1)
if err != nil {
return nil, 0, err
}
m.keys[assetID] = e
return types.PubKey(e.pub), e.version, nil
}
// Sign produces an ed25519 signature over payload with the active key for
// assetID. Auto-registers on first use (lazy key generation). Returns
// ErrKeyringInactive if the key is Rotated or Revoked.
func (m *memKeyring) Sign(ctx context.Context, assetID string, payload []byte) ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
e, ok := m.keys[assetID]
if !ok {
// Lazy auto-register on first Sign.
ne, err := newActiveEntry(1)
if err != nil {
return nil, err
}
m.keys[assetID] = ne
e = ne
}
if e.status != types.KeyringActive {
return nil, types.ErrKeyringInactive
}
return ed25519.Sign(e.priv, payload), nil
}
// Derive returns the active public key for assetID. Auto-registers on first
// use. Returns ErrKeyringRevoked if the key is Revoked; returns the
// historical pubkey if the key is Rotated (for verification of prior
// signatures).
func (m *memKeyring) Derive(ctx context.Context, assetID string) (types.PubKey, error) {
m.mu.Lock()
defer m.mu.Unlock()
e, ok := m.keys[assetID]
if !ok {
// Lazy auto-register on first Derive.
ne, err := newActiveEntry(1)
if err != nil {
return nil, err
}
m.keys[assetID] = ne
e = ne
}
if e.status == types.KeyringRevoked {
return nil, types.ErrKeyringRevoked
}
// Active or Rotated: return the pubkey (Rotated returns the historical
// pubkey of that entry — the entry's own pubkey, not the new active).
return types.PubKey(e.pub), nil
}
// Status reports the active key's status + version for assetID. Returns
// ErrKeyringUnknownAsset if the assetID is not registered (Status does NOT
// auto-register — the handler consults Status before Sign to enforce
// rotation safety; auto-register on Status would mask a missing-asset bug).
func (m *memKeyring) Status(ctx context.Context, assetID string) (types.KeyringStatus, uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
e, ok := m.keys[assetID]
if !ok {
return "", 0, types.ErrKeyringUnknownAsset
}
return e.status, e.version, nil
}
// Rotate swaps the active key for assetID with a fresh ed25519 keypair and
// bumps the version (monotonic). The previous key is retained as a Rotated
// entry (newest-first in e.rotated). A subsequent Sign uses the new key;
// Derive against the Rotated entry returns the historical pubkey. Returns
// the new version. This is the test-only rotation helper (D-058); the
// simtest exercises rotation via this method.
func (m *memKeyring) Rotate(assetID string) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
e, ok := m.keys[assetID]
if !ok {
// Auto-register on Rotate (convenience for test setup).
ne, err := newActiveEntry(1)
if err != nil {
return 0, err
}
m.keys[assetID] = ne
return ne.version, nil
}
if e.status == types.KeyringRevoked {
return 0, types.ErrKeyringRevoked
}
// Promote current active to Rotated, generate a new active.
newVersion := e.version + 1
ne, err := newActiveEntry(newVersion)
if err != nil {
return 0, err
}
old := e
old.status = types.KeyringRotated
ne.rotated = append([]*keyEntry{old}, e.rotated...)
m.keys[assetID] = ne
return newVersion, nil
}
// Revoke marks the active key for assetID as Revoked (terminal). Subsequent
// Sign/Derive against the assetID return ErrKeyringInactive/ErrKeyringRevoked.
// The key material is wiped (defensive — simtest grade). Returns
// ErrKeyringUnknownAsset if the assetID is not registered.
func (m *memKeyring) Revoke(assetID string) error {
m.mu.Lock()
defer m.mu.Unlock()
e, ok := m.keys[assetID]
if !ok {
return types.ErrKeyringUnknownAsset
}
e.status = types.KeyringRevoked
// Defensive: wipe the private key material (simtest grade — a real
// impl would zeroize the HSM key slot).
wipe := make(ed25519.PrivateKey, ed25519.PrivateKeySize)
e.priv = wipe
return nil
}
// newActiveEntry generates a fresh ed25519 keypair with the given version
// and status=Active.
func newActiveEntry(version uint64) (*keyEntry, error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("memKeyring: generate ed25519 key: %w", err)
}
return &keyEntry{
priv: priv,
pub: pub,
status: types.KeyringActive,
version: version,
}, nil
}
// Compile-time assertion: memKeyring implements types.CustodyKeyring.
var _ types.CustodyKeyring = (*memKeyring)(nil)