Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
109
cloud/maplefile-backend/pkg/maplefile/client/auth.go
Normal file
109
cloud/maplefile-backend/pkg/maplefile/client/auth.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// Package client provides a Go SDK for interacting with the MapleFile API.
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// Register creates a new user account.
|
||||
func (c *Client) Register(ctx context.Context, input *RegisterInput) (*RegisterResponse, error) {
|
||||
var resp RegisterResponse
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/register", input, &resp, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// VerifyEmailCode verifies the email verification code.
|
||||
func (c *Client) VerifyEmailCode(ctx context.Context, input *VerifyEmailInput) (*VerifyEmailResponse, error) {
|
||||
var resp VerifyEmailResponse
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/verify-email-code", input, &resp, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ResendVerification resends the email verification code.
|
||||
func (c *Client) ResendVerification(ctx context.Context, email string) error {
|
||||
input := ResendVerificationInput{Email: email}
|
||||
return c.doRequest(ctx, "POST", "/api/v1/resend-verification", input, nil, false)
|
||||
}
|
||||
|
||||
// RequestOTT requests a One-Time Token for login.
|
||||
func (c *Client) RequestOTT(ctx context.Context, email string) (*OTTResponse, error) {
|
||||
input := map[string]string{"email": email}
|
||||
var resp OTTResponse
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/request-ott", input, &resp, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// VerifyOTT verifies a One-Time Token and returns the encrypted challenge.
|
||||
func (c *Client) VerifyOTT(ctx context.Context, email, ott string) (*VerifyOTTResponse, error) {
|
||||
input := map[string]string{
|
||||
"email": email,
|
||||
"ott": ott,
|
||||
}
|
||||
var resp VerifyOTTResponse
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/verify-ott", input, &resp, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CompleteLogin completes the login process with the decrypted challenge.
|
||||
// On success, the client automatically stores the tokens and calls the OnTokenRefresh callback.
|
||||
func (c *Client) CompleteLogin(ctx context.Context, input *CompleteLoginInput) (*LoginResponse, error) {
|
||||
var resp LoginResponse
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/complete-login", input, &resp, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store the tokens
|
||||
c.SetTokens(resp.AccessToken, resp.RefreshToken)
|
||||
|
||||
// Notify callback if set, passing the expiry date
|
||||
if c.onTokenRefresh != nil {
|
||||
c.onTokenRefresh(resp.AccessToken, resp.RefreshToken, resp.AccessTokenExpiryDate)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// RefreshToken manually refreshes the access token using the stored refresh token.
|
||||
// On success, the client automatically updates the stored tokens and calls the OnTokenRefresh callback.
|
||||
func (c *Client) RefreshToken(ctx context.Context) error {
|
||||
return c.refreshAccessToken(ctx)
|
||||
}
|
||||
|
||||
// RecoveryInitiate initiates the account recovery process.
|
||||
func (c *Client) RecoveryInitiate(ctx context.Context, email, method string) (*RecoveryInitiateResponse, error) {
|
||||
input := RecoveryInitiateInput{
|
||||
Email: email,
|
||||
Method: method,
|
||||
}
|
||||
var resp RecoveryInitiateResponse
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/recovery/initiate", input, &resp, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// RecoveryVerify verifies the recovery challenge.
|
||||
func (c *Client) RecoveryVerify(ctx context.Context, input *RecoveryVerifyInput) (*RecoveryVerifyResponse, error) {
|
||||
var resp RecoveryVerifyResponse
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/recovery/verify", input, &resp, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// RecoveryComplete completes the account recovery and resets credentials.
|
||||
func (c *Client) RecoveryComplete(ctx context.Context, input *RecoveryCompleteInput) (*RecoveryCompleteResponse, error) {
|
||||
var resp RecoveryCompleteResponse
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/recovery/complete", input, &resp, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
468
cloud/maplefile-backend/pkg/maplefile/client/client.go
Normal file
468
cloud/maplefile-backend/pkg/maplefile/client/client.go
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
// Package client provides a Go SDK for interacting with the MapleFile API.
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Logger is an interface for logging API requests.
|
||||
// This allows the client to work with any logging library (zap, logrus, etc.)
|
||||
type Logger interface {
|
||||
// Debug logs a debug message with optional key-value pairs
|
||||
Debug(msg string, keysAndValues ...interface{})
|
||||
// Info logs an info message with optional key-value pairs
|
||||
Info(msg string, keysAndValues ...interface{})
|
||||
// Warn logs a warning message with optional key-value pairs
|
||||
Warn(msg string, keysAndValues ...interface{})
|
||||
// Error logs an error message with optional key-value pairs
|
||||
Error(msg string, keysAndValues ...interface{})
|
||||
}
|
||||
|
||||
// Client is the MapleFile API client.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
logger Logger
|
||||
|
||||
// Token storage with mutex for thread safety
|
||||
mu sync.RWMutex
|
||||
accessToken string
|
||||
refreshToken string
|
||||
|
||||
// Callback when tokens are refreshed
|
||||
// Parameters: accessToken, refreshToken, accessTokenExpiryDate (RFC3339 format)
|
||||
onTokenRefresh func(accessToken, refreshToken, accessTokenExpiryDate string)
|
||||
|
||||
// Flag to prevent recursive token refresh (atomic for lock-free reads)
|
||||
isRefreshing atomic.Bool
|
||||
}
|
||||
|
||||
// Predefined environment URLs
|
||||
const (
|
||||
// ProductionURL is the production API endpoint
|
||||
ProductionURL = "https://maplefile.ca"
|
||||
|
||||
// LocalURL is the default local development API endpoint
|
||||
LocalURL = "http://localhost:8000"
|
||||
)
|
||||
|
||||
// Config holds the configuration for creating a new Client.
|
||||
type Config struct {
|
||||
// BaseURL is the base URL of the MapleFile API (e.g., "https://maplefile.ca")
|
||||
// You can use predefined constants: ProductionURL or LocalURL
|
||||
BaseURL string
|
||||
|
||||
// HTTPClient is an optional custom HTTP client. If nil, a default client with 30s timeout is used.
|
||||
HTTPClient *http.Client
|
||||
|
||||
// Logger is an optional logger for API request logging. If nil, no logging is performed.
|
||||
Logger Logger
|
||||
}
|
||||
|
||||
// New creates a new MapleFile API client with the given configuration.
|
||||
//
|
||||
// Security Note: This client uses Go's standard http.Client without certificate
|
||||
// pinning. This is intentional and secure because:
|
||||
//
|
||||
// 1. TLS termination is handled by a reverse proxy (Caddy/Nginx) in production,
|
||||
// which manages certificates via Let's Encrypt with automatic renewal.
|
||||
// 2. Go's default TLS configuration already validates certificate chains,
|
||||
// expiration, and hostname matching against system CA roots.
|
||||
// 3. The application uses end-to-end encryption (E2EE) - even if TLS were
|
||||
// compromised, attackers would only see encrypted data they cannot decrypt.
|
||||
// 4. Certificate pinning would require app updates every 90 days (Let's Encrypt
|
||||
// rotation) or risk bricking deployed applications.
|
||||
//
|
||||
// See: docs/OWASP_AUDIT_REPORT.md (Finding 4.1) for full security analysis.
|
||||
func New(cfg Config) *Client {
|
||||
httpClient := cfg.HTTPClient
|
||||
if httpClient == nil {
|
||||
// Standard HTTP client with timeout. Certificate pinning is intentionally
|
||||
// not implemented - see security note above.
|
||||
httpClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure baseURL doesn't have trailing slash
|
||||
baseURL := strings.TrimSuffix(cfg.BaseURL, "/")
|
||||
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
httpClient: httpClient,
|
||||
logger: cfg.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// NewProduction creates a new MapleFile API client configured for production.
|
||||
func NewProduction() *Client {
|
||||
return New(Config{BaseURL: ProductionURL})
|
||||
}
|
||||
|
||||
// NewLocal creates a new MapleFile API client configured for local development.
|
||||
func NewLocal() *Client {
|
||||
return New(Config{BaseURL: LocalURL})
|
||||
}
|
||||
|
||||
// NewWithURL creates a new MapleFile API client with a custom URL.
|
||||
func NewWithURL(baseURL string) *Client {
|
||||
return New(Config{BaseURL: baseURL})
|
||||
}
|
||||
|
||||
// SetTokens sets the access and refresh tokens for authentication.
|
||||
func (c *Client) SetTokens(accessToken, refreshToken string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.accessToken = accessToken
|
||||
c.refreshToken = refreshToken
|
||||
}
|
||||
|
||||
// GetTokens returns the current access and refresh tokens.
|
||||
func (c *Client) GetTokens() (accessToken, refreshToken string) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.accessToken, c.refreshToken
|
||||
}
|
||||
|
||||
// OnTokenRefresh sets a callback function that will be called when tokens are refreshed.
|
||||
// This is useful for persisting the new tokens to storage.
|
||||
// The callback receives: accessToken, refreshToken, and accessTokenExpiryDate (RFC3339 format).
|
||||
func (c *Client) OnTokenRefresh(callback func(accessToken, refreshToken, accessTokenExpiryDate string)) {
|
||||
c.onTokenRefresh = callback
|
||||
}
|
||||
|
||||
// SetBaseURL changes the base URL of the API.
|
||||
// This is useful for switching between environments at runtime.
|
||||
func (c *Client) SetBaseURL(baseURL string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.baseURL = strings.TrimSuffix(baseURL, "/")
|
||||
}
|
||||
|
||||
// GetBaseURL returns the current base URL.
|
||||
func (c *Client) GetBaseURL() string {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.baseURL
|
||||
}
|
||||
|
||||
// Health checks if the API is healthy.
|
||||
func (c *Client) Health(ctx context.Context) (*HealthResponse, error) {
|
||||
var resp HealthResponse
|
||||
if err := c.doRequest(ctx, "GET", "/health", nil, &resp, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Version returns the API version information.
|
||||
func (c *Client) Version(ctx context.Context) (*VersionResponse, error) {
|
||||
var resp VersionResponse
|
||||
if err := c.doRequest(ctx, "GET", "/version", nil, &resp, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP request with automatic token refresh on 401.
|
||||
func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}, result interface{}, requiresAuth bool) error {
|
||||
return c.doRequestWithRetry(ctx, method, path, body, result, requiresAuth, true)
|
||||
}
|
||||
|
||||
// doRequestWithRetry performs an HTTP request with optional retry on 401.
|
||||
func (c *Client) doRequestWithRetry(ctx context.Context, method, path string, body interface{}, result interface{}, requiresAuth bool, allowRetry bool) error {
|
||||
// Build URL
|
||||
url := c.baseURL + path
|
||||
|
||||
// Log API request
|
||||
if c.logger != nil {
|
||||
c.logger.Info("API request", "method", method, "url", url)
|
||||
}
|
||||
|
||||
// Prepare request body
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(jsonData)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// Accept both standard JSON and RFC 9457 problem+json responses
|
||||
req.Header.Set("Accept", "application/json, application/problem+json")
|
||||
|
||||
// Add authorization header if required
|
||||
if requiresAuth {
|
||||
c.mu.RLock()
|
||||
token := c.accessToken
|
||||
c.mu.RUnlock()
|
||||
|
||||
if token == "" {
|
||||
return &APIError{
|
||||
ProblemDetail: ProblemDetail{
|
||||
Status: 401,
|
||||
Title: "Unauthorized",
|
||||
Detail: "No access token available",
|
||||
},
|
||||
}
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("JWT %s", token))
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if c.logger != nil {
|
||||
c.logger.Error("API request failed", "method", method, "url", url, "error", err.Error())
|
||||
}
|
||||
return fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Log API response
|
||||
if c.logger != nil {
|
||||
c.logger.Info("API response", "method", method, "url", url, "status", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Handle 401 with automatic token refresh
|
||||
// Use atomic.Bool for lock-free check to avoid unnecessary lock acquisition
|
||||
if resp.StatusCode == http.StatusUnauthorized && requiresAuth && allowRetry && !c.isRefreshing.Load() {
|
||||
c.mu.Lock()
|
||||
// Double-check under lock and verify refresh token exists
|
||||
if c.refreshToken != "" && !c.isRefreshing.Load() {
|
||||
c.isRefreshing.Store(true)
|
||||
c.mu.Unlock()
|
||||
|
||||
// Attempt to refresh token
|
||||
refreshErr := c.refreshAccessToken(ctx)
|
||||
c.isRefreshing.Store(false)
|
||||
|
||||
if refreshErr == nil {
|
||||
// Retry the original request without allowing another retry
|
||||
return c.doRequestWithRetry(ctx, method, path, body, result, requiresAuth, false)
|
||||
}
|
||||
} else {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error status codes
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return parseErrorResponse(respBody, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse successful response
|
||||
if result != nil && len(respBody) > 0 {
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshAccessToken attempts to refresh the access token using the refresh token.
|
||||
func (c *Client) refreshAccessToken(ctx context.Context) error {
|
||||
c.mu.RLock()
|
||||
refreshToken := c.refreshToken
|
||||
c.mu.RUnlock()
|
||||
|
||||
if refreshToken == "" {
|
||||
return fmt.Errorf("no refresh token available")
|
||||
}
|
||||
|
||||
// Build refresh request
|
||||
url := c.baseURL + "/api/v1/token/refresh"
|
||||
|
||||
reqBody := map[string]string{
|
||||
"value": refreshToken, // Backend expects "value" field, not "refresh_token"
|
||||
}
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return parseErrorResponse(respBody, resp.StatusCode)
|
||||
}
|
||||
|
||||
// Parse the refresh response
|
||||
// Note: Backend returns access_token_expiry_date and refresh_token_expiry_date,
|
||||
// but the callback currently only passes tokens. Expiry dates are available
|
||||
// in the LoginResponse type if needed for future enhancements.
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
AccessTokenExpiryDate string `json:"access_token_expiry_date"`
|
||||
RefreshTokenExpiryDate string `json:"refresh_token_expiry_date"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update stored tokens
|
||||
c.mu.Lock()
|
||||
c.accessToken = tokenResp.AccessToken
|
||||
c.refreshToken = tokenResp.RefreshToken
|
||||
c.mu.Unlock()
|
||||
|
||||
// Notify callback if set, passing the expiry date so callers can track actual expiration
|
||||
if c.onTokenRefresh != nil {
|
||||
c.onTokenRefresh(tokenResp.AccessToken, tokenResp.RefreshToken, tokenResp.AccessTokenExpiryDate)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// doRequestRaw performs an HTTP request and returns the raw response body.
|
||||
// This is useful for endpoints that return non-JSON responses.
|
||||
func (c *Client) doRequestRaw(ctx context.Context, method, path string, body interface{}, requiresAuth bool) ([]byte, error) {
|
||||
return c.doRequestRawWithRetry(ctx, method, path, body, requiresAuth, true)
|
||||
}
|
||||
|
||||
// doRequestRawWithRetry performs an HTTP request with optional retry on 401.
|
||||
func (c *Client) doRequestRawWithRetry(ctx context.Context, method, path string, body interface{}, requiresAuth bool, allowRetry bool) ([]byte, error) {
|
||||
// Build URL
|
||||
url := c.baseURL + path
|
||||
|
||||
// Log API request
|
||||
if c.logger != nil {
|
||||
c.logger.Info("API request", "method", method, "url", url)
|
||||
}
|
||||
|
||||
// Prepare request body - we need to be able to re-read it for retry
|
||||
var bodyData []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
bodyData, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
var bodyReader io.Reader
|
||||
if bodyData != nil {
|
||||
bodyReader = bytes.NewReader(bodyData)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Add authorization header if required
|
||||
if requiresAuth {
|
||||
c.mu.RLock()
|
||||
token := c.accessToken
|
||||
c.mu.RUnlock()
|
||||
|
||||
if token == "" {
|
||||
return nil, &APIError{
|
||||
ProblemDetail: ProblemDetail{
|
||||
Status: 401,
|
||||
Title: "Unauthorized",
|
||||
Detail: "No access token available",
|
||||
},
|
||||
}
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("JWT %s", token))
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
if c.logger != nil {
|
||||
c.logger.Error("API request failed", "method", method, "url", url, "error", err.Error())
|
||||
}
|
||||
return nil, fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Log API response
|
||||
if c.logger != nil {
|
||||
c.logger.Info("API response", "method", method, "url", url, "status", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Handle 401 with automatic token refresh
|
||||
if resp.StatusCode == http.StatusUnauthorized && requiresAuth && allowRetry && !c.isRefreshing.Load() {
|
||||
c.mu.Lock()
|
||||
if c.refreshToken != "" && !c.isRefreshing.Load() {
|
||||
c.isRefreshing.Store(true)
|
||||
c.mu.Unlock()
|
||||
|
||||
// Attempt to refresh token
|
||||
refreshErr := c.refreshAccessToken(ctx)
|
||||
c.isRefreshing.Store(false)
|
||||
|
||||
if refreshErr == nil {
|
||||
// Retry the original request without allowing another retry
|
||||
return c.doRequestRawWithRetry(ctx, method, path, body, requiresAuth, false)
|
||||
}
|
||||
} else {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle error status codes
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, parseErrorResponse(respBody, resp.StatusCode)
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
165
cloud/maplefile-backend/pkg/maplefile/client/collections.go
Normal file
165
cloud/maplefile-backend/pkg/maplefile/client/collections.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// Package client provides a Go SDK for interacting with the MapleFile API.
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CreateCollection creates a new collection.
|
||||
func (c *Client) CreateCollection(ctx context.Context, input *CreateCollectionInput) (*Collection, error) {
|
||||
var resp Collection
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/collections", input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListCollections returns all collections for the current user.
|
||||
func (c *Client) ListCollections(ctx context.Context) ([]*Collection, error) {
|
||||
var resp struct {
|
||||
Collections []*Collection `json:"collections"`
|
||||
}
|
||||
if err := c.doRequest(ctx, "GET", "/api/v1/collections", nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Collections, nil
|
||||
}
|
||||
|
||||
// GetCollection returns a single collection by ID.
|
||||
func (c *Client) GetCollection(ctx context.Context, id string) (*Collection, error) {
|
||||
path := fmt.Sprintf("/api/v1/collections/%s", id)
|
||||
var resp Collection
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateCollection updates a collection.
|
||||
func (c *Client) UpdateCollection(ctx context.Context, id string, input *UpdateCollectionInput) (*Collection, error) {
|
||||
path := fmt.Sprintf("/api/v1/collections/%s", id)
|
||||
var resp Collection
|
||||
if err := c.doRequest(ctx, "PUT", path, input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteCollection soft-deletes a collection.
|
||||
func (c *Client) DeleteCollection(ctx context.Context, id string) error {
|
||||
path := fmt.Sprintf("/api/v1/collections/%s", id)
|
||||
return c.doRequest(ctx, "DELETE", path, nil, nil, true)
|
||||
}
|
||||
|
||||
// GetRootCollections returns all root-level collections (no parent).
|
||||
func (c *Client) GetRootCollections(ctx context.Context) ([]*Collection, error) {
|
||||
var resp struct {
|
||||
Collections []*Collection `json:"collections"`
|
||||
}
|
||||
if err := c.doRequest(ctx, "GET", "/api/v1/collections/root", nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Collections, nil
|
||||
}
|
||||
|
||||
// GetCollectionsByParent returns all collections with the specified parent.
|
||||
func (c *Client) GetCollectionsByParent(ctx context.Context, parentID string) ([]*Collection, error) {
|
||||
path := fmt.Sprintf("/api/v1/collections/parent/%s", parentID)
|
||||
var resp struct {
|
||||
Collections []*Collection `json:"collections"`
|
||||
}
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Collections, nil
|
||||
}
|
||||
|
||||
// MoveCollection moves a collection to a new parent.
|
||||
func (c *Client) MoveCollection(ctx context.Context, id string, input *MoveCollectionInput) (*Collection, error) {
|
||||
path := fmt.Sprintf("/api/v1/collections/%s/move", id)
|
||||
var resp Collection
|
||||
if err := c.doRequest(ctx, "PUT", path, input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ShareCollection shares a collection with another user.
|
||||
func (c *Client) ShareCollection(ctx context.Context, id string, input *ShareCollectionInput) error {
|
||||
path := fmt.Sprintf("/api/v1/collections/%s/share", id)
|
||||
return c.doRequest(ctx, "POST", path, input, nil, true)
|
||||
}
|
||||
|
||||
// RemoveCollectionMember removes a user from a shared collection.
|
||||
func (c *Client) RemoveCollectionMember(ctx context.Context, collectionID, userID string) error {
|
||||
path := fmt.Sprintf("/api/v1/collections/%s/members/%s", collectionID, userID)
|
||||
return c.doRequest(ctx, "DELETE", path, nil, nil, true)
|
||||
}
|
||||
|
||||
// ListSharedCollections returns all collections shared with the current user.
|
||||
func (c *Client) ListSharedCollections(ctx context.Context) ([]*Collection, error) {
|
||||
var resp struct {
|
||||
Collections []*Collection `json:"collections"`
|
||||
}
|
||||
if err := c.doRequest(ctx, "GET", "/api/v1/collections/shared", nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Collections, nil
|
||||
}
|
||||
|
||||
// ArchiveCollection archives a collection.
|
||||
func (c *Client) ArchiveCollection(ctx context.Context, id string) (*Collection, error) {
|
||||
path := fmt.Sprintf("/api/v1/collections/%s/archive", id)
|
||||
var resp Collection
|
||||
if err := c.doRequest(ctx, "PUT", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// RestoreCollection restores an archived collection.
|
||||
func (c *Client) RestoreCollection(ctx context.Context, id string) (*Collection, error) {
|
||||
path := fmt.Sprintf("/api/v1/collections/%s/restore", id)
|
||||
var resp Collection
|
||||
if err := c.doRequest(ctx, "PUT", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetFilteredCollections returns collections matching the specified filter.
|
||||
func (c *Client) GetFilteredCollections(ctx context.Context, filter *CollectionFilter) ([]*Collection, error) {
|
||||
path := "/api/v1/collections/filtered"
|
||||
if filter != nil {
|
||||
params := ""
|
||||
if filter.State != "" {
|
||||
params += fmt.Sprintf("state=%s", filter.State)
|
||||
}
|
||||
if filter.ParentID != "" {
|
||||
if params != "" {
|
||||
params += "&"
|
||||
}
|
||||
params += fmt.Sprintf("parent_id=%s", filter.ParentID)
|
||||
}
|
||||
if params != "" {
|
||||
path += "?" + params
|
||||
}
|
||||
}
|
||||
var resp struct {
|
||||
Collections []*Collection `json:"collections"`
|
||||
}
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Collections, nil
|
||||
}
|
||||
|
||||
// SyncCollections fetches collection changes since the given cursor.
|
||||
func (c *Client) SyncCollections(ctx context.Context, input *SyncInput) (*CollectionSyncResponse, error) {
|
||||
var resp CollectionSyncResponse
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/collections/sync", input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
157
cloud/maplefile-backend/pkg/maplefile/client/errors.go
Normal file
157
cloud/maplefile-backend/pkg/maplefile/client/errors.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
// Package client provides a Go SDK for interacting with the MapleFile API.
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProblemDetail represents an RFC 9457 problem detail response from the API.
|
||||
type ProblemDetail struct {
|
||||
Type string `json:"type"`
|
||||
Status int `json:"status"`
|
||||
Title string `json:"title"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Errors map[string]string `json:"errors,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
}
|
||||
|
||||
// APIError wraps ProblemDetail for the error interface.
|
||||
type APIError struct {
|
||||
ProblemDetail
|
||||
}
|
||||
|
||||
// Error returns a formatted error message from the ProblemDetail.
|
||||
func (e *APIError) Error() string {
|
||||
var errMsg strings.Builder
|
||||
|
||||
if e.Detail != "" {
|
||||
errMsg.WriteString(e.Detail)
|
||||
} else {
|
||||
errMsg.WriteString(e.Title)
|
||||
}
|
||||
|
||||
if len(e.Errors) > 0 {
|
||||
errMsg.WriteString("\n\nValidation errors:")
|
||||
for field, message := range e.Errors {
|
||||
errMsg.WriteString(fmt.Sprintf("\n - %s: %s", field, message))
|
||||
}
|
||||
}
|
||||
|
||||
return errMsg.String()
|
||||
}
|
||||
|
||||
// StatusCode returns the HTTP status code from the error.
|
||||
func (e *APIError) StatusCode() int {
|
||||
return e.Status
|
||||
}
|
||||
|
||||
// GetValidationErrors returns the validation errors map.
|
||||
func (e *APIError) GetValidationErrors() map[string]string {
|
||||
return e.Errors
|
||||
}
|
||||
|
||||
// GetFieldError returns the error message for a specific field, or empty string if not found.
|
||||
func (e *APIError) GetFieldError(field string) string {
|
||||
if e.Errors == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Errors[field]
|
||||
}
|
||||
|
||||
// HasFieldError checks if a specific field has a validation error.
|
||||
func (e *APIError) HasFieldError(field string) bool {
|
||||
if e.Errors == nil {
|
||||
return false
|
||||
}
|
||||
_, exists := e.Errors[field]
|
||||
return exists
|
||||
}
|
||||
|
||||
// IsNotFound checks if the error is a 404 Not Found error.
|
||||
func IsNotFound(err error) bool {
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return apiErr.Status == 404
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsUnauthorized checks if the error is a 401 Unauthorized error.
|
||||
func IsUnauthorized(err error) bool {
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return apiErr.Status == 401
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsForbidden checks if the error is a 403 Forbidden error.
|
||||
func IsForbidden(err error) bool {
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return apiErr.Status == 403
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValidationError checks if the error has validation errors.
|
||||
func IsValidationError(err error) bool {
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return len(apiErr.Errors) > 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsConflict checks if the error is a 409 Conflict error.
|
||||
func IsConflict(err error) bool {
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return apiErr.Status == 409
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsTooManyRequests checks if the error is a 429 Too Many Requests error.
|
||||
func IsTooManyRequests(err error) bool {
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return apiErr.Status == 429
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseErrorResponse attempts to parse an error response body into an APIError.
|
||||
// It tries RFC 9457 format first, then falls back to legacy format.
|
||||
//
|
||||
// Note: RFC 9457 specifies that error responses should use Content-Type: application/problem+json,
|
||||
// but we parse based on the response structure rather than Content-Type for maximum compatibility.
|
||||
func parseErrorResponse(body []byte, statusCode int) error {
|
||||
// Try to parse as RFC 9457 ProblemDetail
|
||||
// The presence of the "type" field distinguishes RFC 9457 from legacy responses
|
||||
var problem ProblemDetail
|
||||
if err := json.Unmarshal(body, &problem); err == nil && problem.Type != "" {
|
||||
return &APIError{ProblemDetail: problem}
|
||||
}
|
||||
|
||||
// Fallback for non-RFC 9457 errors
|
||||
var errorResponse map[string]interface{}
|
||||
if err := json.Unmarshal(body, &errorResponse); err == nil {
|
||||
if errMsg, ok := errorResponse["message"].(string); ok {
|
||||
return &APIError{
|
||||
ProblemDetail: ProblemDetail{
|
||||
Status: statusCode,
|
||||
Title: errMsg,
|
||||
Detail: errMsg,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: return raw body as error
|
||||
return &APIError{
|
||||
ProblemDetail: ProblemDetail{
|
||||
Status: statusCode,
|
||||
Title: fmt.Sprintf("HTTP %d", statusCode),
|
||||
Detail: string(body),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/client"
|
||||
)
|
||||
|
||||
// Example of handling RFC 9457 errors with validation details
|
||||
func ExampleAPIError_validation() {
|
||||
c := client.NewLocal()
|
||||
|
||||
// Attempt to register with invalid data
|
||||
_, err := c.Register(context.Background(), &client.RegisterInput{
|
||||
Email: "", // Missing required field
|
||||
FirstName: "", // Missing required field
|
||||
// ... other fields
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// Check if it's an API error
|
||||
if apiErr, ok := err.(*client.APIError); ok {
|
||||
fmt.Printf("Error Type: %s\n", apiErr.Type)
|
||||
fmt.Printf("Status: %d\n", apiErr.Status)
|
||||
fmt.Printf("Title: %s\n", apiErr.Title)
|
||||
|
||||
// Check for validation errors
|
||||
if client.IsValidationError(err) {
|
||||
fmt.Println("\nValidation Errors:")
|
||||
for field, message := range apiErr.GetValidationErrors() {
|
||||
fmt.Printf(" %s: %s\n", field, message)
|
||||
}
|
||||
|
||||
// Check for specific field error
|
||||
if apiErr.HasFieldError("email") {
|
||||
fmt.Printf("\nEmail error: %s\n", apiErr.GetFieldError("email"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example of checking specific error types
|
||||
func ExampleAPIError_statusChecks() {
|
||||
c := client.NewProduction()
|
||||
|
||||
user, err := c.GetMe(context.Background())
|
||||
if err != nil {
|
||||
// Use helper functions to check error types
|
||||
switch {
|
||||
case client.IsUnauthorized(err):
|
||||
fmt.Println("Authentication required - please login")
|
||||
// Redirect to login
|
||||
|
||||
case client.IsNotFound(err):
|
||||
fmt.Println("User not found")
|
||||
// Handle not found
|
||||
|
||||
case client.IsForbidden(err):
|
||||
fmt.Println("Access denied")
|
||||
// Show permission error
|
||||
|
||||
case client.IsTooManyRequests(err):
|
||||
fmt.Println("Rate limit exceeded - please try again later")
|
||||
// Implement backoff
|
||||
|
||||
case client.IsValidationError(err):
|
||||
fmt.Println("Validation failed - please check your input")
|
||||
// Show validation errors
|
||||
|
||||
default:
|
||||
fmt.Printf("Unexpected error: %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Welcome, %s!\n", user.Name)
|
||||
}
|
||||
|
||||
// Example of extracting error details for logging
|
||||
func ExampleAPIError_logging() {
|
||||
c := client.NewProduction()
|
||||
|
||||
_, err := c.CreateCollection(context.Background(), &client.CreateCollectionInput{
|
||||
Name: "Test Collection",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*client.APIError); ok {
|
||||
// Log structured error details
|
||||
fmt.Printf("API Error Details:\n")
|
||||
fmt.Printf(" Type: %s\n", apiErr.Type)
|
||||
fmt.Printf(" Status: %d\n", apiErr.StatusCode())
|
||||
fmt.Printf(" Title: %s\n", apiErr.Title)
|
||||
fmt.Printf(" Detail: %s\n", apiErr.Detail)
|
||||
fmt.Printf(" Instance: %s\n", apiErr.Instance)
|
||||
fmt.Printf(" TraceID: %s\n", apiErr.TraceID)
|
||||
fmt.Printf(" Timestamp: %s\n", apiErr.Timestamp)
|
||||
|
||||
if len(apiErr.Errors) > 0 {
|
||||
fmt.Println(" Field Errors:")
|
||||
for field, msg := range apiErr.Errors {
|
||||
fmt.Printf(" %s: %s\n", field, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example of handling errors in a form validation context
|
||||
func ExampleAPIError_formValidation() {
|
||||
c := client.NewLocal()
|
||||
|
||||
type FormData struct {
|
||||
Email string
|
||||
FirstName string
|
||||
LastName string
|
||||
Password string
|
||||
}
|
||||
|
||||
form := FormData{
|
||||
Email: "invalid-email",
|
||||
FirstName: "",
|
||||
LastName: "Doe",
|
||||
Password: "weak",
|
||||
}
|
||||
|
||||
_, err := c.Register(context.Background(), &client.RegisterInput{
|
||||
Email: form.Email,
|
||||
FirstName: form.FirstName,
|
||||
LastName: form.LastName,
|
||||
// ... other fields
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*client.APIError); ok {
|
||||
// Build form error messages
|
||||
formErrors := make(map[string]string)
|
||||
|
||||
if apiErr.HasFieldError("email") {
|
||||
formErrors["email"] = apiErr.GetFieldError("email")
|
||||
}
|
||||
if apiErr.HasFieldError("first_name") {
|
||||
formErrors["first_name"] = apiErr.GetFieldError("first_name")
|
||||
}
|
||||
if apiErr.HasFieldError("last_name") {
|
||||
formErrors["last_name"] = apiErr.GetFieldError("last_name")
|
||||
}
|
||||
|
||||
// Display errors to user
|
||||
for field, msg := range formErrors {
|
||||
fmt.Printf("Form field '%s': %s\n", field, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Example of handling conflict errors
|
||||
func ExampleAPIError_conflict() {
|
||||
c := client.NewProduction()
|
||||
|
||||
_, err := c.Register(context.Background(), &client.RegisterInput{
|
||||
Email: "existing@example.com",
|
||||
// ... other fields
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if client.IsConflict(err) {
|
||||
if apiErr, ok := err.(*client.APIError); ok {
|
||||
// The Detail field contains the conflict explanation
|
||||
fmt.Printf("Registration failed: %s\n", apiErr.Detail)
|
||||
// Output: "Registration failed: User with this email already exists"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
cloud/maplefile-backend/pkg/maplefile/client/files.go
Normal file
191
cloud/maplefile-backend/pkg/maplefile/client/files.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// Package client provides a Go SDK for interacting with the MapleFile API.
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CreatePendingFile creates a new file in pending state.
|
||||
func (c *Client) CreatePendingFile(ctx context.Context, input *CreateFileInput) (*PendingFile, error) {
|
||||
var resp PendingFile
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/files/pending", input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetFile returns a single file by ID.
|
||||
func (c *Client) GetFile(ctx context.Context, id string) (*File, error) {
|
||||
path := fmt.Sprintf("/api/v1/file/%s", id)
|
||||
var resp File
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateFile updates a file's metadata.
|
||||
func (c *Client) UpdateFile(ctx context.Context, id string, input *UpdateFileInput) (*File, error) {
|
||||
path := fmt.Sprintf("/api/v1/file/%s", id)
|
||||
var resp File
|
||||
if err := c.doRequest(ctx, "PUT", path, input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteFile soft-deletes a file.
|
||||
func (c *Client) DeleteFile(ctx context.Context, id string) error {
|
||||
path := fmt.Sprintf("/api/v1/file/%s", id)
|
||||
return c.doRequest(ctx, "DELETE", path, nil, nil, true)
|
||||
}
|
||||
|
||||
// DeleteMultipleFiles deletes multiple files at once.
|
||||
func (c *Client) DeleteMultipleFiles(ctx context.Context, fileIDs []string) error {
|
||||
input := DeleteMultipleFilesInput{FileIDs: fileIDs}
|
||||
return c.doRequest(ctx, "POST", "/api/v1/files/delete-multiple", input, nil, true)
|
||||
}
|
||||
|
||||
// GetPresignedUploadURL gets a presigned URL for uploading file content.
|
||||
func (c *Client) GetPresignedUploadURL(ctx context.Context, fileID string) (*PresignedURL, error) {
|
||||
path := fmt.Sprintf("/api/v1/file/%s/upload-url", fileID)
|
||||
var resp PresignedURL
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CompleteFileUpload marks the file upload as complete and transitions it to active state.
|
||||
func (c *Client) CompleteFileUpload(ctx context.Context, fileID string, input *CompleteUploadInput) (*File, error) {
|
||||
path := fmt.Sprintf("/api/v1/file/%s/complete", fileID)
|
||||
var resp File
|
||||
if err := c.doRequest(ctx, "POST", path, input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetPresignedDownloadURL gets a presigned URL for downloading file content.
|
||||
func (c *Client) GetPresignedDownloadURL(ctx context.Context, fileID string) (*PresignedDownloadResponse, error) {
|
||||
path := fmt.Sprintf("/api/v1/file/%s/download-url", fileID)
|
||||
var resp PresignedDownloadResponse
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ReportDownloadCompleted reports that a file download has completed.
|
||||
func (c *Client) ReportDownloadCompleted(ctx context.Context, fileID string) error {
|
||||
path := fmt.Sprintf("/api/v1/file/%s/download-completed", fileID)
|
||||
return c.doRequest(ctx, "POST", path, nil, nil, true)
|
||||
}
|
||||
|
||||
// ArchiveFile archives a file.
|
||||
func (c *Client) ArchiveFile(ctx context.Context, id string) (*File, error) {
|
||||
path := fmt.Sprintf("/api/v1/file/%s/archive", id)
|
||||
var resp File
|
||||
if err := c.doRequest(ctx, "PUT", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// RestoreFile restores an archived file.
|
||||
func (c *Client) RestoreFile(ctx context.Context, id string) (*File, error) {
|
||||
path := fmt.Sprintf("/api/v1/file/%s/restore", id)
|
||||
var resp File
|
||||
if err := c.doRequest(ctx, "PUT", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListFilesByCollection returns all files in a collection.
|
||||
func (c *Client) ListFilesByCollection(ctx context.Context, collectionID string) ([]*File, error) {
|
||||
path := fmt.Sprintf("/api/v1/collection/%s/files", collectionID)
|
||||
var resp struct {
|
||||
Files []*File `json:"files"`
|
||||
}
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Files, nil
|
||||
}
|
||||
|
||||
// ListRecentFiles returns the user's recent files.
|
||||
func (c *Client) ListRecentFiles(ctx context.Context) ([]*File, error) {
|
||||
var resp struct {
|
||||
Files []*File `json:"files"`
|
||||
}
|
||||
if err := c.doRequest(ctx, "GET", "/api/v1/files/recent", nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Files, nil
|
||||
}
|
||||
|
||||
// SyncFiles fetches file changes since the given cursor.
|
||||
func (c *Client) SyncFiles(ctx context.Context, input *SyncInput) (*FileSyncResponse, error) {
|
||||
var resp FileSyncResponse
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/files/sync", input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UploadToPresignedURL uploads data to an S3 presigned URL.
|
||||
// This is a helper method for uploading encrypted file content directly to S3.
|
||||
func (c *Client) UploadToPresignedURL(ctx context.Context, presignedURL string, data []byte, contentType string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", presignedURL, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create upload request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.ContentLength = int64(len(data))
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upload to presigned URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadFromPresignedURL downloads data from an S3 presigned URL.
|
||||
// This is a helper method for downloading encrypted file content directly from S3.
|
||||
func (c *Client) DownloadFromPresignedURL(ctx context.Context, presignedURL string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", presignedURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download from presigned URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read download response: %w", err)
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
123
cloud/maplefile-backend/pkg/maplefile/client/tags.go
Normal file
123
cloud/maplefile-backend/pkg/maplefile/client/tags.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// Package client provides a Go SDK for interacting with the MapleFile API.
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CreateTag creates a new tag.
|
||||
func (c *Client) CreateTag(ctx context.Context, input *CreateTagInput) (*Tag, error) {
|
||||
var resp Tag
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/tags", input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListTags returns all tags for the current user.
|
||||
func (c *Client) ListTags(ctx context.Context) ([]*Tag, error) {
|
||||
var resp ListTagsResponse
|
||||
if err := c.doRequest(ctx, "GET", "/api/v1/tags", nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Tags, nil
|
||||
}
|
||||
|
||||
// GetTag returns a single tag by ID.
|
||||
func (c *Client) GetTag(ctx context.Context, id string) (*Tag, error) {
|
||||
path := fmt.Sprintf("/api/v1/tags/%s", id)
|
||||
var resp Tag
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateTag updates a tag.
|
||||
func (c *Client) UpdateTag(ctx context.Context, id string, input *UpdateTagInput) (*Tag, error) {
|
||||
path := fmt.Sprintf("/api/v1/tags/%s", id)
|
||||
var resp Tag
|
||||
if err := c.doRequest(ctx, "PUT", path, input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteTag deletes a tag.
|
||||
func (c *Client) DeleteTag(ctx context.Context, id string) error {
|
||||
path := fmt.Sprintf("/api/v1/tags/%s", id)
|
||||
return c.doRequest(ctx, "DELETE", path, nil, nil, true)
|
||||
}
|
||||
|
||||
// AssignTag assigns a tag to a collection or file.
|
||||
func (c *Client) AssignTag(ctx context.Context, input *CreateTagAssignmentInput) (*TagAssignment, error) {
|
||||
path := fmt.Sprintf("/api/v1/tags/%s/assign", input.TagID)
|
||||
|
||||
// Create request body without TagID (since it's in the URL)
|
||||
requestBody := map[string]string{
|
||||
"entity_id": input.EntityID,
|
||||
"entity_type": input.EntityType,
|
||||
}
|
||||
|
||||
var resp TagAssignment
|
||||
if err := c.doRequest(ctx, "POST", path, requestBody, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UnassignTag removes a tag from a collection or file.
|
||||
func (c *Client) UnassignTag(ctx context.Context, tagID, entityID, entityType string) error {
|
||||
path := fmt.Sprintf("/api/v1/tags/%s/entities/%s?entity_type=%s", tagID, entityID, entityType)
|
||||
return c.doRequest(ctx, "DELETE", path, nil, nil, true)
|
||||
}
|
||||
|
||||
// GetTagsForEntity returns all tags assigned to a specific entity (collection or file).
|
||||
func (c *Client) GetTagsForEntity(ctx context.Context, entityID, entityType string) ([]*Tag, error) {
|
||||
path := fmt.Sprintf("/api/v1/tags/%s/%s", entityType, entityID)
|
||||
var resp ListTagsResponse
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Tags, nil
|
||||
}
|
||||
|
||||
// GetTagAssignments returns all assignments for a specific tag.
|
||||
func (c *Client) GetTagAssignments(ctx context.Context, tagID string) ([]*TagAssignment, error) {
|
||||
path := fmt.Sprintf("/api/v1/tags/%s/assignments", tagID)
|
||||
var resp ListTagAssignmentsResponse
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.TagAssignments, nil
|
||||
}
|
||||
|
||||
// SearchByTags searches for collections and files matching ALL the specified tags.
|
||||
// tagIDs: slice of tag UUIDs to search for
|
||||
// limit: maximum number of results (default 50, max 100 on backend)
|
||||
func (c *Client) SearchByTags(ctx context.Context, tagIDs []string, limit int) (*SearchByTagsResponse, error) {
|
||||
if len(tagIDs) == 0 {
|
||||
return nil, fmt.Errorf("at least one tag ID is required")
|
||||
}
|
||||
|
||||
// Build query string with comma-separated tag IDs
|
||||
tags := ""
|
||||
for i, id := range tagIDs {
|
||||
if i > 0 {
|
||||
tags += ","
|
||||
}
|
||||
tags += id
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/v1/tags/search?tags=%s", tags)
|
||||
if limit > 0 {
|
||||
path += fmt.Sprintf("&limit=%d", limit)
|
||||
}
|
||||
|
||||
var resp SearchByTagsResponse
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
598
cloud/maplefile-backend/pkg/maplefile/client/types.go
Normal file
598
cloud/maplefile-backend/pkg/maplefile/client/types.go
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
// Package client provides a Go SDK for interacting with the MapleFile API.
|
||||
package client
|
||||
|
||||
import "time"
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Health & Version Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// HealthResponse represents the health check response.
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// VersionResponse represents the API version response.
|
||||
type VersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Authentication Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// RegisterInput represents the registration request.
|
||||
type RegisterInput struct {
|
||||
BetaAccessCode string `json:"beta_access_code"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Phone string `json:"phone"`
|
||||
Country string `json:"country"`
|
||||
Timezone string `json:"timezone"`
|
||||
PasswordSalt string `json:"salt"`
|
||||
KDFAlgorithm string `json:"kdf_algorithm"`
|
||||
KDFIterations int `json:"kdf_iterations"`
|
||||
KDFMemory int `json:"kdf_memory"`
|
||||
KDFParallelism int `json:"kdf_parallelism"`
|
||||
KDFSaltLength int `json:"kdf_salt_length"`
|
||||
KDFKeyLength int `json:"kdf_key_length"`
|
||||
EncryptedMasterKey string `json:"encryptedMasterKey"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
EncryptedPrivateKey string `json:"encryptedPrivateKey"`
|
||||
EncryptedRecoveryKey string `json:"encryptedRecoveryKey"`
|
||||
MasterKeyEncryptedWithRecoveryKey string `json:"masterKeyEncryptedWithRecoveryKey"`
|
||||
AgreeTermsOfService bool `json:"agree_terms_of_service"`
|
||||
AgreePromotions bool `json:"agree_promotions"`
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `json:"agree_to_tracking_across_third_party_apps_and_services"`
|
||||
}
|
||||
|
||||
// RegisterResponse represents the registration response.
|
||||
type RegisterResponse struct {
|
||||
Message string `json:"message"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// VerifyEmailInput represents the email verification request.
|
||||
type VerifyEmailInput struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// VerifyEmailResponse represents the email verification response.
|
||||
type VerifyEmailResponse struct {
|
||||
Message string `json:"message"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// ResendVerificationInput represents the resend verification request.
|
||||
type ResendVerificationInput struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// OTTResponse represents the OTT request response.
|
||||
type OTTResponse struct {
|
||||
Message string `json:"message"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// VerifyOTTResponse represents the OTT verification response.
|
||||
type VerifyOTTResponse struct {
|
||||
Message string `json:"message"`
|
||||
ChallengeID string `json:"challengeId"`
|
||||
EncryptedChallenge string `json:"encryptedChallenge"`
|
||||
Salt string `json:"salt"`
|
||||
EncryptedMasterKey string `json:"encryptedMasterKey"`
|
||||
EncryptedPrivateKey string `json:"encryptedPrivateKey"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
// KDFAlgorithm specifies which key derivation algorithm to use.
|
||||
// Values: "PBKDF2-SHA256" (web frontend) or "argon2id" (native app legacy)
|
||||
KDFAlgorithm string `json:"kdfAlgorithm"`
|
||||
}
|
||||
|
||||
// CompleteLoginInput represents the complete login request.
|
||||
type CompleteLoginInput struct {
|
||||
Email string `json:"email"`
|
||||
ChallengeID string `json:"challengeId"`
|
||||
DecryptedData string `json:"decryptedData"`
|
||||
}
|
||||
|
||||
// LoginResponse represents the login response (from complete-login or token refresh).
|
||||
type LoginResponse struct {
|
||||
Message string `json:"message"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
AccessTokenExpiryDate string `json:"access_token_expiry_date"`
|
||||
RefreshTokenExpiryDate string `json:"refresh_token_expiry_date"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// RefreshTokenInput represents the token refresh request.
|
||||
type RefreshTokenInput struct {
|
||||
RefreshToken string `json:"value"`
|
||||
}
|
||||
|
||||
// RecoveryInitiateInput represents the recovery initiation request.
|
||||
type RecoveryInitiateInput struct {
|
||||
Email string `json:"email"`
|
||||
Method string `json:"method"` // "recovery_key"
|
||||
}
|
||||
|
||||
// RecoveryInitiateResponse represents the recovery initiation response.
|
||||
type RecoveryInitiateResponse struct {
|
||||
Message string `json:"message"`
|
||||
SessionID string `json:"session_id"`
|
||||
EncryptedChallenge string `json:"encrypted_challenge"`
|
||||
}
|
||||
|
||||
// RecoveryVerifyInput represents the recovery verification request.
|
||||
type RecoveryVerifyInput struct {
|
||||
SessionID string `json:"session_id"`
|
||||
DecryptedChallenge string `json:"decrypted_challenge"`
|
||||
}
|
||||
|
||||
// RecoveryVerifyResponse represents the recovery verification response.
|
||||
type RecoveryVerifyResponse struct {
|
||||
Message string `json:"message"`
|
||||
RecoveryToken string `json:"recovery_token"`
|
||||
CanResetCredentials bool `json:"can_reset_credentials"`
|
||||
}
|
||||
|
||||
// RecoveryCompleteInput represents the recovery completion request.
|
||||
type RecoveryCompleteInput struct {
|
||||
RecoveryToken string `json:"recovery_token"`
|
||||
NewSalt string `json:"new_salt"`
|
||||
NewPublicKey string `json:"new_public_key"`
|
||||
NewEncryptedMasterKey string `json:"new_encrypted_master_key"`
|
||||
NewEncryptedPrivateKey string `json:"new_encrypted_private_key"`
|
||||
NewEncryptedRecoveryKey string `json:"new_encrypted_recovery_key"`
|
||||
NewMasterKeyEncryptedWithRecoveryKey string `json:"new_master_key_encrypted_with_recovery_key"`
|
||||
}
|
||||
|
||||
// RecoveryCompleteResponse represents the recovery completion response.
|
||||
type RecoveryCompleteResponse struct {
|
||||
Message string `json:"message"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// User/Profile Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// User represents a user profile.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Name string `json:"name"`
|
||||
LexicalName string `json:"lexical_name"`
|
||||
Role int8 `json:"role"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Timezone string `json:"timezone"`
|
||||
Region string `json:"region,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
PostalCode string `json:"postal_code,omitempty"`
|
||||
AddressLine1 string `json:"address_line1,omitempty"`
|
||||
AddressLine2 string `json:"address_line2,omitempty"`
|
||||
AgreePromotions bool `json:"agree_promotions,omitempty"`
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `json:"agree_to_tracking_across_third_party_apps_and_services,omitempty"`
|
||||
ShareNotificationsEnabled *bool `json:"share_notifications_enabled,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
Status int8 `json:"status"`
|
||||
ProfileVerificationStatus int8 `json:"profile_verification_status,omitempty"`
|
||||
WebsiteURL string `json:"website_url"`
|
||||
Description string `json:"description"`
|
||||
ComicBookStoreName string `json:"comic_book_store_name,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateUserInput represents the user update request.
|
||||
type UpdateUserInput struct {
|
||||
Email string `json:"email"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Timezone string `json:"timezone"`
|
||||
AgreePromotions bool `json:"agree_promotions,omitempty"`
|
||||
AgreeToTrackingAcrossThirdPartyAppsAndServices bool `json:"agree_to_tracking_across_third_party_apps_and_services,omitempty"`
|
||||
ShareNotificationsEnabled *bool `json:"share_notifications_enabled,omitempty"`
|
||||
}
|
||||
|
||||
// DeleteUserInput represents the user deletion request.
|
||||
type DeleteUserInput struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// PublicUser represents public user information returned from lookup.
|
||||
type PublicUser struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
PublicKeyInBase64 string `json:"public_key_in_base64"`
|
||||
VerificationID string `json:"verification_id"`
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Blocked Email Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// CreateBlockedEmailInput represents the blocked email creation request.
|
||||
type CreateBlockedEmailInput struct {
|
||||
Email string `json:"email"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// BlockedEmail represents a blocked email entry.
|
||||
type BlockedEmail struct {
|
||||
UserID string `json:"user_id"`
|
||||
BlockedEmail string `json:"blocked_email"`
|
||||
BlockedUserID string `json:"blocked_user_id,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListBlockedEmailsResponse represents the list of blocked emails response.
|
||||
type ListBlockedEmailsResponse struct {
|
||||
BlockedEmails []*BlockedEmail `json:"blocked_emails"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// DeleteBlockedEmailResponse represents the blocked email deletion response.
|
||||
type DeleteBlockedEmailResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Dashboard Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Dashboard represents dashboard data.
|
||||
type Dashboard struct {
|
||||
Summary DashboardSummary `json:"summary"`
|
||||
StorageUsageTrend StorageUsageTrend `json:"storage_usage_trend"`
|
||||
RecentFiles []RecentFileDashboard `json:"recent_files"`
|
||||
CollectionKeys []DashboardCollectionKey `json:"collection_keys,omitempty"`
|
||||
}
|
||||
|
||||
// DashboardCollectionKey contains the encrypted collection key for client-side decryption
|
||||
// This allows clients to decrypt file metadata without making additional API calls
|
||||
type DashboardCollectionKey struct {
|
||||
CollectionID string `json:"collection_id"`
|
||||
EncryptedCollectionKey string `json:"encrypted_collection_key"`
|
||||
EncryptedCollectionKeyNonce string `json:"encrypted_collection_key_nonce"`
|
||||
}
|
||||
|
||||
// DashboardResponse represents the dashboard response.
|
||||
type DashboardResponse struct {
|
||||
Dashboard *Dashboard `json:"dashboard"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// DashboardSummary represents dashboard summary data.
|
||||
type DashboardSummary struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
TotalFolders int `json:"total_folders"`
|
||||
StorageUsed StorageAmount `json:"storage_used"`
|
||||
StorageLimit StorageAmount `json:"storage_limit"`
|
||||
StorageUsagePercentage int `json:"storage_usage_percentage"`
|
||||
}
|
||||
|
||||
// StorageAmount represents a storage amount with value and unit.
|
||||
type StorageAmount struct {
|
||||
Value float64 `json:"value"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
// StorageUsageTrend represents storage usage trend data.
|
||||
type StorageUsageTrend struct {
|
||||
Period string `json:"period"`
|
||||
DataPoints []DataPoint `json:"data_points"`
|
||||
}
|
||||
|
||||
// DataPoint represents a single data point in the usage trend.
|
||||
type DataPoint struct {
|
||||
Date string `json:"date"`
|
||||
Usage StorageAmount `json:"usage"`
|
||||
}
|
||||
|
||||
// RecentFileDashboard represents a recent file in the dashboard.
|
||||
// Note: File metadata is E2EE encrypted. Clients should use locally cached
|
||||
// decrypted data when available, or show placeholder text for cloud-only files.
|
||||
type RecentFileDashboard struct {
|
||||
ID string `json:"id"`
|
||||
CollectionID string `json:"collection_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
EncryptedMetadata string `json:"encrypted_metadata"`
|
||||
EncryptedFileKey EncryptedFileKeyData `json:"encrypted_file_key"`
|
||||
EncryptionVersion string `json:"encryption_version"`
|
||||
EncryptedHash string `json:"encrypted_hash"`
|
||||
EncryptedFileSizeInBytes int64 `json:"encrypted_file_size_in_bytes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
Version uint64 `json:"version"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Collection Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// EncryptedKeyData represents an encrypted key with its nonce (used for collections and tags)
|
||||
type EncryptedKeyData struct {
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
// Collection represents a file collection (folder).
|
||||
type Collection struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
EncryptedCollectionKey EncryptedKeyData `json:"encrypted_collection_key"`
|
||||
// CustomIcon is the decrypted custom icon for this collection.
|
||||
// Empty string means use default folder/album icon.
|
||||
// Contains either an emoji character (e.g., "📷") or "icon:<identifier>" for predefined icons.
|
||||
CustomIcon string `json:"custom_icon,omitempty"`
|
||||
// EncryptedCustomIcon is the encrypted version of CustomIcon (for sync operations).
|
||||
EncryptedCustomIcon string `json:"encrypted_custom_icon,omitempty"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
TotalSizeInBytes int64 `json:"total_size_in_bytes"`
|
||||
State string `json:"state"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
SharedWith []Share `json:"shared_with,omitempty"`
|
||||
PermissionLevel string `json:"permission_level,omitempty"`
|
||||
IsOwner bool `json:"is_owner"`
|
||||
OwnerName string `json:"owner_name,omitempty"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
Tags []EmbeddedTag `json:"tags,omitempty"` // Tags assigned to this collection
|
||||
}
|
||||
|
||||
// Share represents a collection sharing entry.
|
||||
type Share struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
PermissionLevel string `json:"permission_level"`
|
||||
SharedAt time.Time `json:"shared_at"`
|
||||
}
|
||||
|
||||
// CreateCollectionInput represents the collection creation request.
|
||||
type CreateCollectionInput struct {
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
EncryptedCollectionKey string `json:"encrypted_collection_key"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
// UpdateCollectionInput represents the collection update request.
|
||||
type UpdateCollectionInput struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// MoveCollectionInput represents the collection move request.
|
||||
type MoveCollectionInput struct {
|
||||
NewParentID string `json:"new_parent_id"`
|
||||
}
|
||||
|
||||
// ShareCollectionInput represents the collection sharing request.
|
||||
type ShareCollectionInput struct {
|
||||
Email string `json:"email"`
|
||||
PermissionLevel string `json:"permission_level"` // "read_only", "read_write", "admin"
|
||||
EncryptedCollectionKey string `json:"encrypted_collection_key"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
// CollectionFilter represents filters for listing collections.
|
||||
type CollectionFilter struct {
|
||||
State string `json:"state,omitempty"` // "active", "archived", "trashed"
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
}
|
||||
|
||||
// SyncInput represents the sync request.
|
||||
type SyncInput struct {
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
Limit int64 `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// CollectionSyncResponse represents the collection sync response.
|
||||
type CollectionSyncResponse struct {
|
||||
Collections []*Collection `json:"collections"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// File Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// EncryptedFileKeyData represents the encrypted file key structure returned by the API.
|
||||
type EncryptedFileKeyData struct {
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
Nonce string `json:"nonce"`
|
||||
}
|
||||
|
||||
// File represents a file in a collection.
|
||||
type File struct {
|
||||
ID string `json:"id"`
|
||||
CollectionID string `json:"collection_id"`
|
||||
UserID string `json:"user_id"`
|
||||
EncryptedFileKey EncryptedFileKeyData `json:"encrypted_file_key"`
|
||||
FileKeyNonce string `json:"file_key_nonce"`
|
||||
EncryptedMetadata string `json:"encrypted_metadata"`
|
||||
MetadataNonce string `json:"metadata_nonce"`
|
||||
FileNonce string `json:"file_nonce"`
|
||||
EncryptedSizeInBytes int64 `json:"encrypted_file_size_in_bytes"`
|
||||
DecryptedSizeInBytes int64 `json:"decrypted_size_in_bytes,omitempty"`
|
||||
State string `json:"state"`
|
||||
StorageMode string `json:"storage_mode"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
ThumbnailURL string `json:"thumbnail_url,omitempty"`
|
||||
Tags []*EmbeddedTag `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// PendingFile represents a file in pending state (awaiting upload).
|
||||
type PendingFile struct {
|
||||
ID string `json:"id"`
|
||||
CollectionID string `json:"collection_id"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// CreateFileInput represents the file creation request.
|
||||
type CreateFileInput struct {
|
||||
CollectionID string `json:"collection_id"`
|
||||
EncryptedFileKey string `json:"encrypted_file_key"`
|
||||
FileKeyNonce string `json:"file_key_nonce"`
|
||||
EncryptedMetadata string `json:"encrypted_metadata"`
|
||||
MetadataNonce string `json:"metadata_nonce"`
|
||||
FileNonce string `json:"file_nonce"`
|
||||
EncryptedSizeInBytes int64 `json:"encrypted_size_in_bytes"`
|
||||
}
|
||||
|
||||
// UpdateFileInput represents the file update request.
|
||||
type UpdateFileInput struct {
|
||||
EncryptedMetadata string `json:"encrypted_metadata,omitempty"`
|
||||
MetadataNonce string `json:"metadata_nonce,omitempty"`
|
||||
}
|
||||
|
||||
// CompleteUploadInput represents the file upload completion request.
|
||||
type CompleteUploadInput struct {
|
||||
ActualFileSizeInBytes int64 `json:"actual_file_size_in_bytes"`
|
||||
UploadConfirmed bool `json:"upload_confirmed"`
|
||||
}
|
||||
|
||||
// PresignedURL represents a presigned upload URL response.
|
||||
type PresignedURL struct {
|
||||
URL string `json:"url"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
// PresignedDownloadResponse represents a presigned download URL response.
|
||||
type PresignedDownloadResponse struct {
|
||||
FileURL string `json:"file_url"`
|
||||
ThumbnailURL string `json:"thumbnail_url,omitempty"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
// DeleteMultipleFilesInput represents the multiple files deletion request.
|
||||
type DeleteMultipleFilesInput struct {
|
||||
FileIDs []string `json:"file_ids"`
|
||||
}
|
||||
|
||||
// FileSyncResponse represents the file sync response.
|
||||
type FileSyncResponse struct {
|
||||
Files []*File `json:"files"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// ListFilesResponse represents the list files response.
|
||||
type ListFilesResponse struct {
|
||||
Files []*File `json:"files"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Tag Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Tag represents a user-defined label with color that can be assigned to collections or files.
|
||||
// All sensitive data (name, color) is encrypted end-to-end.
|
||||
type Tag struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
EncryptedName string `json:"encrypted_name"`
|
||||
EncryptedColor string `json:"encrypted_color"`
|
||||
EncryptedTagKey *EncryptedTagKey `json:"encrypted_tag_key"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
Version uint64 `json:"version"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// EncryptedTagKey represents the encrypted tag key data
|
||||
type EncryptedTagKey struct {
|
||||
Ciphertext string `json:"ciphertext"` // Base64 encoded
|
||||
Nonce string `json:"nonce"` // Base64 encoded
|
||||
KeyVersion int `json:"key_version,omitempty"`
|
||||
}
|
||||
|
||||
// EmbeddedTag represents tag data that is embedded in collections and files
|
||||
// This eliminates the need for frontend API lookups to get tag colors
|
||||
type EmbeddedTag struct {
|
||||
ID string `json:"id"`
|
||||
EncryptedName string `json:"encrypted_name"`
|
||||
EncryptedColor string `json:"encrypted_color"`
|
||||
EncryptedTagKey *EncryptedTagKey `json:"encrypted_tag_key"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
}
|
||||
|
||||
// CreateTagInput represents the tag creation request
|
||||
type CreateTagInput struct {
|
||||
ID string `json:"id"`
|
||||
EncryptedName string `json:"encrypted_name"`
|
||||
EncryptedColor string `json:"encrypted_color"`
|
||||
EncryptedTagKey *EncryptedTagKey `json:"encrypted_tag_key"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ModifiedAt string `json:"modified_at"`
|
||||
Version uint64 `json:"version"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// UpdateTagInput represents the tag update request
|
||||
type UpdateTagInput struct {
|
||||
EncryptedName string `json:"encrypted_name,omitempty"`
|
||||
EncryptedColor string `json:"encrypted_color,omitempty"`
|
||||
EncryptedTagKey *EncryptedTagKey `json:"encrypted_tag_key"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ModifiedAt string `json:"modified_at"`
|
||||
Version uint64 `json:"version"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// ListTagsResponse represents the list tags response
|
||||
type ListTagsResponse struct {
|
||||
Tags []*Tag `json:"tags"`
|
||||
}
|
||||
|
||||
// TagAssignment represents the assignment of a tag to a collection or file
|
||||
type TagAssignment struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
TagID string `json:"tag_id"`
|
||||
EntityID string `json:"entity_id"`
|
||||
EntityType string `json:"entity_type"` // "collection" or "file"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CreateTagAssignmentInput represents the tag assignment request
|
||||
type CreateTagAssignmentInput struct {
|
||||
TagID string `json:"tag_id"`
|
||||
EntityID string `json:"entity_id"`
|
||||
EntityType string `json:"entity_type"`
|
||||
}
|
||||
|
||||
// ListTagAssignmentsResponse represents the list tag assignments response
|
||||
type ListTagAssignmentsResponse struct {
|
||||
TagAssignments []*TagAssignment `json:"tag_assignments"`
|
||||
}
|
||||
|
||||
// SearchByTagsResponse represents the unified search by tags response
|
||||
type SearchByTagsResponse struct {
|
||||
Collections []*Collection `json:"collections"`
|
||||
Files []*File `json:"files"`
|
||||
TagCount int `json:"tag_count"`
|
||||
CollectionCount int `json:"collection_count"`
|
||||
FileCount int `json:"file_count"`
|
||||
}
|
||||
84
cloud/maplefile-backend/pkg/maplefile/client/user.go
Normal file
84
cloud/maplefile-backend/pkg/maplefile/client/user.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Package client provides a Go SDK for interacting with the MapleFile API.
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// GetMe returns the current authenticated user's profile.
|
||||
func (c *Client) GetMe(ctx context.Context) (*User, error) {
|
||||
var resp User
|
||||
if err := c.doRequest(ctx, "GET", "/api/v1/me", nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateMe updates the current user's profile.
|
||||
func (c *Client) UpdateMe(ctx context.Context, input *UpdateUserInput) (*User, error) {
|
||||
var resp User
|
||||
if err := c.doRequest(ctx, "PUT", "/api/v1/me", input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteMe deletes the current user's account.
|
||||
func (c *Client) DeleteMe(ctx context.Context, password string) error {
|
||||
input := DeleteUserInput{Password: password}
|
||||
return c.doRequest(ctx, "DELETE", "/api/v1/me", input, nil, true)
|
||||
}
|
||||
|
||||
// PublicUserLookup looks up a user by email (returns public info only).
|
||||
// This endpoint does not require authentication.
|
||||
func (c *Client) PublicUserLookup(ctx context.Context, email string) (*PublicUser, error) {
|
||||
path := fmt.Sprintf("/iam/api/v1/users/lookup?email=%s", url.QueryEscape(email))
|
||||
var resp PublicUser
|
||||
if err := c.doRequest(ctx, "GET", path, nil, &resp, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// CreateBlockedEmail adds an email to the blocked list.
|
||||
func (c *Client) CreateBlockedEmail(ctx context.Context, email, reason string) (*BlockedEmail, error) {
|
||||
input := CreateBlockedEmailInput{
|
||||
Email: email,
|
||||
Reason: reason,
|
||||
}
|
||||
var resp BlockedEmail
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/me/blocked-emails", input, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListBlockedEmails returns all blocked emails for the current user.
|
||||
func (c *Client) ListBlockedEmails(ctx context.Context) (*ListBlockedEmailsResponse, error) {
|
||||
var resp ListBlockedEmailsResponse
|
||||
if err := c.doRequest(ctx, "GET", "/api/v1/me/blocked-emails", nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// DeleteBlockedEmail removes an email from the blocked list.
|
||||
func (c *Client) DeleteBlockedEmail(ctx context.Context, email string) (*DeleteBlockedEmailResponse, error) {
|
||||
path := fmt.Sprintf("/api/v1/me/blocked-emails/%s", url.PathEscape(email))
|
||||
var resp DeleteBlockedEmailResponse
|
||||
if err := c.doRequest(ctx, "DELETE", path, nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// GetDashboard returns the user's dashboard data.
|
||||
func (c *Client) GetDashboard(ctx context.Context) (*DashboardResponse, error) {
|
||||
var resp DashboardResponse
|
||||
if err := c.doRequest(ctx, "GET", "/api/v1/dashboard", nil, &resp, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
462
cloud/maplefile-backend/pkg/maplefile/e2ee/crypto.go
Normal file
462
cloud/maplefile-backend/pkg/maplefile/e2ee/crypto.go
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
// Package e2ee provides end-to-end encryption operations for the MapleFile SDK.
|
||||
package e2ee
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
"golang.org/x/crypto/nacl/secretbox"
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
// KDF Algorithm identifiers
|
||||
const (
|
||||
Argon2IDAlgorithm = "argon2id"
|
||||
PBKDF2Algorithm = "PBKDF2-SHA256"
|
||||
)
|
||||
|
||||
// Argon2id key derivation parameters
|
||||
const (
|
||||
Argon2MemLimit = 4 * 1024 * 1024 // 4 MB
|
||||
Argon2OpsLimit = 1 // 1 iteration (time cost)
|
||||
Argon2Parallelism = 1 // 1 thread
|
||||
Argon2KeySize = 32 // 256-bit output
|
||||
Argon2SaltSize = 16 // 128-bit salt
|
||||
)
|
||||
|
||||
// PBKDF2 key derivation parameters (matching web frontend)
|
||||
const (
|
||||
PBKDF2Iterations = 100000 // 100,000 iterations (matching web frontend)
|
||||
PBKDF2KeySize = 32 // 256-bit output
|
||||
PBKDF2SaltSize = 16 // 128-bit salt
|
||||
)
|
||||
|
||||
// ChaCha20-Poly1305 constants (IETF variant - 12 byte nonce)
|
||||
const (
|
||||
ChaCha20Poly1305KeySize = 32 // ChaCha20 key size
|
||||
ChaCha20Poly1305NonceSize = 12 // ChaCha20-Poly1305 nonce size
|
||||
ChaCha20Poly1305Overhead = 16 // Poly1305 authentication tag size
|
||||
)
|
||||
|
||||
// XSalsa20-Poly1305 (NaCl secretbox) constants - 24 byte nonce
|
||||
// Used by web frontend (libsodium crypto_secretbox_easy)
|
||||
const (
|
||||
SecretBoxKeySize = 32 // Same as ChaCha20
|
||||
SecretBoxNonceSize = 24 // XSalsa20 uses 24-byte nonce
|
||||
SecretBoxOverhead = secretbox.Overhead // 16 bytes (Poly1305 tag)
|
||||
)
|
||||
|
||||
// Key sizes
|
||||
const (
|
||||
MasterKeySize = 32
|
||||
CollectionKeySize = 32
|
||||
FileKeySize = 32
|
||||
RecoveryKeySize = 32
|
||||
)
|
||||
|
||||
// NaCl Box constants
|
||||
const (
|
||||
BoxPublicKeySize = 32
|
||||
BoxSecretKeySize = 32
|
||||
BoxNonceSize = 24
|
||||
)
|
||||
|
||||
// EncryptedData represents encrypted data with its nonce.
|
||||
type EncryptedData struct {
|
||||
Ciphertext []byte
|
||||
Nonce []byte
|
||||
}
|
||||
|
||||
// DeriveKeyFromPassword derives a key encryption key (KEK) from a password using Argon2id.
|
||||
// This is the legacy function - prefer DeriveKeyFromPasswordWithAlgorithm for new code.
|
||||
func DeriveKeyFromPassword(password string, salt []byte) ([]byte, error) {
|
||||
return DeriveKeyFromPasswordArgon2id(password, salt)
|
||||
}
|
||||
|
||||
// DeriveKeyFromPasswordArgon2id derives a KEK using Argon2id algorithm.
|
||||
// SECURITY: Password bytes are wiped from memory after key derivation.
|
||||
func DeriveKeyFromPasswordArgon2id(password string, salt []byte) ([]byte, error) {
|
||||
if len(salt) != Argon2SaltSize {
|
||||
return nil, fmt.Errorf("invalid salt size: expected %d, got %d", Argon2SaltSize, len(salt))
|
||||
}
|
||||
|
||||
passwordBytes := []byte(password)
|
||||
defer memguard.WipeBytes(passwordBytes) // SECURITY: Wipe password bytes after use
|
||||
|
||||
key := argon2.IDKey(
|
||||
passwordBytes,
|
||||
salt,
|
||||
Argon2OpsLimit, // time cost = 1
|
||||
Argon2MemLimit, // memory = 4 MB
|
||||
Argon2Parallelism, // parallelism = 1
|
||||
Argon2KeySize, // output size = 32 bytes
|
||||
)
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// DeriveKeyFromPasswordPBKDF2 derives a KEK using PBKDF2-SHA256 algorithm.
|
||||
// This matches the web frontend's implementation.
|
||||
// SECURITY: Password bytes are wiped from memory after key derivation.
|
||||
func DeriveKeyFromPasswordPBKDF2(password string, salt []byte) ([]byte, error) {
|
||||
if len(salt) != PBKDF2SaltSize {
|
||||
return nil, fmt.Errorf("invalid salt size: expected %d, got %d", PBKDF2SaltSize, len(salt))
|
||||
}
|
||||
|
||||
passwordBytes := []byte(password)
|
||||
defer memguard.WipeBytes(passwordBytes) // SECURITY: Wipe password bytes after use
|
||||
|
||||
key := pbkdf2.Key(
|
||||
passwordBytes,
|
||||
salt,
|
||||
PBKDF2Iterations, // 100,000 iterations
|
||||
PBKDF2KeySize, // 32 bytes output
|
||||
sha256.New, // SHA-256 hash
|
||||
)
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// DeriveKeyFromPasswordWithAlgorithm derives a KEK using the specified algorithm.
|
||||
// algorithm should be one of: Argon2IDAlgorithm, PBKDF2Algorithm
|
||||
func DeriveKeyFromPasswordWithAlgorithm(password string, salt []byte, algorithm string) ([]byte, error) {
|
||||
switch algorithm {
|
||||
case Argon2IDAlgorithm: // "argon2id"
|
||||
return DeriveKeyFromPasswordArgon2id(password, salt)
|
||||
case PBKDF2Algorithm, "pbkdf2", "pbkdf2-sha256":
|
||||
return DeriveKeyFromPasswordPBKDF2(password, salt)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported KDF algorithm: %s", algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt encrypts data with a symmetric key using ChaCha20-Poly1305.
|
||||
func Encrypt(data, key []byte) (*EncryptedData, error) {
|
||||
if len(key) != ChaCha20Poly1305KeySize {
|
||||
return nil, fmt.Errorf("invalid key size: expected %d, got %d", ChaCha20Poly1305KeySize, len(key))
|
||||
}
|
||||
|
||||
// Create ChaCha20-Poly1305 cipher
|
||||
cipher, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
// Generate random nonce (12 bytes for ChaCha20-Poly1305)
|
||||
nonce, err := GenerateRandomBytes(ChaCha20Poly1305NonceSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
ciphertext := cipher.Seal(nil, nonce, data, nil)
|
||||
|
||||
return &EncryptedData{
|
||||
Ciphertext: ciphertext,
|
||||
Nonce: nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts data with a symmetric key using ChaCha20-Poly1305.
|
||||
func Decrypt(ciphertext, nonce, key []byte) ([]byte, error) {
|
||||
if len(key) != ChaCha20Poly1305KeySize {
|
||||
return nil, fmt.Errorf("invalid key size: expected %d, got %d", ChaCha20Poly1305KeySize, len(key))
|
||||
}
|
||||
|
||||
if len(nonce) != ChaCha20Poly1305NonceSize {
|
||||
return nil, fmt.Errorf("invalid nonce size: expected %d, got %d", ChaCha20Poly1305NonceSize, len(nonce))
|
||||
}
|
||||
|
||||
// Create ChaCha20-Poly1305 cipher
|
||||
cipher, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
plaintext, err := cipher.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// EncryptWithSecretBox encrypts data with a symmetric key using XSalsa20-Poly1305 (NaCl secretbox).
|
||||
// This is compatible with libsodium's crypto_secretbox_easy used by the web frontend.
|
||||
// SECURITY: Key arrays are wiped from memory after encryption.
|
||||
func EncryptWithSecretBox(data, key []byte) (*EncryptedData, error) {
|
||||
if len(key) != SecretBoxKeySize {
|
||||
return nil, fmt.Errorf("invalid key size: expected %d, got %d", SecretBoxKeySize, len(key))
|
||||
}
|
||||
|
||||
// Generate random nonce (24 bytes for XSalsa20)
|
||||
nonce, err := GenerateRandomBytes(SecretBoxNonceSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Convert to fixed-size arrays for NaCl
|
||||
var keyArray [32]byte
|
||||
var nonceArray [24]byte
|
||||
copy(keyArray[:], key)
|
||||
copy(nonceArray[:], nonce)
|
||||
defer memguard.WipeBytes(keyArray[:]) // SECURITY: Wipe key array
|
||||
|
||||
// Encrypt using secretbox
|
||||
ciphertext := secretbox.Seal(nil, data, &nonceArray, &keyArray)
|
||||
|
||||
return &EncryptedData{
|
||||
Ciphertext: ciphertext,
|
||||
Nonce: nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecryptWithSecretBox decrypts data with a symmetric key using XSalsa20-Poly1305 (NaCl secretbox).
|
||||
// This is compatible with libsodium's crypto_secretbox_open_easy used by the web frontend.
|
||||
// SECURITY: Key arrays are wiped from memory after decryption.
|
||||
func DecryptWithSecretBox(ciphertext, nonce, key []byte) ([]byte, error) {
|
||||
if len(key) != SecretBoxKeySize {
|
||||
return nil, fmt.Errorf("invalid key size: expected %d, got %d", SecretBoxKeySize, len(key))
|
||||
}
|
||||
|
||||
if len(nonce) != SecretBoxNonceSize {
|
||||
return nil, fmt.Errorf("invalid nonce size: expected %d, got %d", SecretBoxNonceSize, len(nonce))
|
||||
}
|
||||
|
||||
// Convert to fixed-size arrays for NaCl
|
||||
var keyArray [32]byte
|
||||
var nonceArray [24]byte
|
||||
copy(keyArray[:], key)
|
||||
copy(nonceArray[:], nonce)
|
||||
defer memguard.WipeBytes(keyArray[:]) // SECURITY: Wipe key array
|
||||
|
||||
// Decrypt using secretbox
|
||||
plaintext, ok := secretbox.Open(nil, ciphertext, &nonceArray, &keyArray)
|
||||
if !ok {
|
||||
return nil, errors.New("failed to decrypt: invalid key, nonce, or corrupted ciphertext")
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// DecryptWithAlgorithm decrypts data using the appropriate cipher based on nonce size.
|
||||
// - 12-byte nonce: ChaCha20-Poly1305 (IETF variant)
|
||||
// - 24-byte nonce: XSalsa20-Poly1305 (NaCl secretbox)
|
||||
func DecryptWithAlgorithm(ciphertext, nonce, key []byte) ([]byte, error) {
|
||||
switch len(nonce) {
|
||||
case ChaCha20Poly1305NonceSize: // 12 bytes
|
||||
return Decrypt(ciphertext, nonce, key)
|
||||
case SecretBoxNonceSize: // 24 bytes
|
||||
return DecryptWithSecretBox(ciphertext, nonce, key)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid nonce size: %d (expected %d for ChaCha20 or %d for XSalsa20)",
|
||||
len(nonce), ChaCha20Poly1305NonceSize, SecretBoxNonceSize)
|
||||
}
|
||||
}
|
||||
|
||||
// EncryptWithBoxSeal encrypts data anonymously using NaCl sealed box.
|
||||
// The result format is: ephemeral_public_key (32) || nonce (24) || ciphertext + auth_tag.
|
||||
func EncryptWithBoxSeal(message []byte, recipientPublicKey []byte) ([]byte, error) {
|
||||
if len(recipientPublicKey) != BoxPublicKeySize {
|
||||
return nil, fmt.Errorf("recipient public key must be %d bytes", BoxPublicKeySize)
|
||||
}
|
||||
|
||||
var recipientPubKey [32]byte
|
||||
copy(recipientPubKey[:], recipientPublicKey)
|
||||
|
||||
// Generate ephemeral keypair
|
||||
ephemeralPubKey, ephemeralPrivKey, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate ephemeral keypair: %w", err)
|
||||
}
|
||||
|
||||
// Generate random nonce
|
||||
nonce, err := GenerateRandomBytes(BoxNonceSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
var nonceArray [24]byte
|
||||
copy(nonceArray[:], nonce)
|
||||
|
||||
// Encrypt with ephemeral private key
|
||||
ciphertext := box.Seal(nil, message, &nonceArray, &recipientPubKey, ephemeralPrivKey)
|
||||
|
||||
// Result format: ephemeral_public_key || nonce || ciphertext
|
||||
result := make([]byte, BoxPublicKeySize+BoxNonceSize+len(ciphertext))
|
||||
copy(result[:BoxPublicKeySize], ephemeralPubKey[:])
|
||||
copy(result[BoxPublicKeySize:BoxPublicKeySize+BoxNonceSize], nonce)
|
||||
copy(result[BoxPublicKeySize+BoxNonceSize:], ciphertext)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecryptWithBoxSeal decrypts data that was encrypted with EncryptWithBoxSeal.
|
||||
// SECURITY: Key arrays are wiped from memory after decryption.
|
||||
func DecryptWithBoxSeal(sealedData []byte, recipientPublicKey, recipientPrivateKey []byte) ([]byte, error) {
|
||||
if len(recipientPublicKey) != BoxPublicKeySize {
|
||||
return nil, fmt.Errorf("recipient public key must be %d bytes", BoxPublicKeySize)
|
||||
}
|
||||
if len(recipientPrivateKey) != BoxSecretKeySize {
|
||||
return nil, fmt.Errorf("recipient private key must be %d bytes", BoxSecretKeySize)
|
||||
}
|
||||
if len(sealedData) < BoxPublicKeySize+BoxNonceSize+box.Overhead {
|
||||
return nil, errors.New("sealed data too short")
|
||||
}
|
||||
|
||||
// Extract components
|
||||
ephemeralPublicKey := sealedData[:BoxPublicKeySize]
|
||||
nonce := sealedData[BoxPublicKeySize : BoxPublicKeySize+BoxNonceSize]
|
||||
ciphertext := sealedData[BoxPublicKeySize+BoxNonceSize:]
|
||||
|
||||
// Create fixed-size arrays
|
||||
var ephemeralPubKey [32]byte
|
||||
var recipientPrivKey [32]byte
|
||||
var nonceArray [24]byte
|
||||
copy(ephemeralPubKey[:], ephemeralPublicKey)
|
||||
copy(recipientPrivKey[:], recipientPrivateKey)
|
||||
copy(nonceArray[:], nonce)
|
||||
defer memguard.WipeBytes(recipientPrivKey[:]) // SECURITY: Wipe private key array
|
||||
|
||||
// Decrypt
|
||||
plaintext, ok := box.Open(nil, ciphertext, &nonceArray, &ephemeralPubKey, &recipientPrivKey)
|
||||
if !ok {
|
||||
return nil, errors.New("failed to decrypt sealed box: invalid keys or corrupted ciphertext")
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// DecryptAnonymousBox decrypts sealed box data (used in login challenges).
|
||||
// SECURITY: Key arrays are wiped from memory after decryption.
|
||||
func DecryptAnonymousBox(encryptedData []byte, recipientPublicKey, recipientPrivateKey []byte) ([]byte, error) {
|
||||
if len(recipientPublicKey) != BoxPublicKeySize {
|
||||
return nil, fmt.Errorf("recipient public key must be %d bytes", BoxPublicKeySize)
|
||||
}
|
||||
if len(recipientPrivateKey) != BoxSecretKeySize {
|
||||
return nil, fmt.Errorf("recipient private key must be %d bytes", BoxSecretKeySize)
|
||||
}
|
||||
|
||||
var pubKeyArray, privKeyArray [32]byte
|
||||
copy(pubKeyArray[:], recipientPublicKey)
|
||||
copy(privKeyArray[:], recipientPrivateKey)
|
||||
defer memguard.WipeBytes(privKeyArray[:]) // SECURITY: Wipe private key array
|
||||
|
||||
decryptedData, ok := box.OpenAnonymous(nil, encryptedData, &pubKeyArray, &privKeyArray)
|
||||
if !ok {
|
||||
return nil, errors.New("failed to decrypt anonymous box: invalid keys or corrupted data")
|
||||
}
|
||||
|
||||
return decryptedData, nil
|
||||
}
|
||||
|
||||
// GenerateRandomBytes generates cryptographically secure random bytes.
|
||||
func GenerateRandomBytes(size int) ([]byte, error) {
|
||||
if size <= 0 {
|
||||
return nil, errors.New("size must be positive")
|
||||
}
|
||||
|
||||
buf := make([]byte, size)
|
||||
_, err := io.ReadFull(rand.Reader, buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// GenerateKeyPair generates a NaCl box keypair for asymmetric encryption.
|
||||
func GenerateKeyPair() (publicKey []byte, privateKey []byte, err error) {
|
||||
pubKey, privKey, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate key pair: %w", err)
|
||||
}
|
||||
|
||||
return pubKey[:], privKey[:], nil
|
||||
}
|
||||
|
||||
// ClearBytes overwrites a byte slice with zeros using memguard for secure wiping.
|
||||
// This should be called on sensitive data like keys when they're no longer needed.
|
||||
// SECURITY: Uses memguard.WipeBytes for secure memory wiping that prevents compiler optimizations.
|
||||
func ClearBytes(b []byte) {
|
||||
memguard.WipeBytes(b)
|
||||
}
|
||||
|
||||
// CombineNonceAndCiphertext combines nonce and ciphertext into a single byte slice.
|
||||
func CombineNonceAndCiphertext(nonce, ciphertext []byte) []byte {
|
||||
combined := make([]byte, len(nonce)+len(ciphertext))
|
||||
copy(combined[:len(nonce)], nonce)
|
||||
copy(combined[len(nonce):], ciphertext)
|
||||
return combined
|
||||
}
|
||||
|
||||
// SplitNonceAndCiphertext splits a combined byte slice into nonce and ciphertext.
|
||||
// This function defaults to ChaCha20-Poly1305 nonce size (12 bytes) for backward compatibility.
|
||||
// For XSalsa20-Poly1305 (24-byte nonce), use SplitNonceAndCiphertextSecretBox.
|
||||
func SplitNonceAndCiphertext(combined []byte) (nonce []byte, ciphertext []byte, err error) {
|
||||
if len(combined) < ChaCha20Poly1305NonceSize {
|
||||
return nil, nil, fmt.Errorf("combined data too short: expected at least %d bytes, got %d", ChaCha20Poly1305NonceSize, len(combined))
|
||||
}
|
||||
|
||||
nonce = combined[:ChaCha20Poly1305NonceSize]
|
||||
ciphertext = combined[ChaCha20Poly1305NonceSize:]
|
||||
return nonce, ciphertext, nil
|
||||
}
|
||||
|
||||
// SplitNonceAndCiphertextSecretBox splits a combined byte slice for XSalsa20-Poly1305 (24-byte nonce).
|
||||
// This is compatible with libsodium's secretbox format: nonce (24) || ciphertext || mac (16).
|
||||
func SplitNonceAndCiphertextSecretBox(combined []byte) (nonce []byte, ciphertext []byte, err error) {
|
||||
if len(combined) < SecretBoxNonceSize {
|
||||
return nil, nil, fmt.Errorf("combined data too short: expected at least %d bytes, got %d", SecretBoxNonceSize, len(combined))
|
||||
}
|
||||
|
||||
nonce = combined[:SecretBoxNonceSize]
|
||||
ciphertext = combined[SecretBoxNonceSize:]
|
||||
return nonce, ciphertext, nil
|
||||
}
|
||||
|
||||
// SplitNonceAndCiphertextAuto automatically detects the nonce size based on data length.
|
||||
// It uses heuristics to determine if data is ChaCha20-Poly1305 (12-byte nonce) or XSalsa20 (24-byte nonce).
|
||||
// This function should be used when the cipher type is unknown.
|
||||
func SplitNonceAndCiphertextAuto(combined []byte) (nonce []byte, ciphertext []byte, err error) {
|
||||
// Web frontend uses XSalsa20-Poly1305 with 24-byte nonce
|
||||
// Native app used to use ChaCha20-Poly1305 with 12-byte nonce
|
||||
//
|
||||
// For encrypted master key data:
|
||||
// - Web frontend: nonce (24) + ciphertext (32 + 16 MAC) = 72 bytes
|
||||
// - Native/old: nonce (12) + ciphertext (32 + 16 MAC) = 60 bytes
|
||||
//
|
||||
// We can distinguish by checking if the data length suggests 24-byte nonce
|
||||
// Data encrypted with 24-byte nonce will be 12 bytes longer than 12-byte nonce version
|
||||
|
||||
if len(combined) < ChaCha20Poly1305NonceSize+ChaCha20Poly1305Overhead {
|
||||
return nil, nil, fmt.Errorf("combined data too short: expected at least %d bytes, got %d",
|
||||
ChaCha20Poly1305NonceSize+ChaCha20Poly1305Overhead, len(combined))
|
||||
}
|
||||
|
||||
// If data length is at least 72 bytes (24 nonce + 32 key + 16 MAC for master key),
|
||||
// try XSalsa20 format first. This is the web frontend format.
|
||||
if len(combined) >= SecretBoxNonceSize+SecretBoxOverhead+1 {
|
||||
return SplitNonceAndCiphertextSecretBox(combined)
|
||||
}
|
||||
|
||||
// Default to ChaCha20-Poly1305 (legacy)
|
||||
return SplitNonceAndCiphertext(combined)
|
||||
}
|
||||
|
||||
// EncodeToBase64 encodes bytes to base64 standard encoding.
|
||||
func EncodeToBase64(data []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// DecodeFromBase64 decodes a base64 standard encoded string to bytes.
|
||||
func DecodeFromBase64(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
235
cloud/maplefile-backend/pkg/maplefile/e2ee/file.go
Normal file
235
cloud/maplefile-backend/pkg/maplefile/e2ee/file.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// Package e2ee provides end-to-end encryption operations for the MapleFile SDK.
|
||||
package e2ee
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// FileMetadata represents decrypted file metadata.
|
||||
type FileMetadata struct {
|
||||
Name string `json:"name"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// EncryptFile encrypts file content using the file key.
|
||||
// Returns the combined nonce + ciphertext.
|
||||
// NOTE: This uses ChaCha20-Poly1305 (12-byte nonce). For web frontend compatibility,
|
||||
// use EncryptFileSecretBox instead.
|
||||
func EncryptFile(plaintext, fileKey []byte) ([]byte, error) {
|
||||
encryptedData, err := Encrypt(plaintext, fileKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt file: %w", err)
|
||||
}
|
||||
|
||||
// Combine nonce and ciphertext for storage
|
||||
combined := CombineNonceAndCiphertext(encryptedData.Nonce, encryptedData.Ciphertext)
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
// EncryptFileSecretBox encrypts file content using XSalsa20-Poly1305 (NaCl secretbox).
|
||||
// Returns the combined nonce (24 bytes) + ciphertext.
|
||||
// This is compatible with the web frontend's libsodium implementation.
|
||||
func EncryptFileSecretBox(plaintext, fileKey []byte) ([]byte, error) {
|
||||
encryptedData, err := EncryptWithSecretBox(plaintext, fileKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt file: %w", err)
|
||||
}
|
||||
|
||||
// Combine nonce and ciphertext for storage (matching web frontend format)
|
||||
combined := CombineNonceAndCiphertext(encryptedData.Nonce, encryptedData.Ciphertext)
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
// DecryptFile decrypts file content using the file key.
|
||||
// The input should be combined nonce + ciphertext.
|
||||
// Auto-detects the cipher based on nonce size:
|
||||
// - 24-byte nonce: XSalsa20-Poly1305 (web frontend / SecretBox)
|
||||
// - 12-byte nonce: ChaCha20-Poly1305 (legacy native app)
|
||||
func DecryptFile(encryptedData, fileKey []byte) ([]byte, error) {
|
||||
// Split nonce and ciphertext (auto-detect nonce size)
|
||||
nonce, ciphertext, err := SplitNonceAndCiphertextAuto(encryptedData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to split encrypted data: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt using appropriate algorithm based on nonce size
|
||||
plaintext, err := DecryptWithAlgorithm(ciphertext, nonce, fileKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt file: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// EncryptFileWithNonce encrypts file content and returns the ciphertext and nonce separately.
|
||||
func EncryptFileWithNonce(plaintext, fileKey []byte) (ciphertext []byte, nonce []byte, err error) {
|
||||
encryptedData, err := Encrypt(plaintext, fileKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to encrypt file: %w", err)
|
||||
}
|
||||
|
||||
return encryptedData.Ciphertext, encryptedData.Nonce, nil
|
||||
}
|
||||
|
||||
// DecryptFileWithNonce decrypts file content using separate ciphertext and nonce.
|
||||
func DecryptFileWithNonce(ciphertext, nonce, fileKey []byte) ([]byte, error) {
|
||||
plaintext, err := Decrypt(ciphertext, nonce, fileKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt file: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// EncryptMetadata encrypts file metadata using the file key.
|
||||
// Returns base64-encoded combined nonce + ciphertext.
|
||||
// NOTE: This uses ChaCha20-Poly1305 (12-byte nonce). For web frontend compatibility,
|
||||
// use EncryptMetadataSecretBox instead.
|
||||
func EncryptMetadata(metadata *FileMetadata, fileKey []byte) (string, error) {
|
||||
// Convert metadata to JSON
|
||||
metadataBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt metadata
|
||||
encryptedData, err := Encrypt(metadataBytes, fileKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt metadata: %w", err)
|
||||
}
|
||||
|
||||
// Combine nonce and ciphertext
|
||||
combined := CombineNonceAndCiphertext(encryptedData.Nonce, encryptedData.Ciphertext)
|
||||
|
||||
// Encode to base64
|
||||
return EncodeToBase64(combined), nil
|
||||
}
|
||||
|
||||
// EncryptMetadataSecretBox encrypts file metadata using XSalsa20-Poly1305 (NaCl secretbox).
|
||||
// Returns base64-encoded combined nonce + ciphertext.
|
||||
// This is compatible with the web frontend's libsodium implementation.
|
||||
func EncryptMetadataSecretBox(metadata *FileMetadata, fileKey []byte) (string, error) {
|
||||
// Convert metadata to JSON
|
||||
metadataBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt metadata using SecretBox
|
||||
encryptedData, err := EncryptWithSecretBox(metadataBytes, fileKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt metadata: %w", err)
|
||||
}
|
||||
|
||||
// Combine nonce and ciphertext
|
||||
combined := CombineNonceAndCiphertext(encryptedData.Nonce, encryptedData.Ciphertext)
|
||||
|
||||
// Encode to base64
|
||||
return EncodeToBase64(combined), nil
|
||||
}
|
||||
|
||||
// DecryptMetadata decrypts file metadata using the file key.
|
||||
// The input should be base64-encoded combined nonce + ciphertext.
|
||||
func DecryptMetadata(encryptedMetadata string, fileKey []byte) (*FileMetadata, error) {
|
||||
// Decode from base64
|
||||
combined, err := DecodeFromBase64(encryptedMetadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode encrypted metadata: %w", err)
|
||||
}
|
||||
|
||||
// Split nonce and ciphertext
|
||||
nonce, ciphertext, err := SplitNonceAndCiphertext(combined)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to split encrypted metadata: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decryptedBytes, err := Decrypt(ciphertext, nonce, fileKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt metadata: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var metadata FileMetadata
|
||||
if err := json.Unmarshal(decryptedBytes, &metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse decrypted metadata: %w", err)
|
||||
}
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// EncryptMetadataWithNonce encrypts file metadata and returns nonce separately.
|
||||
func EncryptMetadataWithNonce(metadata *FileMetadata, fileKey []byte) (ciphertext []byte, nonce []byte, err error) {
|
||||
// Convert metadata to JSON
|
||||
metadataBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt metadata
|
||||
encryptedData, err := Encrypt(metadataBytes, fileKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to encrypt metadata: %w", err)
|
||||
}
|
||||
|
||||
return encryptedData.Ciphertext, encryptedData.Nonce, nil
|
||||
}
|
||||
|
||||
// DecryptMetadataWithNonce decrypts file metadata using separate ciphertext and nonce.
|
||||
func DecryptMetadataWithNonce(ciphertext, nonce, fileKey []byte) (*FileMetadata, error) {
|
||||
// Decrypt
|
||||
decryptedBytes, err := Decrypt(ciphertext, nonce, fileKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt metadata: %w", err)
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var metadata FileMetadata
|
||||
if err := json.Unmarshal(decryptedBytes, &metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse decrypted metadata: %w", err)
|
||||
}
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// EncryptData encrypts arbitrary data using the provided key.
|
||||
// Returns base64-encoded combined nonce + ciphertext.
|
||||
func EncryptData(data, key []byte) (string, error) {
|
||||
encryptedData, err := Encrypt(data, key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt data: %w", err)
|
||||
}
|
||||
|
||||
// Combine nonce and ciphertext
|
||||
combined := CombineNonceAndCiphertext(encryptedData.Nonce, encryptedData.Ciphertext)
|
||||
|
||||
// Encode to base64
|
||||
return EncodeToBase64(combined), nil
|
||||
}
|
||||
|
||||
// DecryptData decrypts arbitrary data using the provided key.
|
||||
// The input should be base64-encoded combined nonce + ciphertext.
|
||||
func DecryptData(encryptedData string, key []byte) ([]byte, error) {
|
||||
// Decode from base64
|
||||
combined, err := DecodeFromBase64(encryptedData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode encrypted data: %w", err)
|
||||
}
|
||||
|
||||
// Split nonce and ciphertext
|
||||
nonce, ciphertext, err := SplitNonceAndCiphertext(combined)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to split encrypted data: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
plaintext, err := Decrypt(ciphertext, nonce, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt data: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
401
cloud/maplefile-backend/pkg/maplefile/e2ee/keychain.go
Normal file
401
cloud/maplefile-backend/pkg/maplefile/e2ee/keychain.go
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
// Package e2ee provides end-to-end encryption operations for the MapleFile SDK.
|
||||
package e2ee
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// KeyChain holds the key encryption key derived from the user's password.
|
||||
// It provides methods for decrypting keys in the E2EE chain.
|
||||
type KeyChain struct {
|
||||
kek []byte // Key Encryption Key derived from password
|
||||
salt []byte // Password salt used for key derivation
|
||||
kdfAlgorithm string // KDF algorithm used ("argon2id" or "PBKDF2-SHA256")
|
||||
}
|
||||
|
||||
// EncryptedKey represents a key encrypted with another key.
|
||||
type EncryptedKey struct {
|
||||
Ciphertext []byte `json:"ciphertext"`
|
||||
Nonce []byte `json:"nonce"`
|
||||
}
|
||||
|
||||
// NewKeyChain creates a new KeyChain by deriving the KEK from the password and salt.
|
||||
// This function defaults to Argon2id for backward compatibility.
|
||||
// For cross-platform compatibility, use NewKeyChainWithAlgorithm instead.
|
||||
func NewKeyChain(password string, salt []byte) (*KeyChain, error) {
|
||||
return NewKeyChainWithAlgorithm(password, salt, Argon2IDAlgorithm)
|
||||
}
|
||||
|
||||
// NewKeyChainWithAlgorithm creates a new KeyChain using the specified KDF algorithm.
|
||||
// algorithm should be one of: Argon2IDAlgorithm ("argon2id") or PBKDF2Algorithm ("PBKDF2-SHA256").
|
||||
// The web frontend uses PBKDF2-SHA256, while the native app historically used Argon2id.
|
||||
func NewKeyChainWithAlgorithm(password string, salt []byte, algorithm string) (*KeyChain, error) {
|
||||
// Validate salt size (both algorithms use 16-byte salt)
|
||||
if len(salt) != 16 {
|
||||
return nil, fmt.Errorf("invalid salt size: expected 16, got %d", len(salt))
|
||||
}
|
||||
|
||||
// Derive key encryption key from password using specified algorithm
|
||||
kek, err := DeriveKeyFromPasswordWithAlgorithm(password, salt, algorithm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive key from password: %w", err)
|
||||
}
|
||||
|
||||
return &KeyChain{
|
||||
kek: kek,
|
||||
salt: salt,
|
||||
kdfAlgorithm: algorithm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Clear securely clears the KeyChain's sensitive data from memory.
|
||||
// This should be called when the KeyChain is no longer needed.
|
||||
func (k *KeyChain) Clear() {
|
||||
if k.kek != nil {
|
||||
ClearBytes(k.kek)
|
||||
k.kek = nil
|
||||
}
|
||||
}
|
||||
|
||||
// DecryptMasterKey decrypts the user's master key using the KEK.
|
||||
// This method auto-detects the cipher based on nonce size:
|
||||
// - 12-byte nonce: ChaCha20-Poly1305 (native app)
|
||||
// - 24-byte nonce: XSalsa20-Poly1305 (web frontend)
|
||||
func (k *KeyChain) DecryptMasterKey(encryptedMasterKey *EncryptedKey) ([]byte, error) {
|
||||
if k.kek == nil {
|
||||
return nil, fmt.Errorf("keychain has been cleared")
|
||||
}
|
||||
|
||||
// Auto-detect cipher based on nonce size
|
||||
masterKey, err := DecryptWithAlgorithm(encryptedMasterKey.Ciphertext, encryptedMasterKey.Nonce, k.kek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt master key: %w", err)
|
||||
}
|
||||
|
||||
return masterKey, nil
|
||||
}
|
||||
|
||||
// DecryptCollectionKey decrypts a collection key using the master key.
|
||||
// Auto-detects cipher based on nonce size (12 for ChaCha20, 24 for XSalsa20).
|
||||
func DecryptCollectionKey(encryptedCollectionKey *EncryptedKey, masterKey []byte) ([]byte, error) {
|
||||
collectionKey, err := DecryptWithAlgorithm(encryptedCollectionKey.Ciphertext, encryptedCollectionKey.Nonce, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt collection key: %w", err)
|
||||
}
|
||||
|
||||
return collectionKey, nil
|
||||
}
|
||||
|
||||
// DecryptFileKey decrypts a file key using the collection key.
|
||||
// Auto-detects cipher based on nonce size (12 for ChaCha20, 24 for XSalsa20).
|
||||
func DecryptFileKey(encryptedFileKey *EncryptedKey, collectionKey []byte) ([]byte, error) {
|
||||
fileKey, err := DecryptWithAlgorithm(encryptedFileKey.Ciphertext, encryptedFileKey.Nonce, collectionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt file key: %w", err)
|
||||
}
|
||||
|
||||
return fileKey, nil
|
||||
}
|
||||
|
||||
// DecryptPrivateKey decrypts the user's private key using the master key.
|
||||
// Auto-detects cipher based on nonce size (12 for ChaCha20, 24 for XSalsa20).
|
||||
func DecryptPrivateKey(encryptedPrivateKey *EncryptedKey, masterKey []byte) ([]byte, error) {
|
||||
privateKey, err := DecryptWithAlgorithm(encryptedPrivateKey.Ciphertext, encryptedPrivateKey.Nonce, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
// DecryptRecoveryKey decrypts the user's recovery key using the master key.
|
||||
// Auto-detects cipher based on nonce size (12 for ChaCha20, 24 for XSalsa20).
|
||||
func DecryptRecoveryKey(encryptedRecoveryKey *EncryptedKey, masterKey []byte) ([]byte, error) {
|
||||
recoveryKey, err := DecryptWithAlgorithm(encryptedRecoveryKey.Ciphertext, encryptedRecoveryKey.Nonce, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt recovery key: %w", err)
|
||||
}
|
||||
|
||||
return recoveryKey, nil
|
||||
}
|
||||
|
||||
// DecryptMasterKeyWithRecoveryKey decrypts the master key using the recovery key.
|
||||
// This is used during account recovery.
|
||||
// Auto-detects cipher based on nonce size (12 for ChaCha20, 24 for XSalsa20).
|
||||
func DecryptMasterKeyWithRecoveryKey(encryptedMasterKey *EncryptedKey, recoveryKey []byte) ([]byte, error) {
|
||||
masterKey, err := DecryptWithAlgorithm(encryptedMasterKey.Ciphertext, encryptedMasterKey.Nonce, recoveryKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt master key with recovery key: %w", err)
|
||||
}
|
||||
|
||||
return masterKey, nil
|
||||
}
|
||||
|
||||
// GenerateMasterKey generates a new random master key.
|
||||
func GenerateMasterKey() ([]byte, error) {
|
||||
return GenerateRandomBytes(MasterKeySize)
|
||||
}
|
||||
|
||||
// GenerateCollectionKey generates a new random collection key.
|
||||
func GenerateCollectionKey() ([]byte, error) {
|
||||
return GenerateRandomBytes(CollectionKeySize)
|
||||
}
|
||||
|
||||
// GenerateFileKey generates a new random file key.
|
||||
func GenerateFileKey() ([]byte, error) {
|
||||
return GenerateRandomBytes(FileKeySize)
|
||||
}
|
||||
|
||||
// GenerateRecoveryKey generates a new random recovery key.
|
||||
func GenerateRecoveryKey() ([]byte, error) {
|
||||
return GenerateRandomBytes(RecoveryKeySize)
|
||||
}
|
||||
|
||||
// GenerateSalt generates a new random salt for password derivation.
|
||||
func GenerateSalt() ([]byte, error) {
|
||||
return GenerateRandomBytes(Argon2SaltSize)
|
||||
}
|
||||
|
||||
// EncryptMasterKey encrypts a master key with the KEK.
|
||||
func (k *KeyChain) EncryptMasterKey(masterKey []byte) (*EncryptedKey, error) {
|
||||
if k.kek == nil {
|
||||
return nil, fmt.Errorf("keychain has been cleared")
|
||||
}
|
||||
|
||||
encrypted, err := Encrypt(masterKey, k.kek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt master key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptCollectionKey encrypts a collection key with the master key using ChaCha20-Poly1305.
|
||||
// For web frontend compatibility, use EncryptCollectionKeySecretBox instead.
|
||||
func EncryptCollectionKey(collectionKey, masterKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := Encrypt(collectionKey, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt collection key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptCollectionKeySecretBox encrypts a collection key with the master key using XSalsa20-Poly1305.
|
||||
// This is compatible with the web frontend's libsodium implementation.
|
||||
func EncryptCollectionKeySecretBox(collectionKey, masterKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := EncryptWithSecretBox(collectionKey, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt collection key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptFileKey encrypts a file key with the collection key.
|
||||
// NOTE: This uses ChaCha20-Poly1305 (12-byte nonce). For web frontend compatibility,
|
||||
// use EncryptFileKeySecretBox instead.
|
||||
func EncryptFileKey(fileKey, collectionKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := Encrypt(fileKey, collectionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt file key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptFileKeySecretBox encrypts a file key with the collection key using XSalsa20-Poly1305.
|
||||
// This is compatible with the web frontend's libsodium implementation.
|
||||
func EncryptFileKeySecretBox(fileKey, collectionKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := EncryptWithSecretBox(fileKey, collectionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt file key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptPrivateKey encrypts a private key with the master key.
|
||||
func EncryptPrivateKey(privateKey, masterKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := Encrypt(privateKey, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptRecoveryKey encrypts a recovery key with the master key.
|
||||
func EncryptRecoveryKey(recoveryKey, masterKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := Encrypt(recoveryKey, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt recovery key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptMasterKeyWithRecoveryKey encrypts a master key with the recovery key.
|
||||
// This is used to enable account recovery.
|
||||
func EncryptMasterKeyWithRecoveryKey(masterKey, recoveryKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := Encrypt(masterKey, recoveryKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt master key with recovery key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SecretBox (XSalsa20-Poly1305) Encryption Functions
|
||||
// These match the web frontend's libsodium crypto_secretbox_easy implementation
|
||||
// =============================================================================
|
||||
|
||||
// EncryptMasterKeySecretBox encrypts a master key with the KEK using XSalsa20-Poly1305.
|
||||
// This is compatible with the web frontend's libsodium implementation.
|
||||
func (k *KeyChain) EncryptMasterKeySecretBox(masterKey []byte) (*EncryptedKey, error) {
|
||||
if k.kek == nil {
|
||||
return nil, fmt.Errorf("keychain has been cleared")
|
||||
}
|
||||
|
||||
encrypted, err := EncryptWithSecretBox(masterKey, k.kek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt master key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptPrivateKeySecretBox encrypts a private key with the master key using XSalsa20-Poly1305.
|
||||
func EncryptPrivateKeySecretBox(privateKey, masterKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := EncryptWithSecretBox(privateKey, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptRecoveryKeySecretBox encrypts a recovery key with the master key using XSalsa20-Poly1305.
|
||||
func EncryptRecoveryKeySecretBox(recoveryKey, masterKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := EncryptWithSecretBox(recoveryKey, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt recovery key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptMasterKeyWithRecoveryKeySecretBox encrypts a master key with the recovery key using XSalsa20-Poly1305.
|
||||
func EncryptMasterKeyWithRecoveryKeySecretBox(masterKey, recoveryKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := EncryptWithSecretBox(masterKey, recoveryKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt master key with recovery key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptCollectionKeyForSharing encrypts a collection key for a recipient using BoxSeal.
|
||||
// This is used when sharing a collection with another user.
|
||||
func EncryptCollectionKeyForSharing(collectionKey, recipientPublicKey []byte) ([]byte, error) {
|
||||
if len(recipientPublicKey) != BoxPublicKeySize {
|
||||
return nil, fmt.Errorf("invalid recipient public key size: expected %d, got %d", BoxPublicKeySize, len(recipientPublicKey))
|
||||
}
|
||||
|
||||
return EncryptWithBoxSeal(collectionKey, recipientPublicKey)
|
||||
}
|
||||
|
||||
// DecryptSharedCollectionKey decrypts a collection key that was shared using BoxSeal.
|
||||
// This is used when accessing a shared collection.
|
||||
func DecryptSharedCollectionKey(encryptedCollectionKey, publicKey, privateKey []byte) ([]byte, error) {
|
||||
return DecryptWithBoxSeal(encryptedCollectionKey, publicKey, privateKey)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tag Key Operations
|
||||
// ============================================================================
|
||||
|
||||
// GenerateTagKey generates a new 32-byte tag key for encrypting tag data.
|
||||
func GenerateTagKey() ([]byte, error) {
|
||||
return GenerateRandomBytes(SecretBoxKeySize)
|
||||
}
|
||||
|
||||
// GenerateKey is an alias for GenerateTagKey (convenience function).
|
||||
func GenerateKey() []byte {
|
||||
key, _ := GenerateTagKey()
|
||||
return key
|
||||
}
|
||||
|
||||
// EncryptTagKey encrypts a tag key with the master key using ChaCha20-Poly1305.
|
||||
func EncryptTagKey(tagKey, masterKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := Encrypt(tagKey, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt tag key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptTagKeySecretBox encrypts a tag key with the master key using XSalsa20-Poly1305.
|
||||
func EncryptTagKeySecretBox(tagKey, masterKey []byte) (*EncryptedKey, error) {
|
||||
encrypted, err := EncryptWithSecretBox(tagKey, masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt tag key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecryptTagKey decrypts a tag key with the master key.
|
||||
func DecryptTagKey(encryptedTagKey *EncryptedKey, masterKey []byte) ([]byte, error) {
|
||||
// Try XSalsa20-Poly1305 first (based on nonce size)
|
||||
if len(encryptedTagKey.Nonce) == SecretBoxNonceSize {
|
||||
return DecryptWithSecretBox(encryptedTagKey.Ciphertext, encryptedTagKey.Nonce, masterKey)
|
||||
}
|
||||
|
||||
// Fall back to ChaCha20-Poly1305
|
||||
return Decrypt(encryptedTagKey.Ciphertext, encryptedTagKey.Nonce, masterKey)
|
||||
}
|
||||
246
cloud/maplefile-backend/pkg/maplefile/e2ee/secure.go
Normal file
246
cloud/maplefile-backend/pkg/maplefile/e2ee/secure.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
// Package e2ee provides end-to-end encryption operations for the MapleFile SDK.
|
||||
// This file contains memguard-protected secure memory operations.
|
||||
package e2ee
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
)
|
||||
|
||||
// SecureBuffer wraps memguard.LockedBuffer for type safety
|
||||
type SecureBuffer struct {
|
||||
buffer *memguard.LockedBuffer
|
||||
}
|
||||
|
||||
// NewSecureBuffer creates a new secure buffer from bytes
|
||||
func NewSecureBuffer(data []byte) (*SecureBuffer, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("cannot create secure buffer from empty data")
|
||||
}
|
||||
|
||||
buffer := memguard.NewBufferFromBytes(data)
|
||||
return &SecureBuffer{buffer: buffer}, nil
|
||||
}
|
||||
|
||||
// NewSecureBufferRandom creates a new secure buffer with random data
|
||||
func NewSecureBufferRandom(size int) (*SecureBuffer, error) {
|
||||
if size <= 0 {
|
||||
return nil, fmt.Errorf("size must be positive")
|
||||
}
|
||||
|
||||
buffer := memguard.NewBuffer(size)
|
||||
return &SecureBuffer{buffer: buffer}, nil
|
||||
}
|
||||
|
||||
// Bytes returns the underlying bytes (caller must handle carefully)
|
||||
func (s *SecureBuffer) Bytes() []byte {
|
||||
if s.buffer == nil {
|
||||
return nil
|
||||
}
|
||||
return s.buffer.Bytes()
|
||||
}
|
||||
|
||||
// Size returns the size of the buffer
|
||||
func (s *SecureBuffer) Size() int {
|
||||
if s.buffer == nil {
|
||||
return 0
|
||||
}
|
||||
return s.buffer.Size()
|
||||
}
|
||||
|
||||
// Destroy securely destroys the buffer
|
||||
func (s *SecureBuffer) Destroy() {
|
||||
if s.buffer != nil {
|
||||
s.buffer.Destroy()
|
||||
s.buffer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Copy creates a new SecureBuffer with a copy of the data
|
||||
func (s *SecureBuffer) Copy() (*SecureBuffer, error) {
|
||||
if s.buffer == nil {
|
||||
return nil, fmt.Errorf("cannot copy destroyed buffer")
|
||||
}
|
||||
|
||||
return NewSecureBuffer(s.buffer.Bytes())
|
||||
}
|
||||
|
||||
// SecureKeyChain is a KeyChain that stores the KEK in protected memory
|
||||
type SecureKeyChain struct {
|
||||
kek *SecureBuffer // Key Encryption Key in protected memory
|
||||
salt []byte // Salt (not sensitive, kept in regular memory)
|
||||
kdfAlgorithm string // KDF algorithm used
|
||||
}
|
||||
|
||||
// NewSecureKeyChain creates a new SecureKeyChain with KEK in protected memory.
|
||||
// This function defaults to Argon2id for backward compatibility.
|
||||
// For cross-platform compatibility, use NewSecureKeyChainWithAlgorithm instead.
|
||||
func NewSecureKeyChain(password string, salt []byte) (*SecureKeyChain, error) {
|
||||
return NewSecureKeyChainWithAlgorithm(password, salt, Argon2IDAlgorithm)
|
||||
}
|
||||
|
||||
// NewSecureKeyChainWithAlgorithm creates a new SecureKeyChain using the specified KDF algorithm.
|
||||
// algorithm should be one of: Argon2IDAlgorithm ("argon2id") or PBKDF2Algorithm ("PBKDF2-SHA256").
|
||||
// The web frontend uses PBKDF2-SHA256, while the native app historically used Argon2id.
|
||||
func NewSecureKeyChainWithAlgorithm(password string, salt []byte, algorithm string) (*SecureKeyChain, error) {
|
||||
// Both algorithms use 16-byte salt
|
||||
if len(salt) != 16 {
|
||||
return nil, fmt.Errorf("invalid salt size: expected 16, got %d", len(salt))
|
||||
}
|
||||
|
||||
// Derive KEK from password using specified algorithm
|
||||
kekBytes, err := DeriveKeyFromPasswordWithAlgorithm(password, salt, algorithm)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive key from password: %w", err)
|
||||
}
|
||||
|
||||
// Store KEK in secure memory immediately
|
||||
kek, err := NewSecureBuffer(kekBytes)
|
||||
if err != nil {
|
||||
ClearBytes(kekBytes)
|
||||
return nil, fmt.Errorf("failed to create secure buffer for KEK: %w", err)
|
||||
}
|
||||
|
||||
// Clear the temporary KEK bytes
|
||||
ClearBytes(kekBytes)
|
||||
|
||||
return &SecureKeyChain{
|
||||
kek: kek,
|
||||
salt: salt,
|
||||
kdfAlgorithm: algorithm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Clear securely clears the SecureKeyChain's sensitive data
|
||||
func (k *SecureKeyChain) Clear() {
|
||||
if k.kek != nil {
|
||||
k.kek.Destroy()
|
||||
k.kek = nil
|
||||
}
|
||||
}
|
||||
|
||||
// DecryptMasterKeySecure decrypts the master key and returns it in a SecureBuffer.
|
||||
// Auto-detects cipher based on nonce size (12 for ChaCha20, 24 for XSalsa20).
|
||||
func (k *SecureKeyChain) DecryptMasterKeySecure(encryptedMasterKey *EncryptedKey) (*SecureBuffer, error) {
|
||||
if k.kek == nil || k.kek.buffer == nil {
|
||||
return nil, fmt.Errorf("keychain has been cleared")
|
||||
}
|
||||
|
||||
// Decrypt using KEK from secure memory (auto-detect cipher based on nonce size)
|
||||
masterKeyBytes, err := DecryptWithAlgorithm(encryptedMasterKey.Ciphertext, encryptedMasterKey.Nonce, k.kek.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt master key: %w", err)
|
||||
}
|
||||
|
||||
// Store decrypted master key in secure memory
|
||||
masterKey, err := NewSecureBuffer(masterKeyBytes)
|
||||
if err != nil {
|
||||
ClearBytes(masterKeyBytes)
|
||||
return nil, fmt.Errorf("failed to create secure buffer for master key: %w", err)
|
||||
}
|
||||
|
||||
// Clear temporary bytes
|
||||
ClearBytes(masterKeyBytes)
|
||||
|
||||
return masterKey, nil
|
||||
}
|
||||
|
||||
// DecryptMasterKey provides backward compatibility by returning []byte.
|
||||
// For new code, prefer DecryptMasterKeySecure.
|
||||
// Auto-detects cipher based on nonce size (12 for ChaCha20, 24 for XSalsa20).
|
||||
func (k *SecureKeyChain) DecryptMasterKey(encryptedMasterKey *EncryptedKey) ([]byte, error) {
|
||||
if k.kek == nil || k.kek.buffer == nil {
|
||||
return nil, fmt.Errorf("keychain has been cleared")
|
||||
}
|
||||
|
||||
// Decrypt using KEK from secure memory (auto-detect cipher)
|
||||
return DecryptWithAlgorithm(encryptedMasterKey.Ciphertext, encryptedMasterKey.Nonce, k.kek.Bytes())
|
||||
}
|
||||
|
||||
// EncryptMasterKey encrypts a master key with the KEK using ChaCha20-Poly1305.
|
||||
// For web frontend compatibility, use EncryptMasterKeySecretBox instead.
|
||||
func (k *SecureKeyChain) EncryptMasterKey(masterKey []byte) (*EncryptedKey, error) {
|
||||
if k.kek == nil || k.kek.buffer == nil {
|
||||
return nil, fmt.Errorf("keychain has been cleared")
|
||||
}
|
||||
|
||||
encrypted, err := Encrypt(masterKey, k.kek.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt master key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptMasterKeySecretBox encrypts a master key with the KEK using XSalsa20-Poly1305 (SecretBox).
|
||||
// This is compatible with the web frontend's libsodium implementation.
|
||||
func (k *SecureKeyChain) EncryptMasterKeySecretBox(masterKey []byte) (*EncryptedKey, error) {
|
||||
if k.kek == nil || k.kek.buffer == nil {
|
||||
return nil, fmt.Errorf("keychain has been cleared")
|
||||
}
|
||||
|
||||
encrypted, err := EncryptWithSecretBox(masterKey, k.kek.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt master key: %w", err)
|
||||
}
|
||||
|
||||
return &EncryptedKey{
|
||||
Ciphertext: encrypted.Ciphertext,
|
||||
Nonce: encrypted.Nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecryptPrivateKeySecure decrypts a private key and returns it in a SecureBuffer.
|
||||
// Auto-detects cipher based on nonce size (12 for ChaCha20, 24 for XSalsa20).
|
||||
func DecryptPrivateKeySecure(encryptedPrivateKey *EncryptedKey, masterKey *SecureBuffer) (*SecureBuffer, error) {
|
||||
if masterKey == nil || masterKey.buffer == nil {
|
||||
return nil, fmt.Errorf("master key is nil or destroyed")
|
||||
}
|
||||
|
||||
// Decrypt private key (auto-detect cipher based on nonce size)
|
||||
privateKeyBytes, err := DecryptWithAlgorithm(encryptedPrivateKey.Ciphertext, encryptedPrivateKey.Nonce, masterKey.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
// Store in secure memory
|
||||
privateKey, err := NewSecureBuffer(privateKeyBytes)
|
||||
if err != nil {
|
||||
ClearBytes(privateKeyBytes)
|
||||
return nil, fmt.Errorf("failed to create secure buffer for private key: %w", err)
|
||||
}
|
||||
|
||||
// Clear temporary bytes
|
||||
ClearBytes(privateKeyBytes)
|
||||
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
// WithSecureBuffer provides a callback pattern for temporary use of secure data
|
||||
// The buffer is automatically destroyed after the callback returns
|
||||
func WithSecureBuffer(data []byte, fn func(*SecureBuffer) error) error {
|
||||
buf, err := NewSecureBuffer(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer buf.Destroy()
|
||||
|
||||
return fn(buf)
|
||||
}
|
||||
|
||||
// CopyToSecure copies regular bytes into a new SecureBuffer and clears the source
|
||||
func CopyToSecure(data []byte) (*SecureBuffer, error) {
|
||||
buf, err := NewSecureBuffer(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Clear the source data
|
||||
ClearBytes(data)
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue