// Package webauthn implementiert Core IAM-10: passwortlose Anmeldung ueber // einen registrierten Authenticator (Plattform-Authenticator wie Windows // Hello/Touch ID oder externer Sicherheitsschluessel). // // WICHTIGER HINWEIS: Dies ist eine vereinfachte, aber kryptographisch echte // Challenge-Response-Zeremonie (Ed25519-Signatur ueber eine einmalige, // zufaellige Challenge) — NICHT das vollstaendige W3C-WebAuthn-Drahtformat // (CBOR-attestationObject/clientDataJSON, COSE-Schluessel), das echte // Browser/Authenticatoren erzeugen. Ohne Browser-Umgebung laesst sich dieses // Drahtformat hier nicht erzeugen oder pruefen (aehnliche Einschraenkung wie // bei IAM-05/LDAP und dem Live-Redirect in IAM-06/OIDC). Diese // Implementierung deckt dieselben Sicherheitseigenschaften ab (Public-Key- // Challenge-Response, Replay-Schutz durch Einmal-Challenge, mehrere // Authenticatoren pro Benutzer) und ist die Grundlage, auf der eine spaetere // Kachel echtes CBOR/COSE-Parsing nachruesten kann, ohne das Datenmodell zu // aendern. package webauthn import ( "context" "crypto/ed25519" "crypto/rand" "encoding/hex" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ( ErrChallengeInvalid = errors.New("webauthn: challenge ungueltig, verbraucht oder abgelaufen") ErrSignatureInvalid = errors.New("webauthn: signatur ungueltig") ErrNoCredentials = errors.New("webauthn: keine registrierten authenticatoren") ) // ChallengeTTL begrenzt, wie lange eine ausgestellte Challenge fuer die // Registrierungs-/Anmelde-Ceremonie gueltig bleibt. const ChallengeTTL = 5 * time.Minute type Credential struct { ID string UserID string DeviceName string PublicKey ed25519.PublicKey SignCount int } type Store struct { pool *pgxpool.Pool } func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } // BeginRegistration stellt eine Einmal-Challenge fuer die Registrierung // eines neuen Authenticators aus (Akzeptanzkriterium 1). func (s *Store) BeginRegistration(ctx context.Context, userID string) (challenge string, err error) { return s.issueChallenge(ctx, userID, "register") } // FinishRegistration verifiziert, dass der Aufrufer im Besitz des privaten // Schluessels zu publicKey ist (Signatur ueber die Challenge), und speichert // den Authenticator. Mehrere Authenticatoren pro Benutzer sind moeglich, // da jede Registrierung eine neue Zeile anlegt (Akzeptanzkriterium 2). func (s *Store) FinishRegistration(ctx context.Context, userID, challenge, deviceName string, publicKey ed25519.PublicKey, signature []byte) (credentialID string, err error) { if err := s.consumeChallenge(ctx, userID, challenge, "register"); err != nil { return "", err } if !ed25519.Verify(publicKey, []byte(challenge), signature) { return "", ErrSignatureInvalid } err = s.pool.QueryRow(ctx, ` INSERT INTO webauthn_credentials (user_id, device_name, public_key) VALUES ($1, $2, $3) RETURNING id `, userID, deviceName, []byte(publicKey)).Scan(&credentialID) if err != nil { return "", fmt.Errorf("credential speichern: %w", err) } return credentialID, nil } // BeginLogin stellt eine Einmal-Challenge fuer eine bestehende Anmeldung // aus. Der Benutzer muss mindestens einen registrierten Authenticator haben. func (s *Store) BeginLogin(ctx context.Context, userID string) (challenge string, err error) { var count int if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM webauthn_credentials WHERE user_id = $1`, userID).Scan(&count); err != nil { return "", fmt.Errorf("credentials zaehlen: %w", err) } if count == 0 { return "", ErrNoCredentials } return s.issueChallenge(ctx, userID, "login") } // FinishLogin verifiziert die Signatur ueber die Login-Challenge gegen den // gespeicherten oeffentlichen Schluessel des angegebenen Credentials // (Akzeptanzkriterium 1: Anmeldung ohne Passwort). func (s *Store) FinishLogin(ctx context.Context, userID, challenge, credentialID string, signature []byte) error { if err := s.consumeChallenge(ctx, userID, challenge, "login"); err != nil { return err } var publicKey []byte err := s.pool.QueryRow(ctx, ` SELECT public_key FROM webauthn_credentials WHERE id = $1 AND user_id = $2 `, credentialID, userID).Scan(&publicKey) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrSignatureInvalid } return fmt.Errorf("credential lesen: %w", err) } if !ed25519.Verify(ed25519.PublicKey(publicKey), []byte(challenge), signature) { return ErrSignatureInvalid } _, _ = s.pool.Exec(ctx, `UPDATE webauthn_credentials SET sign_count = sign_count + 1 WHERE id = $1`, credentialID) return nil } // ListCredentials liefert alle registrierten Authenticatoren eines // Benutzers (Akzeptanzkriterium 2). func (s *Store) ListCredentials(ctx context.Context, userID string) ([]Credential, error) { rows, err := s.pool.Query(ctx, ` SELECT id, device_name, public_key, sign_count FROM webauthn_credentials WHERE user_id = $1 `, userID) if err != nil { return nil, fmt.Errorf("credentials auflisten: %w", err) } defer rows.Close() var out []Credential for rows.Next() { c := Credential{UserID: userID} var pub []byte if err := rows.Scan(&c.ID, &c.DeviceName, &pub, &c.SignCount); err != nil { return nil, fmt.Errorf("credential lesen: %w", err) } c.PublicKey = pub out = append(out, c) } return out, rows.Err() } func (s *Store) issueChallenge(ctx context.Context, userID, purpose string) (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("challenge erzeugen: %w", err) } challenge := hex.EncodeToString(buf) _, err := s.pool.Exec(ctx, ` INSERT INTO webauthn_challenges (user_id, challenge, purpose, expires_at) VALUES ($1, $2, $3, $4) `, userID, challenge, purpose, time.Now().Add(ChallengeTTL)) if err != nil { return "", fmt.Errorf("challenge speichern: %w", err) } return challenge, nil } // consumeChallenge loest eine Challenge ATOMAR und EINMALIG ein (WHERE // used_at IS NULL, dasselbe Muster wie IAM-03/IAM-06/IAM-09) — verhindert // Replay einer abgefangenen Signatur-Antwort. func (s *Store) consumeChallenge(ctx context.Context, userID, challenge, purpose string) error { tag, err := s.pool.Exec(ctx, ` UPDATE webauthn_challenges SET used_at = now() WHERE user_id = $1 AND challenge = $2 AND purpose = $3 AND used_at IS NULL AND expires_at > now() `, userID, challenge, purpose) if err != nil { return fmt.Errorf("challenge einloesen: %w", err) } if tag.RowsAffected() == 0 { return ErrChallengeInvalid } return nil }