Files
zeekay cc5f20c11a feat: Go 1.26 stdlib crypto — hkdf, sha3
- OrgDB DEK derivation: raw HMAC → crypto/hkdf.Key (RFC 5869, stdlib)
- security.SHA3_256, SHA3_512 using crypto/sha3 (FIPS 202, stdlib)
- No more x/crypto for key derivation or SHA3

Go 1.26 moved these from x/crypto to stdlib:
  crypto/hkdf, crypto/pbkdf2, crypto/sha3, crypto/mlkem
2026-03-02 14:31:16 -07:00

268 lines
6.9 KiB
Go

package platform
import (
"crypto/hkdf"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/hanzoai/base/core"
)
// OrgDB manages per-org AND per-user SQLite databases.
//
// Directory layout:
//
// {DataDir}/orgs/{orgSlug}/org.db ← org-level shared data
// {DataDir}/orgs/{orgSlug}/users/{userId}/data.db ← per-user PII + keys
//
// Each file is independently encrypted with a unique DEK:
//
// org DEK = HMAC-SHA256(masterKey, orgSlug)
// user DEK = HMAC-SHA256(masterKey, orgSlug + ":" + userId)
//
// Zero data commingling — org data, user PII, and keys are all in separate
// files with separate encryption keys.
type OrgDB struct {
app core.App
masterKey string
mu sync.RWMutex
dbs map[string]string // key → db path
}
func NewOrgDB(app core.App, masterKey string) *OrgDB {
return &OrgDB{
app: app,
masterKey: masterKey,
dbs: make(map[string]string),
}
}
// OrgsDir returns the base directory for all org databases.
func (t *OrgDB) OrgsDir() string {
return filepath.Join(t.app.DataDir(), "orgs")
}
// validateSlug rejects slugs containing path traversal characters.
func validateSlug(s string) error {
if s == "" {
return fmt.Errorf("slug cannot be empty")
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return fmt.Errorf("slug contains invalid character %q", c)
}
}
if s == "." || s == ".." {
return fmt.Errorf("slug cannot be . or ..")
}
return nil
}
// --- Org-level database ---
// OrgDir returns the directory for an org. Validates slug to prevent path traversal.
func (t *OrgDB) OrgDir(orgSlug string) string {
if err := validateSlug(orgSlug); err != nil {
return filepath.Join(t.OrgsDir(), "_invalid")
}
return filepath.Join(t.OrgsDir(), orgSlug)
}
// OrgDBPath returns the org-level SQLite database path.
func (t *OrgDB) OrgDBPath(orgSlug string) string {
return filepath.Join(t.OrgDir(orgSlug), "org.db")
}
// OrgDEK derives the org-level data encryption key using HKDF (RFC 5869).
// Go 1.26+ stdlib crypto/hkdf — proper key derivation, not raw HMAC.
func (t *OrgDB) OrgDEK(orgSlug string) string {
if t.masterKey == "" {
return ""
}
key, _ := hkdf.Key(sha256.New, []byte(t.masterKey), []byte(orgSlug), "org-dek", 32)
return hex.EncodeToString(key)
}
// ProvisionOrg creates the org directory and org-level database.
func (t *OrgDB) ProvisionOrg(orgSlug string) (string, error) {
if err := validateSlug(orgSlug); err != nil {
return "", fmt.Errorf("invalid org slug: %w", err)
}
dir := t.OrgDir(orgSlug)
if err := os.MkdirAll(dir, 0700); err != nil {
return "", fmt.Errorf("create org dir %q: %w", dir, err)
}
// Create users subdirectory
if err := os.MkdirAll(filepath.Join(dir, "users"), 0700); err != nil {
return "", fmt.Errorf("create org users dir: %w", err)
}
dbPath := t.OrgDBPath(orgSlug)
t.mu.Lock()
t.dbs["org:"+orgSlug] = dbPath
t.mu.Unlock()
return dbPath, nil
}
// --- Per-user database ---
// UserDir returns the directory for a specific user within an org.
func (t *OrgDB) UserDir(orgSlug, userId string) string {
if err := validateSlug(userId); err != nil {
return filepath.Join(t.OrgDir(orgSlug), "users", "_invalid")
}
return filepath.Join(t.OrgDir(orgSlug), "users", userId)
}
// UserDBPath returns the per-user SQLite database path.
func (t *OrgDB) UserDBPath(orgSlug, userId string) string {
return filepath.Join(t.UserDir(orgSlug, userId), "data.db")
}
// UserDEK derives the per-user data encryption key using HKDF (RFC 5869).
// Different from the org DEK — user PII gets a user-specific key.
func (t *OrgDB) UserDEK(orgSlug, userId string) string {
if t.masterKey == "" {
return ""
}
key, _ := hkdf.Key(sha256.New, []byte(t.masterKey), []byte(orgSlug+":"+userId), "user-dek", 32)
return hex.EncodeToString(key)
}
// ProvisionUser creates the per-user directory and database.
func (t *OrgDB) ProvisionUser(orgSlug, userId string) (string, error) {
if err := validateSlug(orgSlug); err != nil {
return "", fmt.Errorf("invalid org slug: %w", err)
}
if err := validateSlug(userId); err != nil {
return "", fmt.Errorf("invalid user id: %w", err)
}
dir := t.UserDir(orgSlug, userId)
if err := os.MkdirAll(dir, 0700); err != nil {
return "", fmt.Errorf("create user dir %q: %w", dir, err)
}
dbPath := t.UserDBPath(orgSlug, userId)
t.mu.Lock()
t.dbs["user:"+orgSlug+":"+userId] = dbPath
t.mu.Unlock()
return dbPath, nil
}
// --- Lookup ---
// GetOrgDBPath returns the database path for an existing org.
func (t *OrgDB) GetOrgDBPath(orgSlug string) (string, bool) {
t.mu.RLock()
if p, ok := t.dbs["org:"+orgSlug]; ok {
t.mu.RUnlock()
return p, true
}
t.mu.RUnlock()
dbPath := t.OrgDBPath(orgSlug)
if _, err := os.Stat(dbPath); err == nil {
t.mu.Lock()
t.dbs["org:"+orgSlug] = dbPath
t.mu.Unlock()
return dbPath, true
}
return "", false
}
// GetUserDBPath returns the database path for an existing user.
func (t *OrgDB) GetUserDBPath(orgSlug, userId string) (string, bool) {
key := "user:" + orgSlug + ":" + userId
t.mu.RLock()
if p, ok := t.dbs[key]; ok {
t.mu.RUnlock()
return p, true
}
t.mu.RUnlock()
dbPath := t.UserDBPath(orgSlug, userId)
if _, err := os.Stat(dbPath); err == nil {
t.mu.Lock()
t.dbs[key] = dbPath
t.mu.Unlock()
return dbPath, true
}
return "", false
}
// --- Lifecycle ---
// DeleteUser removes a user's database directory.
func (t *OrgDB) DeleteUser(orgSlug, userId string) error {
t.mu.Lock()
delete(t.dbs, "user:"+orgSlug+":"+userId)
t.mu.Unlock()
return os.RemoveAll(t.UserDir(orgSlug, userId))
}
// DeleteOrg removes an org's entire directory (including all user databases).
func (t *OrgDB) DeleteOrg(orgSlug string) error {
t.mu.Lock()
// Remove all cached paths for this org
for k := range t.dbs {
if len(k) > 4 && k[:4] == "org:" && k[4:] == orgSlug {
delete(t.dbs, k)
}
if len(k) > 5+len(orgSlug) && k[:5+len(orgSlug)] == "user:"+orgSlug+":" {
delete(t.dbs, k)
}
}
t.mu.Unlock()
return os.RemoveAll(t.OrgDir(orgSlug))
}
// ListOrgs returns all provisioned org slugs.
func (t *OrgDB) ListOrgs() ([]string, error) {
entries, err := os.ReadDir(t.OrgsDir())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var slugs []string
for _, e := range entries {
if e.IsDir() {
if _, err := os.Stat(t.OrgDBPath(e.Name())); err == nil {
slugs = append(slugs, e.Name())
}
}
}
return slugs, nil
}
// ListUsers returns all provisioned user IDs within an org.
func (t *OrgDB) ListUsers(orgSlug string) ([]string, error) {
usersDir := filepath.Join(t.OrgDir(orgSlug), "users")
entries, err := os.ReadDir(usersDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var ids []string
for _, e := range entries {
if e.IsDir() {
if _, err := os.Stat(t.UserDBPath(orgSlug, e.Name())); err == nil {
ids = append(ids, e.Name())
}
}
}
return ids, nil
}