NextMyDay/internal/core/auth/totp.go
Claus Lohmar 76a4a6950c feat(auth): add WebAuthn, TOTP and the enroll/login flow (SPEC-004/006)
- webauthn: the passkey ceremony service (a fake in tests)
- totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9)
- sealer: the AEAD TOTP sealer
- identities/sessions/credentials: the GUID state machine
- flow: the enroll/login screens + the passkey nudge (pre-session
  double-submit CSRF — A15), rendered inside the shell
2026-10-14 10:53:53 +00:00

99 lines
2.9 KiB
Go

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
}