Skip to content
quarry Artifact [56ec43ce24]
Login

Artifact [56ec43ce24]

Artifact 56ec43ce243bf8aa3f0b37509aa818ee28708cf39ab64f5c8605094cc280f62e:


package oltp

import (
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"database/sql"
	"encoding/hex"
	"errors"
	"net"
	"strings"
	"time"
)

// RFC 0030, Starting without a credential. A client that holds no
// credential starts a setup with a short code; nothing is filed until a
// signed-in person claims the code. A start is one row, held for
// SetupStartLife and then gone. It names what the client may say (the
// agent name wanted, the kind, its pickup code) and where the start came
// from; nothing in it is secret, and the code itself is kept only as a
// hash.
const setupStartSchema = `
CREATE TABLE IF NOT EXISTS setup_start (
	hash    TEXT PRIMARY KEY,   -- SHA-256 of the normalized short code
	pickup  TEXT NOT NULL,
	name    TEXT NOT NULL,
	kind    TEXT NOT NULL,
	addr    TEXT NOT NULL,
	created TEXT NOT NULL
);`

// SetupStartLife is how long an unclaimed start waits for a person.
const SetupStartLife = 10 * time.Minute

// ErrSetupStartsFull is a start refused because the forge already holds
// as many unclaimed ones as it may.
var ErrSetupStartsFull = errors.New("oltp: too many unclaimed setup starts")

// ErrSetupAddrFull is a start refused because its address already holds
// as many unclaimed ones as one address may.
var ErrSetupAddrFull = errors.New("oltp: this address holds as many unclaimed setup starts as it may")

// AddrGroup is the address a start is counted under: an IPv4 address
// exactly, an IPv6 address as its /64, which is what one subscriber
// holds. Anything that is not an address is kept as it is.
func AddrGroup(addr string) string {
	ip := net.ParseIP(addr)
	if ip == nil {
		return addr
	}
	if ip.To4() != nil {
		return ip.To4().String()
	}
	return ip.Mask(net.CIDRMask(64, 128)).String() + "/64"
}

// SetupStart is one unclaimed start.
type SetupStart struct {
	Pickup  string
	Name    string
	Kind    string
	Addr    string
	Created string
}

// setupCodeLen is the length of a short code: 40 bits in the sync token
// alphabet, which has no I, L, O or U.
const setupCodeLen = 8

// NormalizeSetupCode reads what a person typed as a short code: case and
// dashes and spaces do not matter, and the look-alikes O, I and L are
// read as 0, 1 and 1. ok is false for anything that is not eight
// characters of the alphabet.
func NormalizeSetupCode(s string) (string, bool) {
	var b strings.Builder
	for _, r := range strings.ToUpper(s) {
		switch r {
		case '-', ' ':
			continue
		case 'O':
			r = '0'
		case 'I', 'L':
			r = '1'
		}
		if !strings.ContainsRune("0123456789ABCDEFGHJKMNPQRSTVWXYZ", r) {
			return "", false
		}
		b.WriteRune(r)
	}
	if b.Len() != setupCodeLen {
		return "", false
	}
	return b.String(), true
}

func setupCodeHash(code string) string {
	sum := sha256.Sum256([]byte("setup-start:" + code))
	return hex.EncodeToString(sum[:])
}

// ValidAccountName reports whether name is shaped like an account name:
// the closed alphabet, led by a letter or digit.
func ValidAccountName(name string) bool { return name != "" && validLogin(name) }

// StartSetup records s unclaimed and returns its short code, shown once.
// Lapsed starts are removed first; past max unclaimed ones, or perAddr
// from s's address, it refuses.
func (d *DB) StartSetup(s SetupStart, max, perAddr int, now time.Time) (string, error) {
	tx, err := d.w.Begin()
	if err != nil {
		return "", err
	}
	defer tx.Rollback()
	if _, err := tx.Exec(`DELETE FROM setup_start WHERE created < ?`, setupStartCutoff(now)); err != nil {
		return "", err
	}
	var held int
	if err := tx.QueryRow(`SELECT count(*) FROM setup_start`).Scan(&held); err != nil {
		return "", err
	}
	if held >= max {
		return "", ErrSetupStartsFull
	}
	var mine int
	if err := tx.QueryRow(`SELECT count(*) FROM setup_start WHERE addr = ?`, s.Addr).Scan(&mine); err != nil {
		return "", err
	}
	if mine >= perAddr {
		return "", ErrSetupAddrFull
	}
	s.Created = now.UTC().Format(time.RFC3339)
	for try := 0; try < 8; try++ {
		raw := make([]byte, 5)
		if _, err := rand.Read(raw); err != nil {
			return "", err
		}
		code := syncTokenAlphabet.EncodeToString(raw)
		res, err := tx.Exec(`INSERT OR IGNORE INTO setup_start (hash, pickup, name, kind, addr, created) VALUES (?, ?, ?, ?, ?, ?)`,
			setupCodeHash(code), s.Pickup, s.Name, s.Kind, s.Addr, s.Created)
		if err != nil {
			return "", err
		}
		if n, _ := res.RowsAffected(); n == 1 {
			return code, tx.Commit()
		}
	}
	return "", errors.New("oltp: no free setup code")
}

func setupStartCutoff(now time.Time) string {
	return now.UTC().Add(-SetupStartLife).Format(time.RFC3339)
}

// SetupStartByCode is the unclaimed, unlapsed start a typed code names.
// A malformed code, an unknown one and a lapsed one are all not found.
func (d *DB) SetupStartByCode(typed string, now time.Time) (SetupStart, bool, error) {
	code, ok := NormalizeSetupCode(typed)
	if !ok {
		return SetupStart{}, false, nil
	}
	var s SetupStart
	var hash string
	err := d.db.QueryRow(`SELECT hash, pickup, name, kind, addr, created FROM setup_start WHERE hash = ? AND created >= ?`,
		setupCodeHash(code), setupStartCutoff(now)).Scan(&hash, &s.Pickup, &s.Name, &s.Kind, &s.Addr, &s.Created)
	if errors.Is(err, sql.ErrNoRows) {
		return SetupStart{}, false, nil
	}
	if err != nil {
		return SetupStart{}, false, err
	}
	if subtle.ConstantTimeCompare([]byte(hash), []byte(setupCodeHash(code))) != 1 {
		return SetupStart{}, false, nil
	}
	return s, true, nil
}

// ClaimSetupStart takes the start a typed code names, for good: one
// caller gets it and every other, and every later call, gets not found.
// Discarding a start is the same act with the row thrown away.
func (d *DB) ClaimSetupStart(typed string, now time.Time) (SetupStart, bool, error) {
	code, ok := NormalizeSetupCode(typed)
	if !ok {
		return SetupStart{}, false, nil
	}
	tx, err := d.w.Begin()
	if err != nil {
		return SetupStart{}, false, err
	}
	defer tx.Rollback()
	if _, err := tx.Exec(`DELETE FROM setup_start WHERE created < ?`, setupStartCutoff(now)); err != nil {
		return SetupStart{}, false, err
	}
	var s SetupStart
	err = tx.QueryRow(`SELECT pickup, name, kind, addr, created FROM setup_start WHERE hash = ?`, setupCodeHash(code)).
		Scan(&s.Pickup, &s.Name, &s.Kind, &s.Addr, &s.Created)
	if errors.Is(err, sql.ErrNoRows) {
		return SetupStart{}, false, nil
	}
	if err != nil {
		return SetupStart{}, false, err
	}
	if _, err := tx.Exec(`DELETE FROM setup_start WHERE hash = ?`, setupCodeHash(code)); err != nil {
		return SetupStart{}, false, err
	}
	return s, true, tx.Commit()
}

// RestoreSetupStart puts a claimed start back, as it was, when what the
// claim was to file could not be filed.
func (d *DB) RestoreSetupStart(typed string, s SetupStart) error {
	code, ok := NormalizeSetupCode(typed)
	if !ok {
		return errors.New("oltp: not a setup code")
	}
	_, err := d.w.Exec(`INSERT OR IGNORE INTO setup_start (hash, pickup, name, kind, addr, created) VALUES (?, ?, ?, ?, ?, ?)`,
		setupCodeHash(code), s.Pickup, s.Name, s.Kind, s.Addr, s.Created)
	return err
}

// SetupStartCount is how many unclaimed, unlapsed starts the store holds.
func (d *DB) SetupStartCount(now time.Time) (int, error) {
	var n int
	err := d.db.QueryRow(`SELECT count(*) FROM setup_start WHERE created >= ?`, setupStartCutoff(now)).Scan(&n)
	return n, err
}

// SetupStartRows is every row held, lapsed or not, for a test that looks
// at what the store keeps.
func (d *DB) SetupStartRows() (int, error) {
	var n int
	err := d.db.QueryRow(`SELECT count(*) FROM setup_start`).Scan(&n)
	return n, err
}

// SetupStartLoad is how many unclaimed starts the store holds and how
// many of them came from addr, read without taking the write lock, so a
// start that is refused for want of room costs no write.
func (d *DB) SetupStartLoad(addr string, now time.Time) (total, mine int, err error) {
	err = d.db.QueryRow(`SELECT count(*), coalesce(sum(addr = ?), 0) FROM setup_start WHERE created >= ?`,
		addr, setupStartCutoff(now)).Scan(&total, &mine)
	return total, mine, err
}