diff --git a/internal/keybase/actions.go b/internal/keybase/actions.go new file mode 100644 index 0000000..44c9534 --- /dev/null +++ b/internal/keybase/actions.go @@ -0,0 +1,543 @@ +// Package keybase provides action handlers for concurrent database operations. +package keybase + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "time" +) + +// ActionManager provides thread-safe database operations with concurrent read support. +type ActionManager struct { + kb *Keybase +} + +// NewActionManager creates an ActionManager from the current keybase instance. +func NewActionManager() (*ActionManager, error) { + kb := Get() + if kb == nil { + return nil, fmt.Errorf("keybase not initialized") + } + return &ActionManager{kb: kb}, nil +} + +// ============================================================================= +// ACCOUNT ACTIONS +// ============================================================================= + +// AccountResult represents an account in API responses. +type AccountResult struct { + ID int64 `json:"id"` + Address string `json:"address"` + ChainID string `json:"chain_id"` + CoinType int64 `json:"coin_type"` + AccountIndex int64 `json:"account_index"` + AddressIndex int64 `json:"address_index"` + Label string `json:"label"` + IsDefault bool `json:"is_default"` + PublicKey string `json:"public_key,omitempty"` + Curve string `json:"curve,omitempty"` + CreatedAt string `json:"created_at"` +} + +// ListAccounts returns all accounts for the current DID. +func (am *ActionManager) ListAccounts(ctx context.Context) ([]AccountResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return []AccountResult{}, nil + } + + rows, err := am.kb.queries.ListAccountsByDID(ctx, am.kb.didID) + if err != nil { + return nil, fmt.Errorf("list accounts: %w", err) + } + + results := make([]AccountResult, len(rows)) + for i, row := range rows { + label := "" + if row.Label != nil { + label = *row.Label + } + results[i] = AccountResult{ + ID: row.ID, + Address: row.Address, + ChainID: row.ChainID, + CoinType: row.CoinType, + AccountIndex: row.AccountIndex, + AddressIndex: row.AddressIndex, + Label: label, + IsDefault: row.IsDefault == 1, + PublicKey: row.PublicKey, + Curve: row.Curve, + CreatedAt: row.CreatedAt, + } + } + + return results, nil +} + +// GetAccountByAddress retrieves an account by its blockchain address. +func (am *ActionManager) GetAccountByAddress(ctx context.Context, address string) (*AccountResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + acc, err := am.kb.queries.GetAccountByAddress(ctx, address) + if err != nil { + return nil, fmt.Errorf("get account: %w", err) + } + + label := "" + if acc.Label != nil { + label = *acc.Label + } + + return &AccountResult{ + ID: acc.ID, + Address: acc.Address, + ChainID: acc.ChainID, + CoinType: acc.CoinType, + AccountIndex: acc.AccountIndex, + AddressIndex: acc.AddressIndex, + Label: label, + IsDefault: acc.IsDefault == 1, + CreatedAt: acc.CreatedAt, + }, nil +} + +// ============================================================================= +// CREDENTIAL ACTIONS +// ============================================================================= + +// CredentialResult represents a credential in API responses. +type CredentialResult struct { + ID int64 `json:"id"` + CredentialID string `json:"credential_id"` + DeviceName string `json:"device_name"` + DeviceType string `json:"device_type"` + Authenticator string `json:"authenticator"` + Transports []string `json:"transports"` + SignCount int64 `json:"sign_count"` + IsDiscoverable bool `json:"is_discoverable"` + BackedUp bool `json:"backed_up"` + CreatedAt string `json:"created_at"` + LastUsed string `json:"last_used"` +} + +// ListCredentials returns all credentials for the current DID. +func (am *ActionManager) ListCredentials(ctx context.Context) ([]CredentialResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return []CredentialResult{}, nil + } + + creds, err := am.kb.queries.ListCredentialsByDID(ctx, am.kb.didID) + if err != nil { + return nil, fmt.Errorf("list credentials: %w", err) + } + + results := make([]CredentialResult, len(creds)) + for i, cred := range creds { + var transports []string + if err := json.Unmarshal(cred.Transports, &transports); err != nil { + transports = []string{} + } + + authenticator := "" + if cred.Authenticator != nil { + authenticator = *cred.Authenticator + } + + results[i] = CredentialResult{ + ID: cred.ID, + CredentialID: cred.CredentialID, + DeviceName: cred.DeviceName, + DeviceType: cred.DeviceType, + Authenticator: authenticator, + Transports: transports, + SignCount: cred.SignCount, + IsDiscoverable: cred.IsDiscoverable == 1, + BackedUp: cred.BackedUp == 1, + CreatedAt: cred.CreatedAt, + LastUsed: cred.LastUsed, + } + } + + return results, nil +} + +// GetCredentialByID retrieves a credential by its WebAuthn credential ID. +func (am *ActionManager) GetCredentialByID(ctx context.Context, credentialID string) (*CredentialResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + cred, err := am.kb.queries.GetCredentialByID(ctx, credentialID) + if err != nil { + return nil, fmt.Errorf("get credential: %w", err) + } + + var transports []string + if err := json.Unmarshal(cred.Transports, &transports); err != nil { + transports = []string{} + } + + authenticator := "" + if cred.Authenticator != nil { + authenticator = *cred.Authenticator + } + + return &CredentialResult{ + ID: cred.ID, + CredentialID: cred.CredentialID, + DeviceName: cred.DeviceName, + DeviceType: cred.DeviceType, + Authenticator: authenticator, + Transports: transports, + SignCount: cred.SignCount, + IsDiscoverable: cred.IsDiscoverable == 1, + BackedUp: cred.BackedUp == 1, + CreatedAt: cred.CreatedAt, + LastUsed: cred.LastUsed, + }, nil +} + +// ============================================================================= +// SESSION ACTIONS +// ============================================================================= + +// SessionResult represents a session in API responses. +type SessionResult struct { + ID int64 `json:"id"` + SessionID string `json:"session_id"` + DeviceName string `json:"device_name"` + Authenticator string `json:"authenticator"` + DeviceInfo json.RawMessage `json:"device_info"` + IsCurrent bool `json:"is_current"` + LastActivity string `json:"last_activity"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +// ListSessions returns all active sessions for the current DID. +func (am *ActionManager) ListSessions(ctx context.Context) ([]SessionResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return []SessionResult{}, nil + } + + sessions, err := am.kb.queries.ListSessionsByDID(ctx, am.kb.didID) + if err != nil { + return nil, fmt.Errorf("list sessions: %w", err) + } + + results := make([]SessionResult, len(sessions)) + for i, sess := range sessions { + authenticator := "" + if sess.Authenticator != nil { + authenticator = *sess.Authenticator + } + + results[i] = SessionResult{ + ID: sess.ID, + SessionID: sess.SessionID, + DeviceName: sess.DeviceName, + Authenticator: authenticator, + DeviceInfo: sess.DeviceInfo, + IsCurrent: sess.IsCurrent == 1, + LastActivity: sess.LastActivity, + ExpiresAt: sess.ExpiresAt, + CreatedAt: sess.CreatedAt, + } + } + + return results, nil +} + +// NewSessionParams contains parameters for creating a session. +type NewSessionParams struct { + CredentialID int64 `json:"credential_id"` + DeviceInfo json.RawMessage `json:"device_info"` + ExpiresIn time.Duration `json:"expires_in"` +} + +// CreateSession creates a new session for the current DID. +func (am *ActionManager) CreateSession(ctx context.Context, params NewSessionParams) (*SessionResult, error) { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + sessionID := generateSessionID() + expiresAt := time.Now().Add(params.ExpiresIn).UTC().Format(time.RFC3339) + + deviceInfo := params.DeviceInfo + if deviceInfo == nil { + deviceInfo = json.RawMessage(`{}`) + } + + sess, err := am.kb.queries.CreateSession(ctx, CreateSessionParams{ + DidID: am.kb.didID, + CredentialID: params.CredentialID, + SessionID: sessionID, + DeviceInfo: deviceInfo, + IsCurrent: 1, + ExpiresAt: expiresAt, + }) + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + + return &SessionResult{ + ID: sess.ID, + SessionID: sess.SessionID, + DeviceInfo: sess.DeviceInfo, + IsCurrent: sess.IsCurrent == 1, + LastActivity: sess.LastActivity, + ExpiresAt: sess.ExpiresAt, + CreatedAt: sess.CreatedAt, + }, nil +} + +// generateSessionID creates a random session identifier. +func generateSessionID() string { + b := make([]byte, 16) + rand.Read(b) + return "sess_" + hex.EncodeToString(b) +} + +// RevokeSession deletes a session by its ID. +func (am *ActionManager) RevokeSession(ctx context.Context, sessionID string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + sess, err := am.kb.queries.GetSessionByID(ctx, sessionID) + if err != nil { + return fmt.Errorf("get session: %w", err) + } + + if err := am.kb.queries.DeleteSession(ctx, sess.ID); err != nil { + return fmt.Errorf("delete session: %w", err) + } + + return nil +} + +// ============================================================================= +// GRANT ACTIONS +// ============================================================================= + +// GrantResult represents a grant in API responses. +type GrantResult struct { + ID int64 `json:"id"` + ServiceName string `json:"service_name"` + ServiceOrigin string `json:"service_origin"` + ServiceLogo string `json:"service_logo,omitempty"` + Scopes json.RawMessage `json:"scopes"` + Accounts json.RawMessage `json:"accounts"` + Status string `json:"status"` + GrantedAt string `json:"granted_at"` + LastUsed string `json:"last_used,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +// ListGrants returns all active grants for the current DID. +func (am *ActionManager) ListGrants(ctx context.Context) ([]GrantResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return []GrantResult{}, nil + } + + grants, err := am.kb.queries.ListGrantsByDID(ctx, am.kb.didID) + if err != nil { + return nil, fmt.Errorf("list grants: %w", err) + } + + results := make([]GrantResult, len(grants)) + for i, g := range grants { + serviceLogo := "" + if g.ServiceLogo != nil { + serviceLogo = *g.ServiceLogo + } + + lastUsed := "" + if g.LastUsed != nil { + lastUsed = *g.LastUsed + } + + expiresAt := "" + if g.ExpiresAt != nil { + expiresAt = *g.ExpiresAt + } + + results[i] = GrantResult{ + ID: g.ID, + ServiceName: g.ServiceName, + ServiceOrigin: g.ServiceOrigin, + ServiceLogo: serviceLogo, + Scopes: g.Scopes, + Accounts: g.Accounts, + Status: g.Status, + GrantedAt: g.GrantedAt, + LastUsed: lastUsed, + ExpiresAt: expiresAt, + } + } + + return results, nil +} + +// RevokeGrant revokes a grant by its ID. +func (am *ActionManager) RevokeGrant(ctx context.Context, grantID int64) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if err := am.kb.queries.RevokeGrant(ctx, grantID); err != nil { + return fmt.Errorf("revoke grant: %w", err) + } + + return nil +} + +// ============================================================================= +// DID RESOLUTION +// ============================================================================= + +// VerificationMethodResult represents a verification method in API responses. +type VerificationMethodResult struct { + ID string `json:"id"` + Type string `json:"type"` + Controller string `json:"controller"` + PublicKey string `json:"public_key"` + Purpose string `json:"purpose"` +} + +// DIDDocumentResult represents a resolved DID document. +type DIDDocumentResult struct { + DID string `json:"did"` + Controller string `json:"controller"` + VerificationMethods []VerificationMethodResult `json:"verification_methods"` + Accounts []AccountResult `json:"accounts"` + Credentials []CredentialResult `json:"credentials"` +} + +// ResolveDID resolves a DID document with all associated data. +func (am *ActionManager) ResolveDID(ctx context.Context, did string) (*DIDDocumentResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + // Get DID document + doc, err := am.kb.queries.GetDIDByDID(ctx, did) + if err != nil { + return nil, fmt.Errorf("get DID document: %w", err) + } + + // Get verification methods + vms, err := am.kb.queries.ListVerificationMethods(ctx, doc.ID) + if err != nil { + return nil, fmt.Errorf("list verification methods: %w", err) + } + + vmResults := make([]VerificationMethodResult, len(vms)) + for i, vm := range vms { + vmResults[i] = VerificationMethodResult{ + ID: vm.MethodID, + Type: vm.MethodType, + Controller: vm.Controller, + PublicKey: vm.PublicKey, + Purpose: vm.Purpose, + } + } + + // Get accounts + accountRows, err := am.kb.queries.ListAccountsByDID(ctx, doc.ID) + if err != nil { + return nil, fmt.Errorf("list accounts: %w", err) + } + + accountResults := make([]AccountResult, len(accountRows)) + for i, row := range accountRows { + label := "" + if row.Label != nil { + label = *row.Label + } + accountResults[i] = AccountResult{ + ID: row.ID, + Address: row.Address, + ChainID: row.ChainID, + CoinType: row.CoinType, + AccountIndex: row.AccountIndex, + AddressIndex: row.AddressIndex, + Label: label, + IsDefault: row.IsDefault == 1, + PublicKey: row.PublicKey, + Curve: row.Curve, + CreatedAt: row.CreatedAt, + } + } + + // Get credentials + creds, err := am.kb.queries.ListCredentialsByDID(ctx, doc.ID) + if err != nil { + return nil, fmt.Errorf("list credentials: %w", err) + } + + credResults := make([]CredentialResult, len(creds)) + for i, cred := range creds { + var transports []string + if err := json.Unmarshal(cred.Transports, &transports); err != nil { + transports = []string{} + } + + authenticator := "" + if cred.Authenticator != nil { + authenticator = *cred.Authenticator + } + + credResults[i] = CredentialResult{ + ID: cred.ID, + CredentialID: cred.CredentialID, + DeviceName: cred.DeviceName, + DeviceType: cred.DeviceType, + Authenticator: authenticator, + Transports: transports, + SignCount: cred.SignCount, + IsDiscoverable: cred.IsDiscoverable == 1, + BackedUp: cred.BackedUp == 1, + CreatedAt: cred.CreatedAt, + LastUsed: cred.LastUsed, + } + } + + return &DIDDocumentResult{ + DID: doc.Did, + Controller: doc.Controller, + VerificationMethods: vmResults, + Accounts: accountResults, + Credentials: credResults, + }, nil +} + +// ResolveCurrentDID resolves the current DID document. +func (am *ActionManager) ResolveCurrentDID(ctx context.Context) (*DIDDocumentResult, error) { + am.kb.mu.RLock() + did := am.kb.did + am.kb.mu.RUnlock() + + if did == "" { + return nil, fmt.Errorf("no DID initialized") + } + + return am.ResolveDID(ctx, did) +}