package console // Door keys, storage half (#175 P7). The console's database is the // authority for which door keys an app has; the bucket only ever holds a // signed projection of these rows (apidoorkeys.go). Only a key's sha256 is // stored, never its secret. // // Each app has a revision counter, bumped in the same transaction as every // insert and revoke, so a published record's revision always names exactly // one key set. DoorKeyAdvanceRevision moves it forward past a revision the // bucket already carries, which keeps revisions monotonic across a restored // database. import ( "context" "database/sql" "errors" "fmt" "os" "time" "github.com/carlosframework/platform/internal/doorkey" ) // errDoorKeysFull is DoorKeyInsert's refusal when the app already has // doorkey.MaxKeys live keys. var errDoorKeysFull = errors.New("console: door keys full") // migrateDoorKeys creates the door-key tables. Additive and idempotent, // called from Store.migrate. func (s *Store) migrateDoorKeys() error { for _, stmt := range []string{ `CREATE TABLE IF NOT EXISTS door_keys ( account_id INTEGER NOT NULL, app TEXT NOT NULL, id TEXT NOT NULL, sha256 TEXT NOT NULL, label TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, revoked_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (account_id, app, id) );`, `CREATE TABLE IF NOT EXISTS door_key_revisions ( account_id INTEGER NOT NULL, app TEXT NOT NULL, revision INTEGER NOT NULL, PRIMARY KEY (account_id, app) );`, } { if _, err := s.db.Exec(stmt); err != nil { return err } } return nil } // bumpDoorKeyRevision adds one to the app's counter inside tx, creating the // row at 1, and returns the new revision. func bumpDoorKeyRevision(tx *sql.Tx, accountID int64, app string) (uint64, error) { if _, err := tx.Exec(`INSERT INTO door_key_revisions (account_id, app, revision) VALUES (?, ?, 1) ON CONFLICT (account_id, app) DO UPDATE SET revision = revision + 1`, accountID, app); err != nil { return 0, err } var rev uint64 err := tx.QueryRow(`SELECT revision FROM door_key_revisions WHERE account_id = ? AND app = ?`, accountID, app).Scan(&rev) return rev, err } // DoorKeyInsert stores a newly minted key's digest and bumps the app's // revision in the same transaction. It refuses with errDoorKeysFull when the // app already has doorkey.MaxKeys live keys. func (s *Store) claimRawDoorKeyInsert(accountID int64, app string, e doorkey.Entry) (uint64, error) { tx, err := s.db.Begin() if err != nil { return 0, err } defer tx.Rollback() var live int if err := tx.QueryRow(`SELECT COUNT(*) FROM door_keys WHERE account_id = ? AND app = ? AND revoked_at = 0`, accountID, app).Scan(&live); err != nil { return 0, err } if live >= doorkey.MaxKeys { return 0, errDoorKeysFull } created := e.CreatedAt if created.IsZero() { created = time.Now() } if _, err := tx.Exec(`INSERT INTO door_keys (account_id, app, id, sha256, label, created_at) VALUES (?, ?, ?, ?, ?, ?)`, accountID, app, e.ID, e.SHA256, e.Label, created.UnixNano()); err != nil { return 0, err } rev, err := bumpDoorKeyRevision(tx, accountID, app) if err != nil { return 0, err } return rev, tx.Commit() } // DoorKeyRevoke marks one live key revoked and bumps the app's revision in // the same transaction. An unknown or already revoked id is os.ErrNotExist. func (s *Store) claimRawDoorKeyRevoke(accountID int64, app, id string) (uint64, error) { tx, err := s.db.Begin() if err != nil { return 0, err } defer tx.Rollback() res, err := tx.Exec(`UPDATE door_keys SET revoked_at = ? WHERE account_id = ? AND app = ? AND id = ? AND revoked_at = 0`, time.Now().UnixNano(), accountID, app, id) if err != nil { return 0, err } if n, err := res.RowsAffected(); err != nil { return 0, err } else if n == 0 { return 0, os.ErrNotExist } rev, err := bumpDoorKeyRevision(tx, accountID, app) if err != nil { return 0, err } return rev, tx.Commit() } // revokeAllDoorKeys revokes every live key the account holds under app inside // the caller's transaction, bumping the revision when it revoked any. It is // for an app name that stops belonging to an app (PurgeAppRow, MoveApp): a // key left live there would let a later app of the same name be called with // a key nobody minted for it. func revokeAllDoorKeys(tx *sql.Tx, accountID int64, app string) error { res, err := tx.Exec(`UPDATE door_keys SET revoked_at = ? WHERE account_id = ? AND app = ? AND revoked_at = 0`, time.Now().UnixNano(), accountID, app) if err != nil { return err } if n, err := res.RowsAffected(); err != nil || n == 0 { return err } _, err = bumpDoorKeyRevision(tx, accountID, app) return err } // DoorKeysLive reads the app's live keys and its revision in one // transaction, so the two always describe the same key set. An app with no // revision row answers no keys at revision 0. func (s *Store) claimRawDoorKeysLive(accountID int64, app string) ([]doorkey.Entry, uint64, error) { tx, err := s.db.Begin() if err != nil { return nil, 0, err } defer tx.Rollback() var rev uint64 err = tx.QueryRow(`SELECT revision FROM door_key_revisions WHERE account_id = ? AND app = ?`, accountID, app).Scan(&rev) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, 0, err } rows, err := tx.Query(`SELECT id, sha256, label, created_at FROM door_keys WHERE account_id = ? AND app = ? AND revoked_at = 0 ORDER BY created_at, id`, accountID, app) if err != nil { return nil, 0, err } defer rows.Close() keys := []doorkey.Entry{} for rows.Next() { var e doorkey.Entry var created int64 if err := rows.Scan(&e.ID, &e.SHA256, &e.Label, &created); err != nil { return nil, 0, err } e.CreatedAt = time.Unix(0, created).UTC() keys = append(keys, e) } if err := rows.Err(); err != nil { return nil, 0, err } return keys, rev, tx.Commit() } // DoorKeyApp is one app the re-sign loop publishes. type DoorKeyApp struct { AccountID int64 Sqid, App string } // DoorKeyApps lists every app with a revision row: every app that has ever // had a key minted, including one whose keys are now all revoked, because // its empty record still has to stay fresh. func (s *Store) DoorKeyApps() ([]DoorKeyApp, error) { rows, err := s.db.Query(`SELECT r.account_id, a.sqid, r.app FROM door_key_revisions r JOIN accounts a ON a.id = r.account_id ORDER BY a.sqid, r.app`) if err != nil { return nil, err } defer rows.Close() var out []DoorKeyApp for rows.Next() { var d DoorKeyApp if err := rows.Scan(&d.AccountID, &d.Sqid, &d.App); err != nil { return nil, err } out = append(out, d) } return out, rows.Err() } // DoorKeyAdvanceRevision sets the app's revision to past+1 when it is below // past, and returns the resulting revision. The publisher calls it when the // bucket carries a verified revision above the database's (a restore, or a // second writer), so the next record it signs is above anything a box may // already have admitted. func (s *Store) claimRawDoorKeyAdvanceRevision(accountID int64, app string, past uint64) (uint64, error) { if past >= 1<<63-1 { return 0, fmt.Errorf("console: door-key revision %d out of range", past) } tx, err := s.db.Begin() if err != nil { return 0, err } defer tx.Rollback() if _, err := tx.Exec(`INSERT INTO door_key_revisions (account_id, app, revision) VALUES (?, ?, ?) ON CONFLICT (account_id, app) DO UPDATE SET revision = excluded.revision WHERE revision < ?`, accountID, app, int64(past+1), int64(past)); err != nil { return 0, err } var rev uint64 if err := tx.QueryRow(`SELECT revision FROM door_key_revisions WHERE account_id = ? AND app = ?`, accountID, app).Scan(&rev); err != nil { return 0, err } return rev, tx.Commit() } func (s *Store) DoorKeyAdvanceRevision(accountID int64, app string, past uint64) (claimResult0 uint64, claimResult1 error) { return s.DoorKeyAdvanceRevisionContext(context.Background(), accountID, app, past) } func (s *Store) DoorKeyAdvanceRevisionContext(claimContext context.Context, accountID int64, app string, past uint64) (claimResult0 uint64, claimResult1 error) { claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.DoorKeyAdvanceRevision", func(claimView *Store) error { claimResult0, claimResult1 = claimView.claimRawDoorKeyAdvanceRevision(accountID, app, past) return claimResult1 }) return } func (s *Store) DoorKeyInsert(accountID int64, app string, e doorkey.Entry) (claimResult0 uint64, claimResult1 error) { return s.DoorKeyInsertContext(context.Background(), accountID, app, e) } func (s *Store) DoorKeyInsertContext(claimContext context.Context, accountID int64, app string, e doorkey.Entry) (claimResult0 uint64, claimResult1 error) { claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.DoorKeyInsert", func(claimView *Store) error { claimResult0, claimResult1 = claimView.claimRawDoorKeyInsert(accountID, app, e) return claimResult1 }) return } func (s *Store) DoorKeyRevoke(accountID int64, app, id string) (claimResult0 uint64, claimResult1 error) { return s.DoorKeyRevokeContext(context.Background(), accountID, app, id) } func (s *Store) DoorKeyRevokeContext(claimContext context.Context, accountID int64, app, id string) (claimResult0 uint64, claimResult1 error) { claimResult1 = s.withAutoAppSQL(claimContext, accountID, app, "sql.DoorKeyRevoke", func(claimView *Store) error { claimResult0, claimResult1 = claimView.claimRawDoorKeyRevoke(accountID, app, id) return claimResult1 }) return } func (s *Store) DoorKeysLive(accountID int64, app string) (claimResult0 []doorkey.Entry, claimResult1 uint64, claimResult2 error) { return s.DoorKeysLiveContext(context.Background(), accountID, app) } // DoorKeysLiveContext is a read: its transaction only SELECTs, so it takes no // app mutation and the door-key list stays a read under a foreign operation. // The armed triggers still refuse any write it could ever grow. func (s *Store) DoorKeysLiveContext(claimContext context.Context, accountID int64, app string) (claimResult0 []doorkey.Entry, claimResult1 uint64, claimResult2 error) { return s.claimRawDoorKeysLive(accountID, app) }