// 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 (
"encoding/base32"
"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)
}
// 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)
// MintSyncToken adds a push token named for the day and returns it the
// ONLY time it is ever readable. It adds rather than replaces (RFC
// 0021): replacing is what broke one session's pushes whenever another
// session on the same account minted.
func (d *DB) MintSyncToken(user string) (string, error) {
value, _, err := d.MintToken(user, TokenSpec{
Name: "push " + time.Now().UTC().Format("2006-01-02"),
Scopes: []string{ScopeSync},
})
return value, err
}
// RevokeSyncToken revokes every live sync-scoped token of the account.
// Pushes stop at once, because nothing is cached: every push unseals
// afresh.
func (d *DB) RevokeSyncToken(user string) error {
for _, t := range d.liveSyncTokens(user, time.Now()) {
if err := d.RevokeToken(user, t.ID); err != nil {
return err
}
}
return nil
}
// SyncTokenIssued reports when the account's newest live push token
// was minted, without revealing it: that a credential exists and how
// old it is, never its value.
func (d *DB) SyncTokenIssued(user string) (string, bool) {
toks := d.liveSyncTokens(user, time.Now())
if len(toks) == 0 {
return "", false
}
newest := toks[0].Created
for _, t := range toks[1:] {
if t.Created > newest {
newest = t.Created
}
}
return newest, true
}
// VerifySyncToken reports whether token is one of user's live push
// tokens.
//
// This is the credential a sync client already holds; it signs the
// login card on every round after the first. A clone's FIRST round
// carries no card — the client cannot sign one before the server has
// told it the project code — so both fossil and zit fall back to HTTP
// Basic, and the only thing they have to put in Basic's password slot
// is this token. The read lane therefore has to recognise it there, or
// a private repository stays unclonable by the token its owner minted
// for exactly that (2bb5659e).
func (d *DB) VerifySyncToken(user, token string) bool {
_, ok := d.ResolveToken(user, token, ScopeSync)
return ok
}