Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions internal/api/attest.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"spamfilter/internal/attest"
"spamfilter/internal/store"
"spamfilter/internal/token"
"spamfilter/internal/trust"
)

// attestHandler serves the App Attest challenge/verify endpoints. It converts
Expand Down Expand Up @@ -234,19 +235,21 @@ func (h *attestHandler) handleAssert(w http.ResponseWriter, r *http.Request) {
}

// upsertDevice inserts or updates the device row keyed by key_id and returns
// its device_id. New rows take the schema default trust_weight.
// its device_id. New rows get trust.TrustBase so enrolment matches what
// trust.Compute returns before any reports or tenure accumulate; existing
// rows keep their recomputed trust_weight (UPDATE clause leaves it alone).
func upsertDevice(ctx context.Context, db *sql.DB, keyID string, publicKey, receipt []byte, now time.Time) (uint64, error) {
if db == nil {
return 0, errors.New("api: nil database handle")
}

const upsert = `INSERT INTO devices (key_id, public_key, receipt, last_seen_at)
VALUES (?, ?, ?, ?)
const upsert = `INSERT INTO devices (key_id, public_key, receipt, last_seen_at, trust_weight)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
public_key = VALUES(public_key),
receipt = VALUES(receipt),
last_seen_at = VALUES(last_seen_at)`
if _, err := db.ExecContext(ctx, upsert, keyID, publicKey, receipt, now.UTC()); err != nil {
if _, err := db.ExecContext(ctx, upsert, keyID, publicKey, receipt, now.UTC(), trust.TrustBase); err != nil {
return 0, err
}

Expand Down
48 changes: 46 additions & 2 deletions internal/api/attest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"spamfilter/internal/config"
"spamfilter/internal/dbtest"
"spamfilter/internal/token"
"spamfilter/internal/trust"
)

func decodeEnvelope(t *testing.T, body []byte) (success bool, data json.RawMessage) {
Expand Down Expand Up @@ -220,6 +221,45 @@ func TestUpsertDevice_ClosedDB(t *testing.T) {
}
}

// TestUpsertDevice_EnrolmentTrustMatchesTrustBase guards issue #15: a freshly
// enrolled device must store trust.TrustBase, not the historical 1.00 default,
// so the first recompute does not look like an unexplained halving.
func TestUpsertDevice_EnrolmentTrustMatchesTrustBase(t *testing.T) {
database := dbtest.SetupDB(t)
ctx := context.Background()
now := time.Now()

deviceID, err := upsertDevice(ctx, database, "enrol-trust-key", []byte("pub"), []byte("receipt"), now)
if err != nil {
t.Fatalf("upsertDevice: %v", err)
}

var trustWeight float64
if err := database.QueryRowContext(ctx, "SELECT trust_weight FROM devices WHERE device_id = ?", deviceID).Scan(&trustWeight); err != nil {
t.Fatalf("select trust_weight: %v", err)
}
if trustWeight != trust.TrustBase {
t.Errorf("trust_weight = %v, want trust.TrustBase (%v)", trustWeight, trust.TrustBase)
}

// Schema default alone (insert omitting trust_weight) must also be TrustBase
// after migration 0006, so any other enrolment path stays aligned.
res, err := database.ExecContext(ctx, "INSERT INTO devices (key_id, public_key) VALUES (?, ?)", "schema-default-key", []byte("pub"))
if err != nil {
t.Fatalf("insert omitting trust_weight: %v", err)
}
schemaID, err := res.LastInsertId()
if err != nil {
t.Fatalf("LastInsertId: %v", err)
}
if err := database.QueryRowContext(ctx, "SELECT trust_weight FROM devices WHERE device_id = ?", schemaID).Scan(&trustWeight); err != nil {
t.Fatalf("select schema-default trust_weight: %v", err)
}
if trustWeight != trust.TrustBase {
t.Errorf("schema-default trust_weight = %v, want trust.TrustBase (%v)", trustWeight, trust.TrustBase)
}
}

func TestVerifyEndpoint_BadBody(t *testing.T) {
h := newTestHandler(attest.NewMemoryChallengeStore(), attest.NewMockVerifier(nil, nil), nil)

Expand Down Expand Up @@ -351,11 +391,15 @@ func TestVerifyEndpoint_HappyPath_DB(t *testing.T) {
t.Fatal("device_token is empty")
}

// 3. Assert a device row exists for this key_id.
// 3. Assert a device row exists for this key_id at TrustBase (issue #15).
var deviceID uint64
if err := database.QueryRow("SELECT device_id FROM devices WHERE key_id = ?", keyID).Scan(&deviceID); err != nil {
var trustWeight float64
if err := database.QueryRow("SELECT device_id, trust_weight FROM devices WHERE key_id = ?", keyID).Scan(&deviceID, &trustWeight); err != nil {
t.Fatalf("device row not found: %v", err)
}
if trustWeight != trust.TrustBase {
t.Errorf("enrolment trust_weight = %v, want trust.TrustBase (%v)", trustWeight, trust.TrustBase)
}

// 4. Token parses to the same device_id.
signer := token.NewSigner([]byte(cfg.DeviceTokenSecret))
Expand Down
13 changes: 12 additions & 1 deletion internal/db/migrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func TestMigrate_CreatesAllFiveTablesAndIsIdempotent(t *testing.T) {
}
}

const wantMigrations = 5 // 0001_init, 0002_drop_duplicate_number_index, 0003_device_sign_count, 0004_was_blockable, 0005_push_tokens
const wantMigrations = 6 // 0001_init, 0002_drop_duplicate_number_index, 0003_device_sign_count, 0004_was_blockable, 0005_push_tokens, 0006_trust_weight_default

var migrationRowCount int
if err := sqlDB.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&migrationRowCount); err != nil {
Expand Down Expand Up @@ -131,6 +131,17 @@ func TestMigrate_CreatesAllFiveTablesAndIsIdempotent(t *testing.T) {
}
}

// 0006 must align devices.trust_weight default with trust.TrustBase (0.5).
var trustDefault string
if err := sqlDB.QueryRow(
"SELECT COLUMN_DEFAULT FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'devices' AND column_name = 'trust_weight'",
).Scan(&trustDefault); err != nil {
t.Fatalf("failed to read devices.trust_weight COLUMN_DEFAULT: %v", err)
}
if trustDefault != "0.50" {
t.Errorf("devices.trust_weight COLUMN_DEFAULT = %q, want %q (must be set by 0006)", trustDefault, "0.50")
}

// Second run must be a no-op: no error, no duplicate rows.
if err := db.Migrate(sqlDB); err != nil {
t.Fatalf("second Migrate() failed: %v", err)
Expand Down
2 changes: 2 additions & 0 deletions internal/db/migrations/0006_trust_weight_default.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE devices
MODIFY trust_weight DECIMAL(5,2) NOT NULL DEFAULT 1.00;
5 changes: 5 additions & 0 deletions internal/db/migrations/0006_trust_weight_default.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Align devices.trust_weight column default with trust.TrustBase (0.5).
-- Enrolment (api.upsertDevice) omits trust_weight and relied on DEFAULT 1.00,
-- so brand-new devices looked fully trusted until the first recompute halved them.
ALTER TABLE devices
MODIFY trust_weight DECIMAL(5,2) NOT NULL DEFAULT 0.50;
4 changes: 2 additions & 2 deletions internal/dbtest/guarantees_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ func TestSetupDB_AppliesAllMigrations(t *testing.T) {
if err := sqlDB.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&applied); err != nil {
t.Fatalf("querying schema_migrations: %v", err)
}
if applied < 5 {
t.Errorf("schema_migrations has %d rows, want at least 5 (0001-0005)", applied)
if applied < 6 {
t.Errorf("schema_migrations has %d rows, want at least 6 (0001-0006)", applied)
}

// Spot-check the tables the rest of the suite writes to. information_schema
Expand Down