~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
626 lines · 17.6 KB Raw History
package pds

import (
	"context"
	"crypto/pbkdf2"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"database/sql"
	"encoding/base32"
	"encoding/hex"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/bluesky-social/indigo/atproto/syntax"
	_ "modernc.org/sqlite"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/oauth"
)

const passwordIterations = 120000

const schemaAccounts = `CREATE TABLE IF NOT EXISTS pds_accounts (
	did TEXT PRIMARY KEY,
	handle TEXT NOT NULL COLLATE NOCASE UNIQUE,
	email TEXT NOT NULL DEFAULT '',
	password_hash BLOB,
	password_salt BLOB,
	password_iter INTEGER NOT NULL DEFAULT 0,
	active INTEGER NOT NULL DEFAULT 1,
	created_at INTEGER NOT NULL
)`

const schemaSessions = `CREATE TABLE IF NOT EXISTS pds_sessions (
	id TEXT PRIMARY KEY,
	did TEXT NOT NULL,
	handle TEXT NOT NULL,
	refresh_jti TEXT NOT NULL UNIQUE,
	app_password INTEGER NOT NULL DEFAULT 0,
	privileged INTEGER NOT NULL DEFAULT 0,
	created_at INTEGER NOT NULL,
	expires_at INTEGER NOT NULL,
	revoked_at INTEGER
)`

const schemaSessionsDID = `CREATE INDEX IF NOT EXISTS pds_sessions_did ON pds_sessions (did)`

const schemaAppPasswords = `CREATE TABLE IF NOT EXISTS pds_app_passwords (
	id TEXT PRIMARY KEY,
	did TEXT NOT NULL,
	name TEXT NOT NULL,
	lookup TEXT NOT NULL,
	password_hash BLOB NOT NULL,
	password_salt BLOB NOT NULL,
	password_iter INTEGER NOT NULL,
	privileged INTEGER NOT NULL DEFAULT 0,
	created_at INTEGER NOT NULL,
	revoked_at INTEGER
)`

const schemaAppPasswordsLookup = `CREATE INDEX IF NOT EXISTS pds_app_passwords_lookup ON pds_app_passwords (lookup)`

type Account struct {
	DID       syntax.DID
	Handle    string
	Email     string
	Active    bool
	CreatedAt time.Time
}

type NewAccount struct {
	DID       syntax.DID
	Handle    string
	Email     string
	Password  string
	CreatedAt time.Time
}

type LegacySession struct {
	ID          string
	DID         syntax.DID
	Handle      string
	RefreshJTI  string
	AppPassword bool
	Privileged  bool
	CreatedAt   time.Time
	ExpiresAt   time.Time
	RevokedAt   *time.Time
}

type AppPassword struct {
	Name       string
	Privileged bool
	CreatedAt  time.Time
}

type accountCredential struct {
	Account
	passwordHash []byte
	passwordSalt []byte
	passwordIter int
}

type Store struct {
	db *sql.DB
}

func NewStore(ctx context.Context, path string) (*Store, error) {
	if path == "" {
		return nil, fmt.Errorf("pds: empty sqlite path")
	}
	var dsn string
	if path == ":memory:" {
		dsn = "file::memory:?cache=shared&_pragma=busy_timeout(5000)"
	} else {
		dir := filepath.Dir(path)
		if dir != "." && dir != "" {
			if err := os.MkdirAll(dir, 0o700); err != nil {
				return nil, fmt.Errorf("pds: creating sqlite directory: %w", err)
			}
		}
		dsn = "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
	}
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		return nil, fmt.Errorf("pds: opening sqlite: %w", err)
	}
	if path == ":memory:" {
		db.SetMaxOpenConns(1)
	} else {
		db.SetMaxOpenConns(4)
	}
	db.SetMaxIdleConns(4)
	s := &Store{db: db}
	if err := s.init(ctx); err != nil {
		db.Close()
		return nil, err
	}
	return s, nil
}

func (s *Store) init(ctx context.Context) error {
	for _, stmt := range []string{
		schemaAccounts,
		schemaSessions,
		schemaSessionsDID,
		schemaAppPasswords,
		schemaAppPasswordsLookup,
		schemaOAuthSessions,
		schemaOAuthSessionsDID,
		schemaOAuthCodes,
		schemaOAuthPARs,
		schemaOAuthTokens,
		schemaOAuthTokensSession,
	} {
		if _, err := s.db.ExecContext(ctx, stmt); err != nil {
			return fmt.Errorf("pds: creating schema: %w", err)
		}
	}
	return nil
}

func (s *Store) Close() error {
	return s.db.Close()
}

func (s *Store) CreateAccount(ctx context.Context, in NewAccount) (*Account, error) {
	did := in.DID.String()
	if _, err := syntax.ParseDID(did); err != nil {
		return nil, fmt.Errorf("%w: %v", ErrUnsupported, err)
	}
	handle := strings.ToLower(strings.TrimSpace(in.Handle))
	if _, err := syntax.ParseHandle(handle); err != nil {
		return nil, fmt.Errorf("%w: %v", ErrUnsupported, err)
	}
	created := in.CreatedAt
	if created.IsZero() {
		created = time.Now().UTC()
	}
	var hash, salt []byte
	iter := 0
	if in.Password != "" {
		var err error
		hash, salt, iter, err = hashPassword(in.Password)
		if err != nil {
			return nil, err
		}
	}
	if existing, err := s.GetAccountByIdentifier(ctx, did); err == nil && existing != nil {
		return nil, ErrAccountExists
	}
	if existing, err := s.GetAccountByIdentifier(ctx, handle); err == nil && existing != nil {
		return nil, ErrHandleTaken
	}
	_, err := s.db.ExecContext(ctx,
		`INSERT INTO pds_accounts (did, handle, email, password_hash, password_salt, password_iter, active, created_at)
		 VALUES (?, ?, ?, ?, ?, ?, 1, ?)`,
		did, handle, in.Email, hash, salt, iter, unixMicro(created))
	if err != nil {
		if strings.Contains(err.Error(), "UNIQUE") {
			return nil, ErrAccountExists
		}
		return nil, fmt.Errorf("pds: creating account: %w", err)
	}
	return &Account{
		DID:       in.DID,
		Handle:    handle,
		Email:     in.Email,
		Active:    true,
		CreatedAt: created,
	}, nil
}

func (s *Store) DeleteAccount(ctx context.Context, did syntax.DID) error {
	_, err := s.db.ExecContext(ctx, `DELETE FROM pds_accounts WHERE did = ?`, did.String())
	if err != nil {
		return fmt.Errorf("pds: deleting account: %w", err)
	}
	return nil
}

func scanAccount(row interface{ Scan(...any) error }) (*Account, error) {
	var didRaw, handle, email string
	var active int
	var created int64
	if err := row.Scan(&didRaw, &handle, &email, &active, &created); err != nil {
		return nil, err
	}
	did, err := syntax.ParseDID(didRaw)
	if err != nil {
		return nil, err
	}
	return &Account{
		DID:       did,
		Handle:    handle,
		Email:     email,
		Active:    active != 0,
		CreatedAt: fromUnixMicro(created),
	}, nil
}

const accountColumns = `did, handle, email, active, created_at`

func (s *Store) GetAccount(ctx context.Context, did syntax.DID) (*Account, error) {
	row := s.db.QueryRowContext(ctx, `SELECT `+accountColumns+` FROM pds_accounts WHERE did = ?`, did.String())
	acct, err := scanAccount(row)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrAccountNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("pds: loading account: %w", err)
	}
	return acct, nil
}

func (s *Store) ListDIDs(ctx context.Context) ([]syntax.DID, error) {
	rows, err := s.db.QueryContext(ctx, `SELECT did FROM pds_accounts ORDER BY did`)
	if err != nil {
		return nil, fmt.Errorf("pds: listing account DIDs: %w", err)
	}
	defer rows.Close()
	var out []syntax.DID
	for rows.Next() {
		var raw string
		if err := rows.Scan(&raw); err != nil {
			return nil, fmt.Errorf("pds: reading an account DID: %w", err)
		}
		did, err := syntax.ParseDID(raw)
		if err != nil {
			continue
		}
		out = append(out, did)
	}
	return out, rows.Err()
}

func (s *Store) GetAccountByIdentifier(ctx context.Context, identifier string) (*Account, error) {
	identifier = strings.TrimSpace(identifier)
	row := s.db.QueryRowContext(ctx,
		`SELECT `+accountColumns+` FROM pds_accounts WHERE did = ? OR handle = ? COLLATE NOCASE`,
		identifier, identifier)
	acct, err := scanAccount(row)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrAccountNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("pds: loading account: %w", err)
	}
	return acct, nil
}

func (s *Store) credentialByIdentifier(ctx context.Context, identifier string) (*accountCredential, error) {
	identifier = strings.TrimSpace(identifier)
	row := s.db.QueryRowContext(ctx,
		`SELECT `+accountColumns+`, password_hash, password_salt, password_iter FROM pds_accounts
		 WHERE did = ? OR handle = ? COLLATE NOCASE`,
		identifier, identifier)
	var didRaw, handle, email string
	var active, iter int
	var created int64
	var hash, salt []byte
	if err := row.Scan(&didRaw, &handle, &email, &active, &created, &hash, &salt, &iter); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrAccountNotFound
		}
		return nil, fmt.Errorf("pds: loading credentials: %w", err)
	}
	did, err := syntax.ParseDID(didRaw)
	if err != nil {
		return nil, err
	}
	return &accountCredential{
		Account: Account{
			DID:       did,
			Handle:    handle,
			Email:     email,
			Active:    active != 0,
			CreatedAt: fromUnixMicro(created),
		},
		passwordHash: hash,
		passwordSalt: salt,
		passwordIter: iter,
	}, nil
}

func (s *Store) VerifyPassword(ctx context.Context, identifier, password string) (*Account, bool, bool, error) {
	cred, err := s.credentialByIdentifier(ctx, identifier)
	if err != nil {
		return nil, false, false, ErrInvalidCredentials
	}
	if !cred.Active {
		return nil, false, false, ErrInvalidCredentials
	}
	if len(cred.passwordHash) > 0 && verifyPassword(password, cred.passwordSalt, cred.passwordHash, cred.passwordIter) {
		acct := cred.Account
		return &acct, false, false, nil
	}
	ok, privileged, err := s.verifyAppPassword(ctx, cred.DID, password)
	if err != nil {
		return nil, false, false, err
	}
	if ok {
		acct := cred.Account
		return &acct, true, privileged, nil
	}
	return nil, false, false, ErrInvalidCredentials
}

func (s *Store) Authenticate(ctx context.Context, identifier, password string) (oauth.Account, error) {
	cred, err := s.credentialByIdentifier(ctx, identifier)
	if err != nil || !cred.Active || len(cred.passwordHash) == 0 {
		return oauth.Account{}, oauth.ErrInvalidCredentials
	}
	if !verifyPassword(password, cred.passwordSalt, cred.passwordHash, cred.passwordIter) {
		return oauth.Account{}, oauth.ErrInvalidCredentials
	}
	return oauth.Account{DID: cred.DID, Handle: cred.Handle}, nil
}

func (s *Store) CreateLegacySession(ctx context.Context, sess LegacySession) error {
	_, err := s.db.ExecContext(ctx,
		`INSERT INTO pds_sessions (id, did, handle, refresh_jti, app_password, privileged, created_at, expires_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
		sess.ID, sess.DID.String(), sess.Handle, sess.RefreshJTI,
		boolInt(sess.AppPassword), boolInt(sess.Privileged),
		unixMicro(sess.CreatedAt), unixMicro(sess.ExpiresAt))
	if err != nil {
		return fmt.Errorf("pds: creating session: %w", err)
	}
	return nil
}

const sessionColumns = `id, did, handle, refresh_jti, app_password, privileged, created_at, expires_at, revoked_at`

func scanLegacySession(row interface{ Scan(...any) error }) (*LegacySession, error) {
	var id, didRaw, handle, refreshJTI string
	var appPassword, privileged int
	var created, expires int64
	var revoked sql.NullInt64
	if err := row.Scan(&id, &didRaw, &handle, &refreshJTI, &appPassword, &privileged, &created, &expires, &revoked); err != nil {
		return nil, err
	}
	did, err := syntax.ParseDID(didRaw)
	if err != nil {
		return nil, err
	}
	return &LegacySession{
		ID:          id,
		DID:         did,
		Handle:      handle,
		RefreshJTI:  refreshJTI,
		AppPassword: appPassword != 0,
		Privileged:  privileged != 0,
		CreatedAt:   fromUnixMicro(created),
		ExpiresAt:   fromUnixMicro(expires),
		RevokedAt:   scanTimePtr(revoked),
	}, nil
}

func (s *Store) GetLegacySession(ctx context.Context, id string) (*LegacySession, error) {
	row := s.db.QueryRowContext(ctx, `SELECT `+sessionColumns+` FROM pds_sessions WHERE id = ?`, id)
	sess, err := scanLegacySession(row)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrSessionNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("pds: loading session: %w", err)
	}
	return sess, nil
}

func (s *Store) GetLegacySessionByRefresh(ctx context.Context, jti string) (*LegacySession, error) {
	row := s.db.QueryRowContext(ctx, `SELECT `+sessionColumns+` FROM pds_sessions WHERE refresh_jti = ?`, jti)
	sess, err := scanLegacySession(row)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrSessionNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("pds: loading session: %w", err)
	}
	return sess, nil
}

func (s *Store) RotateLegacySession(ctx context.Context, id, refreshJTI string, expiresAt time.Time) error {
	res, err := s.db.ExecContext(ctx,
		`UPDATE pds_sessions SET refresh_jti = ?, expires_at = ? WHERE id = ? AND revoked_at IS NULL`,
		refreshJTI, unixMicro(expiresAt), id)
	if err != nil {
		return fmt.Errorf("pds: rotating session: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return err
	}
	if n == 0 {
		return ErrSessionNotFound
	}
	return nil
}

func (s *Store) RevokeLegacySession(ctx context.Context, id string, at time.Time) error {
	_, err := s.db.ExecContext(ctx,
		`UPDATE pds_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`,
		unixMicro(at), id)
	if err != nil {
		return fmt.Errorf("pds: revoking session: %w", err)
	}
	return nil
}

func (s *Store) RevokeLegacySessionByRefresh(ctx context.Context, jti string, at time.Time) error {
	_, err := s.db.ExecContext(ctx,
		`UPDATE pds_sessions SET revoked_at = ? WHERE refresh_jti = ? AND revoked_at IS NULL`,
		unixMicro(at), jti)
	if err != nil {
		return fmt.Errorf("pds: revoking session: %w", err)
	}
	return nil
}

func (s *Store) CreateAppPassword(ctx context.Context, did syntax.DID, name, password string, privileged bool) (*AppPassword, error) {
	name = strings.TrimSpace(name)
	if name == "" || len(name) > 64 {
		return nil, fmt.Errorf("%w: app password name must be 1-64 characters", ErrUnsupported)
	}
	var existing int
	err := s.db.QueryRowContext(ctx,
		`SELECT 1 FROM pds_app_passwords WHERE did = ? AND name = ? AND revoked_at IS NULL`,
		did.String(), name).Scan(&existing)
	if err == nil {
		return nil, ErrAppPasswordExists
	}
	if !errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("pds: checking app password: %w", err)
	}
	hash, salt, iter, err := hashPassword(password)
	if err != nil {
		return nil, err
	}
	now := time.Now().UTC()
	id, err := randomToken(16)
	if err != nil {
		return nil, err
	}
	_, err = s.db.ExecContext(ctx,
		`INSERT INTO pds_app_passwords (id, did, name, lookup, password_hash, password_salt, password_iter, privileged, created_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		id, did.String(), name, appPasswordLookup(password), hash, salt, iter, boolInt(privileged), unixMicro(now))
	if err != nil {
		return nil, fmt.Errorf("pds: creating app password: %w", err)
	}
	return &AppPassword{Name: name, Privileged: privileged, CreatedAt: now}, nil
}

func (s *Store) ListAppPasswords(ctx context.Context, did syntax.DID) ([]AppPassword, error) {
	rows, err := s.db.QueryContext(ctx,
		`SELECT name, privileged, created_at FROM pds_app_passwords
		 WHERE did = ? AND revoked_at IS NULL ORDER BY created_at`, did.String())
	if err != nil {
		return nil, fmt.Errorf("pds: listing app passwords: %w", err)
	}
	defer rows.Close()
	out := []AppPassword{}
	for rows.Next() {
		var name string
		var privileged int
		var created int64
		if err := rows.Scan(&name, &privileged, &created); err != nil {
			return nil, fmt.Errorf("pds: listing app passwords: %w", err)
		}
		out = append(out, AppPassword{Name: name, Privileged: privileged != 0, CreatedAt: fromUnixMicro(created)})
	}
	return out, rows.Err()
}

func (s *Store) RevokeAppPassword(ctx context.Context, did syntax.DID, name string, at time.Time) error {
	res, err := s.db.ExecContext(ctx,
		`UPDATE pds_app_passwords SET revoked_at = ? WHERE did = ? AND name = ? AND revoked_at IS NULL`,
		unixMicro(at), did.String(), name)
	if err != nil {
		return fmt.Errorf("pds: revoking app password: %w", err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return err
	}
	if n == 0 {
		return ErrAppPasswordMissing
	}
	return nil
}

func (s *Store) verifyAppPassword(ctx context.Context, did syntax.DID, password string) (bool, bool, error) {
	var hash, salt []byte
	var iter, privileged int
	var didRaw string
	err := s.db.QueryRowContext(ctx,
		`SELECT did, password_hash, password_salt, password_iter, privileged FROM pds_app_passwords
		 WHERE lookup = ? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1`,
		appPasswordLookup(password)).Scan(&didRaw, &hash, &salt, &iter, &privileged)
	if errors.Is(err, sql.ErrNoRows) {
		return false, false, nil
	}
	if err != nil {
		return false, false, fmt.Errorf("pds: loading app password: %w", err)
	}
	if didRaw != did.String() {
		return false, false, nil
	}
	if !verifyPassword(password, salt, hash, iter) {
		return false, false, nil
	}
	return true, privileged != 0, nil
}

func GenerateAppPassword() (string, error) {
	buf := make([]byte, 12)
	if _, err := rand.Read(buf); err != nil {
		return "", fmt.Errorf("pds: generating app password: %w", err)
	}
	enc := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf))
	return enc[0:5] + "-" + enc[5:10] + "-" + enc[10:15] + "-" + enc[15:20], nil
}

func hashPassword(password string) ([]byte, []byte, int, error) {
	salt := make([]byte, 16)
	if _, err := rand.Read(salt); err != nil {
		return nil, nil, 0, fmt.Errorf("pds: generating salt: %w", err)
	}
	hash, err := pbkdf2.Key(sha256.New, password, salt, passwordIterations, 32)
	if err != nil {
		return nil, nil, 0, fmt.Errorf("pds: hashing password: %w", err)
	}
	return hash, salt, passwordIterations, nil
}

func verifyPassword(password string, salt, expected []byte, iter int) bool {
	if len(expected) == 0 || len(salt) == 0 || iter <= 0 {
		return false
	}
	actual, err := pbkdf2.Key(sha256.New, password, salt, iter, len(expected))
	if err != nil {
		return false
	}
	return subtle.ConstantTimeCompare(actual, expected) == 1
}

func appPasswordLookup(password string) string {
	sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(password))))
	return hex.EncodeToString(sum[:])
}

func randomToken(n int) (string, error) {
	buf := make([]byte, n)
	if _, err := rand.Read(buf); err != nil {
		return "", fmt.Errorf("pds: generating random token: %w", err)
	}
	return hex.EncodeToString(buf), nil
}

func unixMicro(t time.Time) int64 {
	return t.UTC().UnixMicro()
}

func fromUnixMicro(v int64) time.Time {
	return time.UnixMicro(v).UTC()
}

func scanTimePtr(v sql.NullInt64) *time.Time {
	if !v.Valid {
		return nil
	}
	t := fromUnixMicro(v.Int64)
	return &t
}

func boolInt(v bool) int {
	if v {
		return 1
	}
	return 0
}