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)