package auth import ( "context" "database/sql" "errors" "fmt" "strings" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth/webauthn" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" ) // Credentials is the WebAuthn credential store contract (SPEC-004 §4 // `webauthn_credentials`). Credentials hang off an IDENTITY, never a user // (D-008): identity rotation destroys them. type Credentials interface { // PutWebAuthnCredential stores a credential for an identity (upsert by // credential id). PutWebAuthnCredential(ctx context.Context, identityID int64, c webauthn.Credential) error // WebAuthnCredentials returns every credential of an identity. WebAuthnCredentials(ctx context.Context, identityID int64) ([]webauthn.Credential, error) // UpdateWebAuthnSignCount records an authenticator's advanced counter after // a successful assertion (clone detection). UpdateWebAuthnSignCount(ctx context.Context, identityID int64, credentialID []byte, signCount uint32) error // HasWebAuthnCredential reports whether an identity has at least one // registered passkey — the enrollment two-factor check (SPEC-006 §4 AC-2). HasWebAuthnCredential(ctx context.Context, identityID int64) (bool, error) } var _ Credentials = (*IdentitiesService)(nil) // PutWebAuthnCredential implements Credentials. func (s *IdentitiesService) PutWebAuthnCredential(ctx context.Context, identityID int64, c webauthn.Credential) error { if len(c.ID) == 0 { return errors.New("auth: webauthn credential id is empty") } if _, err := s.identityByID(identityID); err != nil { return err } if _, err := s.db.Exec( `INSERT INTO webauthn_credentials (credential_id, identity_id, public_key, sign_count, transports, aaguid, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(credential_id) DO UPDATE SET public_key = excluded.public_key, sign_count = excluded.sign_count, transports = excluded.transports, aaguid = excluded.aaguid`, c.ID, identityID, c.PublicKey, int64(c.SignCount), strings.Join(c.Transports, ","), nullBytes(c.AAGUID), store.FormatTime(s.clock()), ); err != nil { return fmt.Errorf("auth: store webauthn credential: %w", err) } // No key material in the trail — identifiers only (A9). s.record(ctx, audit.Event{ Action: "webauthn.credential.put", Subject: fmt.Sprintf("identity:%d", identityID), Detail: fmt.Sprintf("credential_id=%s", fingerprint(c.ID)), }) return nil } // WebAuthnCredentials implements Credentials. func (s *IdentitiesService) WebAuthnCredentials(ctx context.Context, identityID int64) ([]webauthn.Credential, error) { rows, err := s.db.Query( `SELECT credential_id, public_key, sign_count, transports, aaguid FROM webauthn_credentials WHERE identity_id = ? ORDER BY created_at`, identityID, ) if err != nil { return nil, fmt.Errorf("auth: load webauthn credentials: %w", err) } defer rows.Close() var out []webauthn.Credential for rows.Next() { var ( c webauthn.Credential signCount int64 transports string aaguid []byte ) if err := rows.Scan(&c.ID, &c.PublicKey, &signCount, &transports, &aaguid); err != nil { return nil, fmt.Errorf("auth: scan webauthn credential: %w", err) } c.SignCount = uint32(signCount) c.AAGUID = aaguid if transports != "" { c.Transports = strings.Split(transports, ",") } out = append(out, c) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("auth: iterate webauthn credentials: %w", err) } return out, nil } // UpdateWebAuthnSignCount implements Credentials. It is a no-op when the // credential is unknown — sign-count tracking is best-effort, never a gate. func (s *IdentitiesService) UpdateWebAuthnSignCount(ctx context.Context, identityID int64, credentialID []byte, signCount uint32) error { if _, err := s.db.Exec( `UPDATE webauthn_credentials SET sign_count = ? WHERE credential_id = ? AND identity_id = ?`, int64(signCount), credentialID, identityID, ); err != nil { return fmt.Errorf("auth: update sign count: %w", err) } return nil } // HasWebAuthnCredential implements Credentials. func (s *IdentitiesService) HasWebAuthnCredential(ctx context.Context, identityID int64) (bool, error) { var one int err := s.db.QueryRow( `SELECT 1 FROM webauthn_credentials WHERE identity_id = ? LIMIT 1`, identityID, ).Scan(&one) switch { case errors.Is(err, sql.ErrNoRows): return false, nil case err != nil: return false, fmt.Errorf("auth: webauthn credential lookup: %w", err) } return true, nil } // nullBytes maps an empty blob to NULL. func nullBytes(b []byte) any { if len(b) == 0 { return nil } return b } // fingerprint is a short label for an opaque credential id, for the audit trail // only — the credential id is public, but the trail keeps just a prefix. func fingerprint(id []byte) string { if len(id) == 0 { return "" } if len(id) > 8 { id = id[:8] } return fmt.Sprintf("%x", id) }