package auth import ( "context" "database/sql" "errors" "fmt" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" ) // The TOTP secret store (SPEC-004 §4 `totp_secrets`, A9). // // The plaintext secret exists only transiently in memory while being sealed or // opened; the database only ever holds ciphertext, and no secret material ever // reaches an audit row, a log line, or an error string. // PutTOTPSecret seals secret and stores the ciphertext for an identity, // replacing any previous secret and clearing its verified mark. func (s *IdentitiesService) PutTOTPSecret(ctx context.Context, identityID int64, secret []byte) error { if s.sealer == nil { // Fail closed: never fall back to storing plaintext (A9). return ErrNoSealer } if len(secret) == 0 { return ErrEmptySecret } if _, err := s.identityByID(identityID); err != nil { return err } encrypted, err := s.sealer.Seal(secret) if err != nil { return fmt.Errorf("auth: seal totp secret: %w", err) } if _, err := s.db.Exec( `INSERT INTO totp_secrets (identity_id, secret_encrypted, verified_at) VALUES (?, ?, NULL) ON CONFLICT(identity_id) DO UPDATE SET secret_encrypted = excluded.secret_encrypted, verified_at = NULL`, identityID, encrypted, ); err != nil { return fmt.Errorf("auth: store totp secret: %w", err) } // NOTE: the secret is deliberately absent from this event (A9). s.record(ctx, audit.Event{ Action: "totp.secret.put", Subject: fmt.Sprintf("identity:%d", identityID), Detail: "secret stored encrypted", }) return nil } // TOTPSecret returns the decrypted secret for an identity. func (s *IdentitiesService) TOTPSecret(ctx context.Context, identityID int64) ([]byte, error) { if s.sealer == nil { return nil, ErrNoSealer } var encrypted []byte err := s.db.QueryRow( `SELECT secret_encrypted FROM totp_secrets WHERE identity_id = ?`, identityID, ).Scan(&encrypted) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("%w: identity:%d", ErrSecretNotFound, identityID) } if err != nil { return nil, fmt.Errorf("auth: load totp secret: %w", err) } plaintext, err := s.sealer.Open(encrypted) if err != nil { return nil, fmt.Errorf("auth: open totp secret: %w", err) } return plaintext, nil } // MarkTOTPVerified records that a TOTP code has been verified for an identity. func (s *IdentitiesService) MarkTOTPVerified(ctx context.Context, identityID int64) error { res, err := s.db.Exec( `UPDATE totp_secrets SET verified_at = ? WHERE identity_id = ?`, store.FormatTime(s.clock()), identityID, ) if err != nil { return fmt.Errorf("auth: mark totp verified: %w", err) } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("%w: identity:%d", ErrSecretNotFound, identityID) } s.record(ctx, audit.Event{ Action: "totp.verified", Subject: fmt.Sprintf("identity:%d", identityID), Detail: "verified_at set", }) return nil } // TOTPEnrolled reports whether an identity has a VERIFIED TOTP secret — the // second factor is enrolled (SPEC-010 §4). A missing secret and an issued-but- // unverified one both read as not-enrolled. It reads a boolean, never the // secret (A9). func (s *IdentitiesService) TOTPEnrolled(ctx context.Context, identityID int64) (bool, error) { var one int err := s.db.QueryRow( `SELECT 1 FROM totp_secrets WHERE identity_id = ? AND verified_at IS NOT NULL LIMIT 1`, identityID, ).Scan(&one) switch { case errors.Is(err, sql.ErrNoRows): return false, nil case err != nil: return false, fmt.Errorf("auth: totp enrolled lookup: %w", err) } return true, nil }