mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
Two instances booting against a fresh database both find no key, both generate one, and both write. PutCryptoKey was last-writer-wins, so the loser kept its own key in memory while the database held the other's. It then signed OAuth client assertions with a key absent from the published JWKS, and issued registry JWTs that did not match the certificate written to disk. Every one of them fails verification, and nothing logs why. PutCryptoKey now keeps the first write, and both loaders re-read afterwards and use whatever is stored. Nothing in the codebase rotates a key through this function, so the update arm only ever fired on the race. Verified against the old behavior: with last-writer-wins restored, three of six concurrent loaders returned a key that was not the one in the database. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
4.1 KiB
Go
157 lines
4.1 KiB
Go
package appview
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
|
|
"atcr.io/pkg/appview/db"
|
|
)
|
|
|
|
// cryptoTestDB returns a file-backed database.
|
|
//
|
|
// Not ":memory:" — go-libsql gives each connection to an in-memory DSN its own
|
|
// private database, so concurrent loaders would not even see each other's writes
|
|
// and the test would pass without testing anything.
|
|
func cryptoTestDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
database, err := db.InitDB(filepath.Join(t.TempDir(), "keys.db"), db.LibsqlConfig{})
|
|
if err != nil {
|
|
t.Fatalf("InitDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { database.Close() })
|
|
return database
|
|
}
|
|
|
|
// TestLoadOAuthKeyConcurrentBootAgreesOnOneKey covers two instances booting
|
|
// against a fresh database at the same time.
|
|
//
|
|
// Both find no key, both generate one, and both write. The write used to be
|
|
// last-writer-wins, so the loser kept its own key in memory while the database
|
|
// held the other's: it then signed OAuth client assertions with a key absent
|
|
// from the published JWKS, and every one of them failed verification.
|
|
func TestLoadOAuthKeyConcurrentBootAgreesOnOneKey(t *testing.T) {
|
|
database := cryptoTestDB(t)
|
|
|
|
const loaders = 6
|
|
keys := make([][]byte, loaders)
|
|
errs := make([]error, loaders)
|
|
|
|
var wg sync.WaitGroup
|
|
start := make(chan struct{})
|
|
for i := range loaders {
|
|
wg.Go(func() {
|
|
<-start
|
|
key, err := loadOAuthKey(database)
|
|
if err != nil {
|
|
errs[i] = err
|
|
return
|
|
}
|
|
keys[i] = key.Bytes()
|
|
})
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("loader %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
stored, err := db.GetCryptoKey(database, "oauth_p256")
|
|
if err != nil {
|
|
t.Fatalf("GetCryptoKey: %v", err)
|
|
}
|
|
for i, k := range keys {
|
|
if !bytes.Equal(k, stored) {
|
|
t.Errorf("loader %d returned a key that is not the one in the database; "+
|
|
"it would sign with a key missing from the published JWKS", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLoadRSAKeyConcurrentBootAgreesOnOneKey is the same race for the JWT
|
|
// signing key. Losing it means issuing registry JWTs that do not match the
|
|
// advertised certificate.
|
|
func TestLoadRSAKeyConcurrentBootAgreesOnOneKey(t *testing.T) {
|
|
database := cryptoTestDB(t)
|
|
|
|
const loaders = 6
|
|
fingerprints := make([][]byte, loaders)
|
|
errs := make([]error, loaders)
|
|
|
|
var wg sync.WaitGroup
|
|
start := make(chan struct{})
|
|
for i := range loaders {
|
|
wg.Go(func() {
|
|
<-start
|
|
key, err := loadRSAKey(database)
|
|
if err != nil {
|
|
errs[i] = err
|
|
return
|
|
}
|
|
fingerprints[i] = key.N.Bytes()
|
|
})
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("loader %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
for i := 1; i < loaders; i++ {
|
|
if !bytes.Equal(fingerprints[i], fingerprints[0]) {
|
|
t.Fatalf("loaders disagreed on the JWT signing key: loader %d differs from loader 0", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPutCryptoKeyKeepsTheFirstWrite pins the storage semantics the loaders rely
|
|
// on. If this ever goes back to last-writer-wins, a second instance booting
|
|
// would silently replace the key the first one is already signing with.
|
|
func TestPutCryptoKeyKeepsTheFirstWrite(t *testing.T) {
|
|
database := cryptoTestDB(t)
|
|
|
|
first := []byte("first-key")
|
|
second := []byte("second-key")
|
|
|
|
if err := db.PutCryptoKey(database, "probe", first); err != nil {
|
|
t.Fatalf("first put: %v", err)
|
|
}
|
|
if err := db.PutCryptoKey(database, "probe", second); err != nil {
|
|
t.Fatalf("second put: %v", err)
|
|
}
|
|
|
|
got, err := db.GetCryptoKey(database, "probe")
|
|
if err != nil {
|
|
t.Fatalf("GetCryptoKey: %v", err)
|
|
}
|
|
if !bytes.Equal(got, first) {
|
|
t.Errorf("stored key = %q, want the first write %q", got, first)
|
|
}
|
|
}
|
|
|
|
// TestLoadOAuthKeyIsStableAcrossRestarts: a restart must reuse the stored key,
|
|
// not generate a new one, or every previously issued token breaks.
|
|
func TestLoadOAuthKeyIsStableAcrossRestarts(t *testing.T) {
|
|
database := cryptoTestDB(t)
|
|
|
|
first, err := loadOAuthKey(database)
|
|
if err != nil {
|
|
t.Fatalf("first load: %v", err)
|
|
}
|
|
second, err := loadOAuthKey(database)
|
|
if err != nil {
|
|
t.Fatalf("second load: %v", err)
|
|
}
|
|
if !bytes.Equal(first.Bytes(), second.Bytes()) {
|
|
t.Error("loadOAuthKey generated a new key instead of reusing the stored one")
|
|
}
|
|
}
|