76ada04442
Resolve final-review findings: commandRole is now the single source of truth (Run resolves role once and threads it to handlers, replacing hardcoded openStore roles). Tighten crypto/SKILL/SPEC/USER-MANUAL wording and document init's agent-key-on-first-init-only semantics. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
3.0 KiB
Go
106 lines
3.0 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
|
)
|
|
|
|
// Role selects which DEK wrap slot a command may unlock.
|
|
type Role int
|
|
|
|
const (
|
|
RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback)
|
|
RoleAdmin // all commands; uses dek_wrap_admin ONLY
|
|
)
|
|
|
|
const (
|
|
settingDEKWrapAdmin = "dek_wrap_admin"
|
|
settingDEKWrapAgent = "dek_wrap_agent"
|
|
)
|
|
|
|
// ErrLocked means the DB has no DEK wrap slots yet (never initialized).
|
|
var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")
|
|
|
|
// dbPath returns the file path SQLite opened (used by tests to re-open).
|
|
func (s *Store) dbPath() string {
|
|
var p string
|
|
_ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p)
|
|
return p
|
|
}
|
|
|
|
// InitKeys generates a DEK (only if absent), seals it under both KEKs, writes
|
|
// both wrap slots, and unlocks the store. If the slots already exist it does
|
|
// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init).
|
|
func (s *Store) InitKeys(adminKey, agentKey []byte) error {
|
|
if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil {
|
|
// Already initialised: the DEK and both wrap slots already exist, so the
|
|
// agent key is not consumed here. Only the admin key is used to unlock the
|
|
// existing dek_wrap_admin slot; the DEK itself is preserved unchanged.
|
|
return s.Unlock(RoleAdmin, adminKey, nil)
|
|
}
|
|
dek, err := crypto.NewDEK()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wrapAdmin, err := crypto.Seal(adminKey, dek)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wrapAgent, err := crypto.Seal(agentKey, dek)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil {
|
|
return err
|
|
}
|
|
if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil {
|
|
return err
|
|
}
|
|
s.key = dek
|
|
return nil
|
|
}
|
|
|
|
// Unlock loads the DEK into the store by decrypting the wrap slot for role.
|
|
// RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and
|
|
// falls back to the admin slot only when no agent key is supplied.
|
|
func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error {
|
|
switch role {
|
|
case RoleAdmin:
|
|
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
|
|
case RoleAgent:
|
|
if len(agentKey) > 0 {
|
|
return s.unlockSlot(settingDEKWrapAgent, agentKey)
|
|
}
|
|
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
|
|
default:
|
|
return fmt.Errorf("unknown role %d", role)
|
|
}
|
|
}
|
|
|
|
func (s *Store) unlockSlot(settingKey string, kek []byte) error {
|
|
if len(kek) == 0 {
|
|
return ErrLocked
|
|
}
|
|
enc, err := s.GetSetting(settingKey)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return ErrLocked
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
blob, err := base64.StdEncoding.DecodeString(enc)
|
|
if err != nil {
|
|
return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err)
|
|
}
|
|
dek, err := crypto.Open(kek, blob)
|
|
if err != nil {
|
|
return errors.New("wrong key for this DB")
|
|
}
|
|
s.key = dek
|
|
return nil
|
|
}
|