- 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
99 lines
2.9 KiB
Go
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
|
|
}
|