diff --git a/internal/enclave/crypto.go b/internal/enclave/crypto.go new file mode 100644 index 0000000..18ca7f9 --- /dev/null +++ b/internal/enclave/crypto.go @@ -0,0 +1,219 @@ +// Package enclave provides encrypted database operations with WebAuthn PRF key derivation. +package enclave + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + + "golang.org/x/crypto/hkdf" +) + +const ( + // EnclaveSalt is the salt used for HKDF key derivation + EnclaveSalt = "nebula-enclave-v1" + + // KeySize is the size of the derived encryption key (256 bits) + KeySize = 32 + + // NonceSize is the size of the GCM nonce (96 bits) + NonceSize = 12 + + // AuthTagSize is the size of the GCM authentication tag (128 bits) + AuthTagSize = 16 +) + +// DeriveEncryptionKey derives a 256-bit encryption key from WebAuthn PRF output using HKDF. +// +// Parameters: +// - prfOutput: The raw PRF output from WebAuthn (typically 32 bytes) +// +// Returns: +// - A 32-byte key suitable for AES-256-GCM encryption +// - An error if key derivation fails +func DeriveEncryptionKey(prfOutput []byte) ([]byte, error) { + if len(prfOutput) == 0 { + return nil, fmt.Errorf("enclave: PRF output cannot be empty") + } + + salt := []byte(EnclaveSalt) + info := []byte("database-encryption") + + hkdfReader := hkdf.New(sha256.New, prfOutput, salt, info) + + key := make([]byte, KeySize) + if _, err := io.ReadFull(hkdfReader, key); err != nil { + return nil, fmt.Errorf("enclave: failed to derive key: %w", err) + } + + return key, nil +} + +// DeriveKeyWithContext derives an encryption key with additional context binding. +// This allows deriving different keys for different purposes from the same PRF output. +// +// Parameters: +// - prfOutput: The raw PRF output from WebAuthn +// - context: Additional context to bind the key to (e.g., "database", "mpc-share") +func DeriveKeyWithContext(prfOutput []byte, context string) ([]byte, error) { + if len(prfOutput) == 0 { + return nil, fmt.Errorf("enclave: PRF output cannot be empty") + } + if context == "" { + return nil, fmt.Errorf("enclave: context cannot be empty") + } + + salt := []byte(EnclaveSalt) + info := []byte(context) + + hkdfReader := hkdf.New(sha256.New, prfOutput, salt, info) + + key := make([]byte, KeySize) + if _, err := io.ReadFull(hkdfReader, key); err != nil { + return nil, fmt.Errorf("enclave: failed to derive key: %w", err) + } + + return key, nil +} + +// EncryptedData represents encrypted data with its metadata. +type EncryptedData struct { + // Nonce is the unique nonce used for this encryption (12 bytes) + Nonce []byte `json:"nonce"` + // Ciphertext is the encrypted data including the GCM authentication tag + Ciphertext []byte `json:"ciphertext"` + // Version indicates the encryption scheme version + Version int `json:"version"` +} + +// Encrypt encrypts plaintext using AES-256-GCM with the provided key. +// +// Parameters: +// - key: 32-byte encryption key (from DeriveEncryptionKey) +// - plaintext: The data to encrypt +// +// Returns: +// - EncryptedData containing nonce and ciphertext +// - An error if encryption fails +func Encrypt(key, plaintext []byte) (*EncryptedData, error) { + if len(key) != KeySize { + return nil, fmt.Errorf("enclave: invalid key size: got %d, want %d", len(key), KeySize) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("enclave: failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("enclave: failed to create GCM: %w", err) + } + + nonce := make([]byte, NonceSize) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("enclave: failed to generate nonce: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + + return &EncryptedData{ + Nonce: nonce, + Ciphertext: ciphertext, + Version: 1, + }, nil +} + +// Decrypt decrypts ciphertext using AES-256-GCM with the provided key. +// +// Parameters: +// - key: 32-byte encryption key (from DeriveEncryptionKey) +// - data: The EncryptedData to decrypt +// +// Returns: +// - The decrypted plaintext +// - An error if decryption fails (including authentication failure) +func Decrypt(key []byte, data *EncryptedData) ([]byte, error) { + if len(key) != KeySize { + return nil, fmt.Errorf("enclave: invalid key size: got %d, want %d", len(key), KeySize) + } + if data == nil { + return nil, fmt.Errorf("enclave: encrypted data cannot be nil") + } + if len(data.Nonce) != NonceSize { + return nil, fmt.Errorf("enclave: invalid nonce size: got %d, want %d", len(data.Nonce), NonceSize) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("enclave: failed to create cipher: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("enclave: failed to create GCM: %w", err) + } + + plaintext, err := gcm.Open(nil, data.Nonce, data.Ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("enclave: decryption failed (authentication error): %w", err) + } + + return plaintext, nil +} + +// EncryptBytes is a convenience function that encrypts and returns serialized bytes. +// The format is: version (1 byte) + nonce (12 bytes) + ciphertext (variable) +func EncryptBytes(key, plaintext []byte) ([]byte, error) { + data, err := Encrypt(key, plaintext) + if err != nil { + return nil, err + } + + result := make([]byte, 1+NonceSize+len(data.Ciphertext)) + result[0] = byte(data.Version) + copy(result[1:1+NonceSize], data.Nonce) + copy(result[1+NonceSize:], data.Ciphertext) + + return result, nil +} + +// DecryptBytes is a convenience function that decrypts serialized encrypted bytes. +// Expected format: version (1 byte) + nonce (12 bytes) + ciphertext (variable) +func DecryptBytes(key, encryptedBytes []byte) ([]byte, error) { + if len(encryptedBytes) < 1+NonceSize+AuthTagSize { + return nil, fmt.Errorf("enclave: encrypted data too short") + } + + version := int(encryptedBytes[0]) + if version != 1 { + return nil, fmt.Errorf("enclave: unsupported encryption version: %d", version) + } + + data := &EncryptedData{ + Version: version, + Nonce: encryptedBytes[1 : 1+NonceSize], + Ciphertext: encryptedBytes[1+NonceSize:], + } + + return Decrypt(key, data) +} + +// GenerateNonce generates a cryptographically secure random nonce. +func GenerateNonce() ([]byte, error) { + nonce := make([]byte, NonceSize) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("enclave: failed to generate nonce: %w", err) + } + return nonce, nil +} + +// SecureZero zeros out a byte slice to prevent sensitive data from remaining in memory. +func SecureZero(b []byte) { + for i := range b { + b[i] = 0 + } +} diff --git a/internal/enclave/enclave.go b/internal/enclave/enclave.go new file mode 100644 index 0000000..f013b15 --- /dev/null +++ b/internal/enclave/enclave.go @@ -0,0 +1,184 @@ +package enclave + +import ( + "encoding/json" + "fmt" + + "enclave/internal/keybase" +) + +// Enclave wraps a Keybase with encryption capabilities using WebAuthn PRF-derived keys. +type Enclave struct { + keybase *keybase.Keybase + encryptionKey []byte +} + +// Config holds enclave configuration options. +type Config struct { + PRFOutput []byte +} + +// New creates a new Enclave with the given PRF output for key derivation. +func New(prfOutput []byte) (*Enclave, error) { + if len(prfOutput) == 0 { + return nil, fmt.Errorf("enclave: PRF output required") + } + + key, err := DeriveEncryptionKey(prfOutput) + if err != nil { + return nil, fmt.Errorf("enclave: key derivation failed: %w", err) + } + + kb, err := keybase.Open() + if err != nil { + SecureZero(key) + return nil, fmt.Errorf("enclave: failed to open keybase: %w", err) + } + + return &Enclave{ + keybase: kb, + encryptionKey: key, + }, nil +} + +// Keybase returns the underlying keybase instance. +func (e *Enclave) Keybase() *keybase.Keybase { + return e.keybase +} + +// DID returns the current DID. +func (e *Enclave) DID() string { + return e.keybase.DID() +} + +// IsInitialized returns true if the enclave has been initialized with a DID. +func (e *Enclave) IsInitialized() bool { + return e.keybase.IsInitialized() +} + +// SerializeEncrypted exports the database state as encrypted bytes. +func (e *Enclave) SerializeEncrypted() ([]byte, error) { + plaintext, err := e.keybase.Serialize() + if err != nil { + return nil, fmt.Errorf("enclave: serialization failed: %w", err) + } + + encrypted, err := EncryptBytes(e.encryptionKey, plaintext) + if err != nil { + SecureZero(plaintext) + return nil, fmt.Errorf("enclave: encryption failed: %w", err) + } + + SecureZero(plaintext) + return encrypted, nil +} + +// LoadEncrypted loads the database state from encrypted bytes. +func (e *Enclave) LoadEncrypted(encryptedData []byte) error { + plaintext, err := DecryptBytes(e.encryptionKey, encryptedData) + if err != nil { + return fmt.Errorf("enclave: decryption failed: %w", err) + } + defer SecureZero(plaintext) + + return e.loadFromPlaintext(plaintext) +} + +// loadFromPlaintext parses and executes the SQL statements to restore database state. +func (e *Enclave) loadFromPlaintext(data []byte) error { + if len(data) == 0 { + return fmt.Errorf("enclave: empty data") + } + + return e.keybase.RestoreFromDump(data) +} + +// Close securely closes the enclave and zeros out sensitive data. +func (e *Enclave) Close() error { + SecureZero(e.encryptionKey) + return keybase.Close() +} + +// EncryptedBundle represents a complete encrypted database export. +type EncryptedBundle struct { + Version int `json:"version"` + DID string `json:"did"` + Ciphertext []byte `json:"ciphertext"` + Nonce []byte `json:"nonce"` +} + +// Export creates a complete encrypted bundle for storage. +func (e *Enclave) Export() (*EncryptedBundle, error) { + plaintext, err := e.keybase.Serialize() + if err != nil { + return nil, fmt.Errorf("enclave: serialization failed: %w", err) + } + defer SecureZero(plaintext) + + encData, err := Encrypt(e.encryptionKey, plaintext) + if err != nil { + return nil, fmt.Errorf("enclave: encryption failed: %w", err) + } + + return &EncryptedBundle{ + Version: encData.Version, + DID: e.keybase.DID(), + Ciphertext: encData.Ciphertext, + Nonce: encData.Nonce, + }, nil +} + +// Import loads an encrypted bundle. +func (e *Enclave) Import(bundle *EncryptedBundle) error { + if bundle == nil { + return fmt.Errorf("enclave: bundle cannot be nil") + } + + encData := &EncryptedData{ + Version: bundle.Version, + Ciphertext: bundle.Ciphertext, + Nonce: bundle.Nonce, + } + + plaintext, err := Decrypt(e.encryptionKey, encData) + if err != nil { + return fmt.Errorf("enclave: decryption failed: %w", err) + } + defer SecureZero(plaintext) + + return e.loadFromPlaintext(plaintext) +} + +// MarshalBundle serializes an encrypted bundle to JSON. +func (b *EncryptedBundle) Marshal() ([]byte, error) { + return json.Marshal(b) +} + +// UnmarshalBundle deserializes an encrypted bundle from JSON. +func UnmarshalBundle(data []byte) (*EncryptedBundle, error) { + var bundle EncryptedBundle + if err := json.Unmarshal(data, &bundle); err != nil { + return nil, fmt.Errorf("enclave: failed to unmarshal bundle: %w", err) + } + return &bundle, nil +} + +// FromExisting wraps an existing keybase with encryption capabilities. +func FromExisting(kb *keybase.Keybase, prfOutput []byte) (*Enclave, error) { + if kb == nil { + return nil, fmt.Errorf("enclave: keybase cannot be nil") + } + if len(prfOutput) == 0 { + return nil, fmt.Errorf("enclave: PRF output required") + } + + key, err := DeriveEncryptionKey(prfOutput) + if err != nil { + return nil, fmt.Errorf("enclave: key derivation failed: %w", err) + } + + return &Enclave{ + keybase: kb, + encryptionKey: key, + }, nil +} diff --git a/internal/keybase/actions_account.go b/internal/keybase/actions_account.go new file mode 100644 index 0000000..8c139c7 --- /dev/null +++ b/internal/keybase/actions_account.go @@ -0,0 +1,177 @@ +package keybase + +import ( + "context" + "fmt" +) + +type NewAccountInput struct { + KeyShareID int64 `json:"key_share_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,omitempty"` +} + +func (am *ActionManager) CreateAccount(ctx context.Context, params NewAccountInput) (*AccountResult, error) { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + var label *string + if params.Label != "" { + label = ¶ms.Label + } + + acc, err := am.kb.queries.CreateAccount(ctx, CreateAccountParams{ + DidID: am.kb.didID, + KeyShareID: params.KeyShareID, + Address: params.Address, + ChainID: params.ChainID, + CoinType: params.CoinType, + AccountIndex: params.AccountIndex, + AddressIndex: params.AddressIndex, + Label: label, + }) + if err != nil { + return nil, fmt.Errorf("create account: %w", err) + } + + labelStr := "" + if acc.Label != nil { + labelStr = *acc.Label + } + + return &AccountResult{ + ID: acc.ID, + Address: acc.Address, + ChainID: acc.ChainID, + CoinType: acc.CoinType, + AccountIndex: acc.AccountIndex, + AddressIndex: acc.AddressIndex, + Label: labelStr, + IsDefault: acc.IsDefault == 1, + CreatedAt: acc.CreatedAt, + }, nil +} + +func (am *ActionManager) ListAccountsByChain(ctx context.Context, chainID string) ([]AccountResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return []AccountResult{}, nil + } + + accounts, err := am.kb.queries.ListAccountsByChain(ctx, ListAccountsByChainParams{ + DidID: am.kb.didID, + ChainID: chainID, + }) + if err != nil { + return nil, fmt.Errorf("list accounts by chain: %w", err) + } + + results := make([]AccountResult, len(accounts)) + for i, acc := range accounts { + label := "" + if acc.Label != nil { + label = *acc.Label + } + results[i] = 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, + } + } + + return results, nil +} + +func (am *ActionManager) GetDefaultAccount(ctx context.Context, chainID string) (*AccountResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + acc, err := am.kb.queries.GetDefaultAccount(ctx, GetDefaultAccountParams{ + DidID: am.kb.didID, + ChainID: chainID, + }) + if err != nil { + return nil, fmt.Errorf("get default 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 +} + +func (am *ActionManager) SetDefaultAccount(ctx context.Context, accountID int64, chainID string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return fmt.Errorf("DID not initialized") + } + + return am.kb.queries.SetDefaultAccount(ctx, SetDefaultAccountParams{ + ID: accountID, + DidID: am.kb.didID, + ChainID: chainID, + }) +} + +func (am *ActionManager) UpdateAccountLabel(ctx context.Context, accountID int64, label string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + var labelPtr *string + if label != "" { + labelPtr = &label + } + + return am.kb.queries.UpdateAccountLabel(ctx, UpdateAccountLabelParams{ + Label: labelPtr, + ID: accountID, + }) +} + +func (am *ActionManager) DeleteAccount(ctx context.Context, accountID int64) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return fmt.Errorf("DID not initialized") + } + + return am.kb.queries.DeleteAccount(ctx, DeleteAccountParams{ + ID: accountID, + DidID: am.kb.didID, + }) +} diff --git a/internal/keybase/actions_credential.go b/internal/keybase/actions_credential.go new file mode 100644 index 0000000..f3994d8 --- /dev/null +++ b/internal/keybase/actions_credential.go @@ -0,0 +1,155 @@ +package keybase + +import ( + "context" + "encoding/json" + "fmt" +) + +type NewCredentialInput struct { + CredentialID string `json:"credential_id"` + PublicKey string `json:"public_key"` + PublicKeyAlg int64 `json:"public_key_alg"` + AAGUID string `json:"aaguid,omitempty"` + Transports []string `json:"transports"` + DeviceName string `json:"device_name"` + DeviceType string `json:"device_type"` + Authenticator string `json:"authenticator,omitempty"` + IsDiscoverable bool `json:"is_discoverable"` + BackedUp bool `json:"backed_up"` +} + +func (am *ActionManager) CreateCredential(ctx context.Context, params NewCredentialInput) (*CredentialResult, error) { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + var aaguid, authenticator *string + if params.AAGUID != "" { + aaguid = ¶ms.AAGUID + } + if params.Authenticator != "" { + authenticator = ¶ms.Authenticator + } + + transports, err := json.Marshal(params.Transports) + if err != nil { + return nil, fmt.Errorf("marshal transports: %w", err) + } + + var isDiscoverable, backedUp int64 + if params.IsDiscoverable { + isDiscoverable = 1 + } + if params.BackedUp { + backedUp = 1 + } + + cred, err := am.kb.queries.CreateCredential(ctx, CreateCredentialParams{ + DidID: am.kb.didID, + CredentialID: params.CredentialID, + PublicKey: params.PublicKey, + PublicKeyAlg: params.PublicKeyAlg, + Aaguid: aaguid, + Transports: transports, + DeviceName: params.DeviceName, + DeviceType: params.DeviceType, + Authenticator: authenticator, + IsDiscoverable: isDiscoverable, + BackedUp: backedUp, + }) + if err != nil { + return nil, fmt.Errorf("create credential: %w", err) + } + + return credentialToResult(&cred), nil +} + +func (am *ActionManager) UpdateCredentialCounter(ctx context.Context, credentialID string, signCount int64) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + cred, err := am.kb.queries.GetCredentialByID(ctx, credentialID) + if err != nil { + return fmt.Errorf("get credential: %w", err) + } + + return am.kb.queries.UpdateCredentialCounter(ctx, UpdateCredentialCounterParams{ + SignCount: signCount, + ID: cred.ID, + }) +} + +func (am *ActionManager) RenameCredential(ctx context.Context, credentialID string, newName string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + cred, err := am.kb.queries.GetCredentialByID(ctx, credentialID) + if err != nil { + return fmt.Errorf("get credential: %w", err) + } + + return am.kb.queries.RenameCredential(ctx, RenameCredentialParams{ + DeviceName: newName, + ID: cred.ID, + }) +} + +func (am *ActionManager) DeleteCredential(ctx context.Context, credentialID string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return fmt.Errorf("DID not initialized") + } + + cred, err := am.kb.queries.GetCredentialByID(ctx, credentialID) + if err != nil { + return fmt.Errorf("get credential: %w", err) + } + + return am.kb.queries.DeleteCredential(ctx, DeleteCredentialParams{ + ID: cred.ID, + DidID: am.kb.didID, + }) +} + +func (am *ActionManager) CountCredentialsByDID(ctx context.Context) (int64, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return 0, nil + } + + return am.kb.queries.CountCredentialsByDID(ctx, am.kb.didID) +} + +func credentialToResult(cred *Credential) *CredentialResult { + 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, + } +} diff --git a/internal/keybase/actions_delegation.go b/internal/keybase/actions_delegation.go new file mode 100644 index 0000000..eab2434 --- /dev/null +++ b/internal/keybase/actions_delegation.go @@ -0,0 +1,181 @@ +package keybase + +import ( + "context" + "encoding/json" + "fmt" +) + +type DelegationResult struct { + ID int64 `json:"id"` + UcanID int64 `json:"ucan_id"` + Delegator string `json:"delegator"` + Delegate string `json:"delegate"` + Resource string `json:"resource"` + Action string `json:"action"` + Caveats json.RawMessage `json:"caveats"` + Depth int64 `json:"depth"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +type NewDelegationInput struct { + UcanID int64 `json:"ucan_id"` + Delegator string `json:"delegator"` + Delegate string `json:"delegate"` + Resource string `json:"resource"` + Action string `json:"action"` + Caveats json.RawMessage `json:"caveats,omitempty"` + ParentID int64 `json:"parent_id,omitempty"` + Depth int64 `json:"depth"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +func (am *ActionManager) CreateDelegation(ctx context.Context, params NewDelegationInput) (*DelegationResult, error) { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + var parentID *int64 + if params.ParentID != 0 { + parentID = ¶ms.ParentID + } + + var expiresAt *string + if params.ExpiresAt != "" { + expiresAt = ¶ms.ExpiresAt + } + + caveats := params.Caveats + if caveats == nil { + caveats = json.RawMessage(`{}`) + } + + d, err := am.kb.queries.CreateDelegation(ctx, CreateDelegationParams{ + DidID: am.kb.didID, + UcanID: params.UcanID, + Delegator: params.Delegator, + Delegate: params.Delegate, + Resource: params.Resource, + Action: params.Action, + Caveats: caveats, + ParentID: parentID, + Depth: params.Depth, + ExpiresAt: expiresAt, + }) + if err != nil { + return nil, fmt.Errorf("create delegation: %w", err) + } + + return delegationToResult(&d), nil +} + +func (am *ActionManager) ListDelegationsByDelegator(ctx context.Context, delegator string) ([]DelegationResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + delegations, err := am.kb.queries.ListDelegationsByDelegator(ctx, delegator) + if err != nil { + return nil, fmt.Errorf("list delegations by delegator: %w", err) + } + + results := make([]DelegationResult, len(delegations)) + for i, d := range delegations { + results[i] = *delegationToResult(&d) + } + + return results, nil +} + +func (am *ActionManager) ListDelegationsByDelegate(ctx context.Context, delegate string) ([]DelegationResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + delegations, err := am.kb.queries.ListDelegationsByDelegate(ctx, delegate) + if err != nil { + return nil, fmt.Errorf("list delegations by delegate: %w", err) + } + + results := make([]DelegationResult, len(delegations)) + for i, d := range delegations { + results[i] = *delegationToResult(&d) + } + + return results, nil +} + +func (am *ActionManager) ListDelegationsForResource(ctx context.Context, resource string) ([]DelegationResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return []DelegationResult{}, nil + } + + delegations, err := am.kb.queries.ListDelegationsForResource(ctx, ListDelegationsForResourceParams{ + DidID: am.kb.didID, + Resource: resource, + }) + if err != nil { + return nil, fmt.Errorf("list delegations for resource: %w", err) + } + + results := make([]DelegationResult, len(delegations)) + for i, d := range delegations { + results[i] = *delegationToResult(&d) + } + + return results, nil +} + +func (am *ActionManager) GetDelegationChain(ctx context.Context, delegationID int64) ([]DelegationResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + delegations, err := am.kb.queries.GetDelegationChain(ctx, GetDelegationChainParams{ + ID: delegationID, + ParentID: &delegationID, + }) + if err != nil { + return nil, fmt.Errorf("get delegation chain: %w", err) + } + + results := make([]DelegationResult, len(delegations)) + for i, d := range delegations { + results[i] = *delegationToResult(&d) + } + + return results, nil +} + +func (am *ActionManager) RevokeDelegation(ctx context.Context, delegationID int64) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + return am.kb.queries.RevokeDelegation(ctx, delegationID) +} + +func delegationToResult(d *Delegation) *DelegationResult { + expiresAt := "" + if d.ExpiresAt != nil { + expiresAt = *d.ExpiresAt + } + + return &DelegationResult{ + ID: d.ID, + UcanID: d.UcanID, + Delegator: d.Delegator, + Delegate: d.Delegate, + Resource: d.Resource, + Action: d.Action, + Caveats: d.Caveats, + Depth: d.Depth, + Status: d.Status, + CreatedAt: d.CreatedAt, + ExpiresAt: expiresAt, + } +} diff --git a/internal/keybase/actions_grant.go b/internal/keybase/actions_grant.go new file mode 100644 index 0000000..dbf9949 --- /dev/null +++ b/internal/keybase/actions_grant.go @@ -0,0 +1,181 @@ +package keybase + +import ( + "context" + "encoding/json" + "fmt" +) + +type NewGrantInput struct { + ServiceID int64 `json:"service_id"` + UcanID int64 `json:"ucan_id,omitempty"` + Scopes json.RawMessage `json:"scopes"` + Accounts json.RawMessage `json:"accounts"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +func (am *ActionManager) CreateGrant(ctx context.Context, params NewGrantInput) (*GrantResult, error) { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + var ucanID *int64 + if params.UcanID != 0 { + ucanID = ¶ms.UcanID + } + + var expiresAt *string + if params.ExpiresAt != "" { + expiresAt = ¶ms.ExpiresAt + } + + scopes := params.Scopes + if scopes == nil { + scopes = json.RawMessage(`[]`) + } + accounts := params.Accounts + if accounts == nil { + accounts = json.RawMessage(`[]`) + } + + g, err := am.kb.queries.CreateGrant(ctx, CreateGrantParams{ + DidID: am.kb.didID, + ServiceID: params.ServiceID, + UcanID: ucanID, + Scopes: scopes, + Accounts: accounts, + ExpiresAt: expiresAt, + }) + if err != nil { + return nil, fmt.Errorf("create grant: %w", err) + } + + svc, err := am.kb.queries.GetServiceByID(ctx, g.ServiceID) + if err != nil { + return nil, fmt.Errorf("get service: %w", err) + } + + serviceLogo := "" + if svc.LogoUrl != nil { + serviceLogo = *svc.LogoUrl + } + + lastUsed := "" + if g.LastUsed != nil { + lastUsed = *g.LastUsed + } + + expires := "" + if g.ExpiresAt != nil { + expires = *g.ExpiresAt + } + + return &GrantResult{ + ID: g.ID, + ServiceName: svc.Name, + ServiceOrigin: svc.Origin, + ServiceLogo: serviceLogo, + Scopes: g.Scopes, + Accounts: g.Accounts, + Status: g.Status, + GrantedAt: g.GrantedAt, + LastUsed: lastUsed, + ExpiresAt: expires, + }, nil +} + +func (am *ActionManager) GetGrantByService(ctx context.Context, serviceID int64) (*GrantResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + g, err := am.kb.queries.GetGrantByService(ctx, GetGrantByServiceParams{ + DidID: am.kb.didID, + ServiceID: serviceID, + }) + if err != nil { + return nil, fmt.Errorf("get grant by service: %w", err) + } + + svc, err := am.kb.queries.GetServiceByID(ctx, g.ServiceID) + if err != nil { + return nil, fmt.Errorf("get service: %w", err) + } + + serviceLogo := "" + if svc.LogoUrl != nil { + serviceLogo = *svc.LogoUrl + } + + lastUsed := "" + if g.LastUsed != nil { + lastUsed = *g.LastUsed + } + + expires := "" + if g.ExpiresAt != nil { + expires = *g.ExpiresAt + } + + return &GrantResult{ + ID: g.ID, + ServiceName: svc.Name, + ServiceOrigin: svc.Origin, + ServiceLogo: serviceLogo, + Scopes: g.Scopes, + Accounts: g.Accounts, + Status: g.Status, + GrantedAt: g.GrantedAt, + LastUsed: lastUsed, + ExpiresAt: expires, + }, nil +} + +func (am *ActionManager) UpdateGrantScopes(ctx context.Context, grantID int64, scopes, accounts json.RawMessage) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + return am.kb.queries.UpdateGrantScopes(ctx, UpdateGrantScopesParams{ + Scopes: scopes, + Accounts: accounts, + ID: grantID, + }) +} + +func (am *ActionManager) UpdateGrantLastUsed(ctx context.Context, grantID int64) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + return am.kb.queries.UpdateGrantLastUsed(ctx, grantID) +} + +func (am *ActionManager) SuspendGrant(ctx context.Context, grantID int64) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + return am.kb.queries.SuspendGrant(ctx, grantID) +} + +func (am *ActionManager) ReactivateGrant(ctx context.Context, grantID int64) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + return am.kb.queries.ReactivateGrant(ctx, grantID) +} + +func (am *ActionManager) CountActiveGrants(ctx context.Context) (int64, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return 0, nil + } + + return am.kb.queries.CountActiveGrants(ctx, am.kb.didID) +} diff --git a/internal/keybase/actions_keyshare.go b/internal/keybase/actions_keyshare.go new file mode 100644 index 0000000..974df80 --- /dev/null +++ b/internal/keybase/actions_keyshare.go @@ -0,0 +1,206 @@ +package keybase + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" +) + +type KeyShareResult struct { + ID int64 `json:"id"` + ShareID string `json:"share_id"` + KeyID string `json:"key_id"` + PartyIndex int64 `json:"party_index"` + Threshold int64 `json:"threshold"` + TotalParties int64 `json:"total_parties"` + Curve string `json:"curve"` + PublicKey string `json:"public_key"` + ChainCode string `json:"chain_code,omitempty"` + DerivationPath string `json:"derivation_path,omitempty"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + RotatedAt string `json:"rotated_at,omitempty"` +} + +type NewKeyShareInput struct { + KeyID string `json:"key_id"` + PartyIndex int64 `json:"party_index"` + Threshold int64 `json:"threshold"` + TotalParties int64 `json:"total_parties"` + Curve string `json:"curve"` + ShareData string `json:"share_data"` + PublicKey string `json:"public_key"` + ChainCode string `json:"chain_code,omitempty"` + DerivationPath string `json:"derivation_path,omitempty"` +} + +func (am *ActionManager) CreateKeyShare(ctx context.Context, params NewKeyShareInput) (*KeyShareResult, error) { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + shareID := generateShareID() + + var chainCode, derivationPath *string + if params.ChainCode != "" { + chainCode = ¶ms.ChainCode + } + if params.DerivationPath != "" { + derivationPath = ¶ms.DerivationPath + } + + ks, err := am.kb.queries.CreateKeyShare(ctx, CreateKeyShareParams{ + DidID: am.kb.didID, + ShareID: shareID, + KeyID: params.KeyID, + PartyIndex: params.PartyIndex, + Threshold: params.Threshold, + TotalParties: params.TotalParties, + Curve: params.Curve, + ShareData: params.ShareData, + PublicKey: params.PublicKey, + ChainCode: chainCode, + DerivationPath: derivationPath, + }) + if err != nil { + return nil, fmt.Errorf("create key share: %w", err) + } + + return keyShareToResult(&ks), nil +} + +func (am *ActionManager) ListKeyShares(ctx context.Context) ([]KeyShareResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return []KeyShareResult{}, nil + } + + shares, err := am.kb.queries.ListKeySharesByDID(ctx, am.kb.didID) + if err != nil { + return nil, fmt.Errorf("list key shares: %w", err) + } + + results := make([]KeyShareResult, len(shares)) + for i, ks := range shares { + results[i] = *keyShareToResult(&ks) + } + + return results, nil +} + +func (am *ActionManager) GetKeyShareByID(ctx context.Context, shareID string) (*KeyShareResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + ks, err := am.kb.queries.GetKeyShareByID(ctx, shareID) + if err != nil { + return nil, fmt.Errorf("get key share: %w", err) + } + + return keyShareToResult(&ks), nil +} + +func (am *ActionManager) GetKeyShareByKeyID(ctx context.Context, keyID string) (*KeyShareResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + ks, err := am.kb.queries.GetKeyShareByKeyID(ctx, GetKeyShareByKeyIDParams{ + DidID: am.kb.didID, + KeyID: keyID, + }) + if err != nil { + return nil, fmt.Errorf("get key share by key ID: %w", err) + } + + return keyShareToResult(&ks), nil +} + +func (am *ActionManager) RotateKeyShare(ctx context.Context, shareID string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + ks, err := am.kb.queries.GetKeyShareByID(ctx, shareID) + if err != nil { + return fmt.Errorf("get key share: %w", err) + } + + return am.kb.queries.RotateKeyShare(ctx, ks.ID) +} + +func (am *ActionManager) ArchiveKeyShare(ctx context.Context, shareID string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + ks, err := am.kb.queries.GetKeyShareByID(ctx, shareID) + if err != nil { + return fmt.Errorf("get key share: %w", err) + } + + return am.kb.queries.ArchiveKeyShare(ctx, ks.ID) +} + +func (am *ActionManager) DeleteKeyShare(ctx context.Context, shareID string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return fmt.Errorf("DID not initialized") + } + + ks, err := am.kb.queries.GetKeyShareByID(ctx, shareID) + if err != nil { + return fmt.Errorf("get key share: %w", err) + } + + return am.kb.queries.DeleteKeyShare(ctx, DeleteKeyShareParams{ + ID: ks.ID, + DidID: am.kb.didID, + }) +} + +func generateShareID() string { + b := make([]byte, 16) + rand.Read(b) + return "ks_" + hex.EncodeToString(b) +} + +func keyShareToResult(ks *KeyShare) *KeyShareResult { + chainCode := "" + if ks.ChainCode != nil { + chainCode = *ks.ChainCode + } + derivationPath := "" + if ks.DerivationPath != nil { + derivationPath = *ks.DerivationPath + } + rotatedAt := "" + if ks.RotatedAt != nil { + rotatedAt = *ks.RotatedAt + } + + return &KeyShareResult{ + ID: ks.ID, + ShareID: ks.ShareID, + KeyID: ks.KeyID, + PartyIndex: ks.PartyIndex, + Threshold: ks.Threshold, + TotalParties: ks.TotalParties, + Curve: ks.Curve, + PublicKey: ks.PublicKey, + ChainCode: chainCode, + DerivationPath: derivationPath, + Status: ks.Status, + CreatedAt: ks.CreatedAt, + RotatedAt: rotatedAt, + } +} diff --git a/internal/keybase/actions_service.go b/internal/keybase/actions_service.go new file mode 100644 index 0000000..9bafb37 --- /dev/null +++ b/internal/keybase/actions_service.go @@ -0,0 +1,172 @@ +package keybase + +import ( + "context" + "encoding/json" + "fmt" +) + +type ServiceResult struct { + ID int64 `json:"id"` + Origin string `json:"origin"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + DID string `json:"did,omitempty"` + IsVerified bool `json:"is_verified"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` +} + +type NewServiceInput struct { + Origin string `json:"origin"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + DID string `json:"did,omitempty"` + IsVerified bool `json:"is_verified"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +type UpdateServiceInput struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +func (am *ActionManager) CreateService(ctx context.Context, params NewServiceInput) (*ServiceResult, error) { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + var description, logoURL, did *string + if params.Description != "" { + description = ¶ms.Description + } + if params.LogoURL != "" { + logoURL = ¶ms.LogoURL + } + if params.DID != "" { + did = ¶ms.DID + } + + metadata := params.Metadata + if metadata == nil { + metadata = json.RawMessage(`{}`) + } + + var isVerified int64 + if params.IsVerified { + isVerified = 1 + } + + svc, err := am.kb.queries.CreateService(ctx, CreateServiceParams{ + Origin: params.Origin, + Name: params.Name, + Description: description, + LogoUrl: logoURL, + Did: did, + IsVerified: isVerified, + Metadata: metadata, + }) + if err != nil { + return nil, fmt.Errorf("create service: %w", err) + } + + return serviceToResult(&svc), nil +} + +func (am *ActionManager) GetServiceByOrigin(ctx context.Context, origin string) (*ServiceResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + svc, err := am.kb.queries.GetServiceByOrigin(ctx, origin) + if err != nil { + return nil, fmt.Errorf("get service by origin: %w", err) + } + + return serviceToResult(&svc), nil +} + +func (am *ActionManager) GetServiceByID(ctx context.Context, serviceID int64) (*ServiceResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + svc, err := am.kb.queries.GetServiceByID(ctx, serviceID) + if err != nil { + return nil, fmt.Errorf("get service by ID: %w", err) + } + + return serviceToResult(&svc), nil +} + +func (am *ActionManager) UpdateService(ctx context.Context, params UpdateServiceInput) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + var description, logoURL *string + if params.Description != "" { + description = ¶ms.Description + } + if params.LogoURL != "" { + logoURL = ¶ms.LogoURL + } + + metadata := params.Metadata + if metadata == nil { + metadata = json.RawMessage(`{}`) + } + + return am.kb.queries.UpdateService(ctx, UpdateServiceParams{ + Name: params.Name, + Description: description, + LogoUrl: logoURL, + Metadata: metadata, + ID: params.ID, + }) +} + +func (am *ActionManager) ListVerifiedServices(ctx context.Context) ([]ServiceResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + services, err := am.kb.queries.ListVerifiedServices(ctx) + if err != nil { + return nil, fmt.Errorf("list verified services: %w", err) + } + + results := make([]ServiceResult, len(services)) + for i, svc := range services { + results[i] = *serviceToResult(&svc) + } + + return results, nil +} + +func serviceToResult(svc *Service) *ServiceResult { + description := "" + if svc.Description != nil { + description = *svc.Description + } + logoURL := "" + if svc.LogoUrl != nil { + logoURL = *svc.LogoUrl + } + did := "" + if svc.Did != nil { + did = *svc.Did + } + + return &ServiceResult{ + ID: svc.ID, + Origin: svc.Origin, + Name: svc.Name, + Description: description, + LogoURL: logoURL, + DID: did, + IsVerified: svc.IsVerified == 1, + Metadata: svc.Metadata, + CreatedAt: svc.CreatedAt, + } +} diff --git a/internal/keybase/actions_session.go b/internal/keybase/actions_session.go new file mode 100644 index 0000000..1bbacb7 --- /dev/null +++ b/internal/keybase/actions_session.go @@ -0,0 +1,84 @@ +package keybase + +import ( + "context" + "fmt" +) + +func (am *ActionManager) GetSessionByID(ctx context.Context, sessionID string) (*SessionResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + sess, err := am.kb.queries.GetSessionByID(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("get session: %w", err) + } + + return sessionToResult(&sess), nil +} + +func (am *ActionManager) GetCurrentSession(ctx context.Context) (*SessionResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + sess, err := am.kb.queries.GetCurrentSession(ctx, am.kb.didID) + if err != nil { + return nil, fmt.Errorf("get current session: %w", err) + } + + return sessionToResult(&sess), nil +} + +func (am *ActionManager) UpdateSessionActivity(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) + } + + return am.kb.queries.UpdateSessionActivity(ctx, sess.ID) +} + +func (am *ActionManager) SetCurrentSession(ctx context.Context, sessionID string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return fmt.Errorf("DID not initialized") + } + + sess, err := am.kb.queries.GetSessionByID(ctx, sessionID) + if err != nil { + return fmt.Errorf("get session: %w", err) + } + + return am.kb.queries.SetCurrentSession(ctx, SetCurrentSessionParams{ + ID: sess.ID, + DidID: am.kb.didID, + }) +} + +func (am *ActionManager) DeleteExpiredSessions(ctx context.Context) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + return am.kb.queries.DeleteExpiredSessions(ctx) +} + +func sessionToResult(sess *Session) *SessionResult { + return &SessionResult{ + ID: sess.ID, + SessionID: sess.SessionID, + DeviceInfo: sess.DeviceInfo, + IsCurrent: sess.IsCurrent == 1, + LastActivity: sess.LastActivity, + ExpiresAt: sess.ExpiresAt, + CreatedAt: sess.CreatedAt, + } +} diff --git a/internal/keybase/actions_ucan.go b/internal/keybase/actions_ucan.go new file mode 100644 index 0000000..eb367e9 --- /dev/null +++ b/internal/keybase/actions_ucan.go @@ -0,0 +1,205 @@ +package keybase + +import ( + "context" + "encoding/json" + "fmt" +) + +type UCANResult struct { + ID int64 `json:"id"` + CID string `json:"cid"` + Issuer string `json:"issuer"` + Audience string `json:"audience"` + Subject string `json:"subject,omitempty"` + Capabilities json.RawMessage `json:"capabilities"` + NotBefore string `json:"not_before,omitempty"` + ExpiresAt string `json:"expires_at"` + IsRevoked bool `json:"is_revoked"` + CreatedAt string `json:"created_at"` +} + +type NewUCANInput struct { + CID string `json:"cid"` + Issuer string `json:"issuer"` + Audience string `json:"audience"` + Subject string `json:"subject,omitempty"` + Capabilities json.RawMessage `json:"capabilities"` + ProofChain json.RawMessage `json:"proof_chain,omitempty"` + NotBefore string `json:"not_before,omitempty"` + ExpiresAt string `json:"expires_at"` + Nonce string `json:"nonce,omitempty"` + Facts json.RawMessage `json:"facts,omitempty"` + Signature string `json:"signature"` + RawToken string `json:"raw_token"` +} + +func (am *ActionManager) CreateUCAN(ctx context.Context, params NewUCANInput) (*UCANResult, error) { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + var subject, notBefore, nonce *string + if params.Subject != "" { + subject = ¶ms.Subject + } + if params.NotBefore != "" { + notBefore = ¶ms.NotBefore + } + if params.Nonce != "" { + nonce = ¶ms.Nonce + } + + proofChain := params.ProofChain + if proofChain == nil { + proofChain = json.RawMessage(`[]`) + } + facts := params.Facts + if facts == nil { + facts = json.RawMessage(`{}`) + } + + ucan, err := am.kb.queries.CreateUCAN(ctx, CreateUCANParams{ + DidID: am.kb.didID, + Cid: params.CID, + Issuer: params.Issuer, + Audience: params.Audience, + Subject: subject, + Capabilities: params.Capabilities, + ProofChain: proofChain, + NotBefore: notBefore, + ExpiresAt: params.ExpiresAt, + Nonce: nonce, + Facts: facts, + Signature: params.Signature, + RawToken: params.RawToken, + }) + if err != nil { + return nil, fmt.Errorf("create ucan: %w", err) + } + + return ucanToResult(&ucan), nil +} + +func (am *ActionManager) ListUCANs(ctx context.Context) ([]UCANResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return []UCANResult{}, nil + } + + ucans, err := am.kb.queries.ListUCANsByDID(ctx, am.kb.didID) + if err != nil { + return nil, fmt.Errorf("list ucans: %w", err) + } + + results := make([]UCANResult, len(ucans)) + for i, u := range ucans { + results[i] = *ucanToResult(&u) + } + + return results, nil +} + +func (am *ActionManager) GetUCANByCID(ctx context.Context, cid string) (*UCANResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + ucan, err := am.kb.queries.GetUCANByCID(ctx, cid) + if err != nil { + return nil, fmt.Errorf("get ucan: %w", err) + } + + return ucanToResult(&ucan), nil +} + +func (am *ActionManager) ListUCANsByAudience(ctx context.Context, audience string) ([]UCANResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + ucans, err := am.kb.queries.ListUCANsByAudience(ctx, audience) + if err != nil { + return nil, fmt.Errorf("list ucans by audience: %w", err) + } + + results := make([]UCANResult, len(ucans)) + for i, u := range ucans { + results[i] = *ucanToResult(&u) + } + + return results, nil +} + +func (am *ActionManager) RevokeUCAN(ctx context.Context, cid string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + return am.kb.queries.RevokeUCAN(ctx, cid) +} + +func (am *ActionManager) IsUCANRevoked(ctx context.Context, cid string) (bool, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + revoked, err := am.kb.queries.IsUCANRevoked(ctx, cid) + if err != nil { + return false, fmt.Errorf("check ucan revocation: %w", err) + } + + return revoked == 1, nil +} + +func (am *ActionManager) CreateRevocation(ctx context.Context, ucanCID string, revokedBy string, reason string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + var reasonPtr *string + if reason != "" { + reasonPtr = &reason + } + + if err := am.kb.queries.RevokeUCAN(ctx, ucanCID); err != nil { + return fmt.Errorf("revoke ucan token: %w", err) + } + + return am.kb.queries.CreateRevocation(ctx, CreateRevocationParams{ + UcanCid: ucanCID, + RevokedBy: revokedBy, + Reason: reasonPtr, + }) +} + +func (am *ActionManager) CleanExpiredUCANs(ctx context.Context) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + return am.kb.queries.CleanExpiredUCANs(ctx) +} + +func ucanToResult(u *UcanToken) *UCANResult { + subject := "" + if u.Subject != nil { + subject = *u.Subject + } + notBefore := "" + if u.NotBefore != nil { + notBefore = *u.NotBefore + } + + return &UCANResult{ + ID: u.ID, + CID: u.Cid, + Issuer: u.Issuer, + Audience: u.Audience, + Subject: subject, + Capabilities: u.Capabilities, + NotBefore: notBefore, + ExpiresAt: u.ExpiresAt, + IsRevoked: u.IsRevoked == 1, + CreatedAt: u.CreatedAt, + } +} diff --git a/internal/keybase/actions_verification.go b/internal/keybase/actions_verification.go new file mode 100644 index 0000000..5d38990 --- /dev/null +++ b/internal/keybase/actions_verification.go @@ -0,0 +1,114 @@ +package keybase + +import ( + "context" + "fmt" +) + +type NewVerificationMethodInput struct { + MethodID string `json:"method_id"` + MethodType string `json:"method_type"` + Controller string `json:"controller"` + PublicKey string `json:"public_key"` + Purpose string `json:"purpose"` +} + +func (am *ActionManager) CreateVerificationMethod(ctx context.Context, params NewVerificationMethodInput) (*VerificationMethodResult, error) { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + vm, err := am.kb.queries.CreateVerificationMethod(ctx, CreateVerificationMethodParams{ + DidID: am.kb.didID, + MethodID: params.MethodID, + MethodType: params.MethodType, + Controller: params.Controller, + PublicKey: params.PublicKey, + Purpose: params.Purpose, + }) + if err != nil { + return nil, fmt.Errorf("create verification method: %w", err) + } + + return &VerificationMethodResult{ + ID: vm.MethodID, + Type: vm.MethodType, + Controller: vm.Controller, + PublicKey: vm.PublicKey, + Purpose: vm.Purpose, + }, nil +} + +func (am *ActionManager) ListVerificationMethodsFull(ctx context.Context) ([]VerificationMethodResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return []VerificationMethodResult{}, nil + } + + vms, err := am.kb.queries.ListVerificationMethods(ctx, am.kb.didID) + if err != nil { + return nil, fmt.Errorf("list verification methods: %w", err) + } + + results := make([]VerificationMethodResult, len(vms)) + for i, vm := range vms { + results[i] = VerificationMethodResult{ + ID: vm.MethodID, + Type: vm.MethodType, + Controller: vm.Controller, + PublicKey: vm.PublicKey, + Purpose: vm.Purpose, + } + } + + return results, nil +} + +func (am *ActionManager) GetVerificationMethod(ctx context.Context, methodID string) (*VerificationMethodResult, error) { + am.kb.mu.RLock() + defer am.kb.mu.RUnlock() + + if am.kb.didID == 0 { + return nil, fmt.Errorf("DID not initialized") + } + + vm, err := am.kb.queries.GetVerificationMethod(ctx, GetVerificationMethodParams{ + DidID: am.kb.didID, + MethodID: methodID, + }) + if err != nil { + return nil, fmt.Errorf("get verification method: %w", err) + } + + return &VerificationMethodResult{ + ID: vm.MethodID, + Type: vm.MethodType, + Controller: vm.Controller, + PublicKey: vm.PublicKey, + Purpose: vm.Purpose, + }, nil +} + +func (am *ActionManager) DeleteVerificationMethod(ctx context.Context, methodID string) error { + am.kb.mu.Lock() + defer am.kb.mu.Unlock() + + if am.kb.didID == 0 { + return fmt.Errorf("DID not initialized") + } + + vm, err := am.kb.queries.GetVerificationMethod(ctx, GetVerificationMethodParams{ + DidID: am.kb.didID, + MethodID: methodID, + }) + if err != nil { + return fmt.Errorf("get verification method: %w", err) + } + + return am.kb.queries.DeleteVerificationMethod(ctx, vm.ID) +}