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
}