package store import ( "context" "database/sql" "errors" "fmt" "strings" "time" "golang.org/x/crypto/ssh" "github.com/barerepo/server/internal/gitx" "github.com/barerepo/server/internal/repo" ) var ( ErrNotFound = errors.New("not found") ErrTaken = errors.New("already taken") ) type Account struct { Name string Admin bool Created time.Time } type PubKey struct { ID int64 Account string Fingerprint string Algo string Blob string // the full authorized_keys line body, without options Comment string Created time.Time LastUsed time.Time // Retired is when the key stopped granting access, and the zero time means it still does. Retired time.Time } // ParsePubKey reads authorized_keys form and rejects a private key pasted by mistake. 45.4. func ParsePubKey(raw string) (algo, blob, comment string, fingerprint string, err error) { raw = strings.TrimSpace(raw) if strings.Contains(raw, "PRIVATE KEY") { return "", "", "", "", errors.New("that is a private key. paste the .pub file instead, and treat the key you pasted as compromised") } key, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(raw)) if err != nil { return "", "", "", "", errors.New("that does not parse as an ssh public key") } switch key.Type() { case ssh.KeyAlgoED25519, ssh.KeyAlgoECDSA256, ssh.KeyAlgoECDSA384, ssh.KeyAlgoECDSA521, ssh.KeyAlgoRSA: default: return "", "", "", "", fmt.Errorf("unsupported key type %s", key.Type()) } line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(key))) fields := strings.Fields(line) return fields[0], fields[1], comment, ssh.FingerprintSHA256(key), nil } // CheckNewAccount refuses a taken name before the challenge, not after the person has signed. func (db *DB) CheckNewAccount(ctx context.Context, name, rawKey string) error { if !gitx.ValidName(name) { if gitx.Reserved(name) { // A name a page already uses is not available, and why is nobody else's business. return fmt.Errorf("the name %q is %w", name, ErrTaken) } return fmt.Errorf("a name is 1 to 39 characters of a-z, 0-9 and -, and starts with a letter or digit") } if _, err := db.Account(ctx, name); err == nil { return fmt.Errorf("the name %q is %w", name, ErrTaken) } _, _, _, fp, err := ParsePubKey(rawKey) if err != nil { return err } var n int if err := db.QueryRowContext(ctx, `SELECT count(*) FROM pubkeys WHERE fingerprint = ?`, fp).Scan(&n); err != nil { return err } if n > 0 { return fmt.Errorf("that key is %w by another account", ErrTaken) } return nil } // CreateAccount writes account and first key in one transaction, since neither works alone. func (db *DB) CreateAccount(ctx context.Context, name, rawKey string, admin bool) (*Account, error) { if !gitx.ValidName(name) { if gitx.Reserved(name) { // The same answer as a taken name, because they are the same answer to the visitor. return nil, fmt.Errorf("the name %q is %w", name, ErrTaken) } return nil, fmt.Errorf("a name is 1 to 39 characters of a-z, 0-9 and -, and starts with a letter or digit") } algo, blob, comment, fp, err := ParsePubKey(rawKey) if err != nil { return nil, err } now := now() tx, err := db.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback() _, err = tx.ExecContext(ctx, `INSERT INTO accounts (name, admin, created_at) VALUES (?, ?, ?)`, name, boolInt(admin), now.Unix()) if err != nil { if isUnique(err) { return nil, fmt.Errorf("the name %q is %w", name, ErrTaken) } return nil, err } _, err = tx.ExecContext(ctx, `INSERT INTO pubkeys (account, fingerprint, algo, blob, comment, created_at) VALUES (?, ?, ?, ?, ?, ?)`, name, fp, algo, blob, comment, now.Unix()) if err != nil { if isUnique(err) { return nil, fmt.Errorf("that key is %w by another account", ErrTaken) } return nil, err } if err := tx.Commit(); err != nil { return nil, err } // The file that grants ssh access is a copy of this table, so it is rewritten with it. if err := db.SyncAuthorizedKeys(ctx); err != nil { return nil, err } return &Account{Name: name, Admin: admin, Created: now}, nil } func (db *DB) Account(ctx context.Context, name string) (*Account, error) { var a Account var admin int var created int64 err := db.QueryRowContext(ctx, `SELECT name, admin, created_at FROM accounts WHERE name = ?`, name). Scan(&a.Name, &admin, &created) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } a.Admin = admin == 1 a.Created = time.Unix(created, 0) return &a, nil } // AddKey stores another key, which chapter 10 urges, because losing every key loses the account. func (db *DB) AddKey(ctx context.Context, account, rawKey string) (*PubKey, error) { algo, blob, comment, fp, err := ParsePubKey(rawKey) if err != nil { return nil, err } now := now() // RETURNING rather than LastInsertId, which PostgreSQL does not have. var id int64 err = db.QueryRowContext(ctx, `INSERT INTO pubkeys (account, fingerprint, algo, blob, comment, created_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id`, account, fp, algo, blob, comment, now.Unix()).Scan(&id) if err != nil { if isUnique(err) { return nil, fmt.Errorf("that key is %w", ErrTaken) } return nil, err } if err := db.SyncAuthorizedKeys(ctx); err != nil { return nil, err } return &PubKey{ID: id, Account: account, Fingerprint: fp, Algo: algo, Blob: blob, Comment: comment, Created: now}, nil } // Keys lists an account's keys, oldest first. func (db *DB) Keys(ctx context.Context, account string) ([]PubKey, error) { rows, err := db.QueryContext(ctx, `SELECT id, account, fingerprint, algo, blob, comment, created_at, last_used FROM pubkeys WHERE account = ? AND retired_at IS NULL ORDER BY created_at, id`, account) if err != nil { return nil, err } defer rows.Close() var out []PubKey for rows.Next() { var k PubKey var created int64 var used sql.NullInt64 if err := rows.Scan(&k.ID, &k.Account, &k.Fingerprint, &k.Algo, &k.Blob, &k.Comment, &created, &used); err != nil { return nil, err } k.Created = time.Unix(created, 0) if used.Valid { k.LastUsed = time.Unix(used.Int64, 0) } out = append(out, k) } return out, rows.Err() } // SSHDir is where authorized_keys is written, and empty writes none. Set once at start. var SSHDir string // SSHBin is the program authorized_keys forces, which is this one. var SSHBin string // SignersPath is where the keys a commit signature is checked against are written, and empty writes none. var SignersPath string // SyncAuthorizedKeys rewrites the file from the database, which is the thing that grants access. func (db *DB) SyncAuthorizedKeys(ctx context.Context) error { if SSHDir == "" && SignersPath == "" { return nil } // Every key ever published, because a retired one still vouches for what it signed while it was live. if SignersPath != "" { all, err := db.AllKeysEverPublished(ctx) if err != nil { return err } signers := make([]repo.AuthKey, 0, len(all)) for _, k := range all { signers = append(signers, repo.AuthKey{Account: k.Account, Algo: k.Algo, Blob: k.Blob, Retired: k.Retired}) } if err := repo.WriteAllowedSigners(SignersPath, signers); err != nil { return err } } if SSHDir == "" { return nil } // Only the live keys open a door, so a retired one is gone from authorized_keys the moment it retires. keys, err := db.AllKeys(ctx) if err != nil { return err } out := make([]repo.AuthKey, 0, len(keys)) for _, k := range keys { out = append(out, repo.AuthKey{Account: k.Account, Algo: k.Algo, Blob: k.Blob}) } return repo.WriteAuthorizedKeys(SSHDir, SSHBin, out) } // AllKeys lists every key that still grants access, for writing authorized_keys. func (db *DB) AllKeys(ctx context.Context) ([]PubKey, error) { return db.allKeys(ctx, true) } // AllKeysEverPublished lists retired keys too, because a retired key still vouches for what it signed. func (db *DB) AllKeysEverPublished(ctx context.Context) ([]PubKey, error) { return db.allKeys(ctx, false) } func (db *DB) allKeys(ctx context.Context, liveOnly bool) ([]PubKey, error) { where := "" if liveOnly { where = " WHERE retired_at IS NULL" } rows, err := db.QueryContext(ctx, `SELECT id, account, fingerprint, algo, blob, comment, created_at, last_used, retired_at FROM pubkeys`+where+` ORDER BY account, id`) if err != nil { return nil, err } defer rows.Close() var out []PubKey for rows.Next() { var k PubKey var created int64 var used, retired sql.NullInt64 if err := rows.Scan(&k.ID, &k.Account, &k.Fingerprint, &k.Algo, &k.Blob, &k.Comment, &created, &used, &retired); err != nil { return nil, err } k.Created = time.Unix(created, 0) if used.Valid { k.LastUsed = time.Unix(used.Int64, 0) } if retired.Valid { k.Retired = time.Unix(retired.Int64, 0) } out = append(out, k) } return out, rows.Err() } // DeleteKey refuses the last one, because an account with no key cannot sign in or recover. func (db *DB) DeleteKey(ctx context.Context, account string, id int64) error { // The count belongs inside the delete, or two keys removed at once are both the one that stays. res, err := db.ExecContext(ctx, `UPDATE pubkeys SET retired_at = ? WHERE account = ? AND id = ? AND retired_at IS NULL AND (SELECT count(*) FROM pubkeys WHERE account = ? AND retired_at IS NULL) > 1`, now().Unix(), account, id, account) if err != nil { return err } if n, _ := res.RowsAffected(); n > 0 { return db.SyncAuthorizedKeys(ctx) } // Nothing went, so read which of the two reasons to give. var left int if err := db.QueryRowContext(ctx, `SELECT count(*) FROM pubkeys WHERE account = ? AND retired_at IS NULL`, account).Scan(&left); err != nil { return err } if left <= 1 { return errors.New("this is your only key. add another one first, or the account becomes unreachable") } return ErrNotFound } func boolInt(b bool) int { if b { return 1 } return 0 } // now is the one clock this package reads, so a test can hold it still. var now = time.Now // isUnique reads either dialect's wording, because database/sql exposes no shared value. func isUnique(err error) bool { if err == nil { return false } msg := err.Error() return strings.Contains(msg, "UNIQUE constraint failed") || // sqlite strings.Contains(msg, "SQLSTATE 23505") || // postgres strings.Contains(msg, "duplicate key value") } // ReposOf lists the repository names in an account's namespace. func (db *DB) ReposOf(ctx context.Context, owner string) ([]string, error) { rows, err := db.QueryContext(ctx, `SELECT name FROM repos WHERE owner = ? ORDER BY name`, owner) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return nil, err } out = append(out, n) } return out, rows.Err() } // TouchKey records a sign-in, which the keys page shows so a lost key can be spotted. 32.5. func (db *DB) TouchKey(ctx context.Context, id int64) error { _, err := db.ExecContext(ctx, `UPDATE pubkeys SET last_used = ? WHERE id = ? AND retired_at IS NULL`, now().Unix(), id) return err } // Accounts lists every account name. func (db *DB) Accounts(ctx context.Context) ([]string, error) { rows, err := db.QueryContext(ctx, `SELECT name FROM accounts ORDER BY name`) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return nil, err } out = append(out, n) } return out, rows.Err() } // AccountsExist answers which of these names are accounts, in one query, so a page can link only what resolves. func (db *DB) AccountsExist(ctx context.Context, names []string) (map[string]bool, error) { out := map[string]bool{} if len(names) == 0 { return out, nil } // A git author name is whatever the committer set locally, so most of these will not be accounts. holes := make([]string, len(names)) args := make([]any, len(names)) for i, n := range names { holes[i] = "?" args[i] = n } rows, err := db.QueryContext(ctx, `SELECT name FROM accounts WHERE name IN (`+strings.Join(holes, ",")+`)`, args...) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return nil, err } out[n] = true } return out, rows.Err() } // Move leaves a redirect behind forever, because a 404 breaks every clone and link. 44.2. func (db *DB) Move(ctx context.Context, oldOwner, oldName, newOwner, newName string) error { if !gitx.ValidName(newOwner) || !gitx.ValidRepoName(newName) { return fmt.Errorf("%q is not a usable name", newOwner+"/"+newName) } if _, err := db.Account(ctx, newOwner); err != nil { return fmt.Errorf("there is no account named %s", newOwner) } tx, err := db.Begin(ctx) if err != nil { return err } defer tx.Rollback() var taken int if err := tx.QueryRowContext(ctx, `SELECT count(*) FROM repos WHERE owner = ? AND name = ?`, newOwner, newName).Scan(&taken); err != nil { return err } if taken > 0 { return fmt.Errorf("%s/%s already exists", newOwner, newName) } // The old name is never freed, or it inherits the old identity's threads. Chapter 21.2. if _, err := tx.ExecContext(ctx, `UPDATE repos SET owner = ?, name = ? WHERE owner = ? AND name = ?`, newOwner, newName, oldOwner, oldName); err != nil { return err } if _, err := tx.ExecContext(ctx, `INSERT INTO redirects (old_owner, old_name, new_owner, new_name, created_at) VALUES (?, ?, ?, ?, ?)`, oldOwner, oldName, newOwner, newName, now().Unix()); err != nil { return err } // Collapse the two hops, so an old clone follows one redirect and not a chain. if _, err := tx.ExecContext(ctx, `UPDATE redirects SET new_owner = ?, new_name = ? WHERE new_owner = ? AND new_name = ? AND NOT (old_owner = ? AND old_name = ?)`, newOwner, newName, oldOwner, oldName, oldOwner, oldName); err != nil { return err } // Every other table keys on the path, and one left behind is a runner that never builds again. from, to := oldOwner+"/"+oldName, newOwner+"/"+newName for _, q := range []string{ `UPDATE tokens SET scope = ? WHERE scope = ?`, `UPDATE runners SET repo = ? WHERE repo = ?`, `UPDATE jobs SET repo = ? WHERE repo = ?`, `UPDATE events SET repo = ? WHERE repo = ?`, `UPDATE participation SET repo = ? WHERE repo = ?`, `UPDATE webhooks SET repo = ? WHERE repo = ?`, `UPDATE search_docs SET repo = ? WHERE repo = ?`, } { if _, err := tx.ExecContext(ctx, q, to, from); err != nil { return err } } return tx.Commit() } // Forget drops the ownership row, while trash keeps the name claimed for its window. 44.4. func (db *DB) Forget(ctx context.Context, owner, name string) error { tx, err := db.Begin(ctx) if err != nil { return err } defer tx.Rollback() if _, err := tx.ExecContext(ctx, `DELETE FROM repos WHERE owner = ? AND name = ?`, owner, name); err != nil { return err } // Nothing may outlive the repository, or a token, a runner and an inbox line point at a 404. repo := owner + "/" + name for _, q := range []string{ `DELETE FROM runners WHERE repo = ?`, `DELETE FROM tokens WHERE scope = ?`, `DELETE FROM jobs WHERE repo = ?`, `DELETE FROM events WHERE repo = ?`, `DELETE FROM participation WHERE repo = ?`, `DELETE FROM webhooks WHERE repo = ?`, `DELETE FROM search_docs WHERE repo = ?`, } { if _, err := tx.ExecContext(ctx, q, repo); err != nil { return err } } return tx.Commit() }