- 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
151 lines
4.9 KiB
Go
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)
|
|
}
|