// synctoken.go is the push credential a person can actually hold.
//
// The sync protocol needs the server to recompute
// SHA1(project-code/user/password) for whatever the client types into
// its remote (RFC §6.3). For a while this forge derived that from the
// account password, which was wrong twice over: it planted a fast-hash
// derivative of everybody's password in a database whose whole point
// is that argon2id makes a stolen copy worthless, and it silently
// evicted any purpose-made credential the moment its owner signed in.
//
// So the value behind a push is now a random token, minted on request,
// shown once, revocable on its own, and belonging to no other system.
// It is kept SEALED rather than derived-and-forgotten (secret.go),
// which is the one deliberate trade here: a reversible secret at rest,
// scoped to push, in exchange for deriving per-repository secrets on
// demand. That is what ends the staleness — a repository created after
// the token was minted, or a grant made this morning, needs no
// re-issue, no second sign-in and no admin. A credential that quietly
// goes out of date is the exact shape that locked this forge's
// twenty-three repositories away from their own owners.
package oltp
import (
"crypto/rand"
"database/sql"
"encoding/base32"
"errors"
"strings"
"time"
"craftdesign.group/quarry/internal/xfer"
)
// syncSecret is RFC §6.3's derivation, named once here so the store and
// the protocol cannot drift apart.
func syncSecret(projectCode, user, token string) string {
return xfer.SharedSecret(projectCode, user, token)
}
const syncTokenSchema = `
CREATE TABLE IF NOT EXISTS sync_token (
user TEXT PRIMARY KEY,
sealed TEXT NOT NULL, -- the token itself, sealed (secret.go)
created TEXT NOT NULL
);`
// syncTokenAlphabet is Crockford-ish base32 without padding: no case
// question, and none of the pairs a person mistypes when copying a
// credential out of a page and into a terminal.
var syncTokenAlphabet = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
// newSyncToken returns 160 bits in groups of five, which is enough that
// the derived SHA1 is not worth attacking and short enough to paste.
func newSyncToken() (string, error) {
raw := make([]byte, 20)
if _, err := rand.Read(raw); err != nil {
return "", err
}
s := syncTokenAlphabet.EncodeToString(raw)
var b strings.Builder
for i, r := range s {
if i > 0 && i%4 == 0 {
b.WriteByte('-')
}
b.WriteRune(r)
}
return b.String(), nil
}
// MintSyncToken issues a push token, replacing any the account holds,
// and returns it the ONLY time it is ever readable.
func (d *DB) MintSyncToken(user string) (string, error) {
if user == "" {
return "", errors.New("oltp: empty account name")
}
token, err := newSyncToken()
if err != nil {
return "", err
}
sealed, err := d.seal.seal(token)
if err != nil {
return "", err
}
if _, err := d.db.Exec(`INSERT OR REPLACE INTO sync_token (user, sealed, created)
VALUES (?, ?, ?)`, user, sealed, time.Now().UTC().Format(time.RFC3339)); err != nil {
return "", err
}
return token, nil
}
// RevokeSyncToken drops the account's push token. Pushes stop at once,
// because nothing is cached: every push unseals afresh.
func (d *DB) RevokeSyncToken(user string) error {
_, err := d.db.Exec(`DELETE FROM sync_token WHERE user = ?`, user)
return err
}
// SyncTokenIssued reports when the account's token was minted, without
// revealing it. This is what a page shows: that a credential exists and
// how old it is, never its value.
func (d *DB) SyncTokenIssued(user string) (string, bool) {
var created string
err := d.db.QueryRow(`SELECT created FROM sync_token WHERE user = ?`, user).Scan(&created)
return created, err == nil
}
// syncToken unseals the account's push token, or reports that it holds
// none.
func (d *DB) syncToken(user string) (string, bool) {
var sealed string
err := d.db.QueryRow(`SELECT sealed FROM sync_token WHERE user = ?`, user).Scan(&sealed)
if errors.Is(err, sql.ErrNoRows) || err != nil {
return "", false
}
token, err := d.seal.open(sealed)
if err != nil {
return "", false
}
return token, true
}