NextMyDay/internal/core/auth/credentials.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

151 lines
4.9 KiB
Go

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)
}