package audit import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ( ErrConfirmationNotFound = errors.New("audit: bestaetigungsvorgang nicht gefunden") ErrAlreadyDecided = errors.New("audit: bestaetigungsvorgang wurde bereits entschieden") ErrSameActor = errors.New("audit: bestaetigung muss von einer anderen person als der anfordernden erfolgen") ErrInvalidCode = errors.New("audit: bestaetigungscode ungueltig") ) type ConfirmationStatus string const ( StatusPending ConfirmationStatus = "pending" StatusConfirmed ConfirmationStatus = "confirmed" ) // FourEyes implementiert das Vier-Augen-Prinzip fuer sicherheitskritische // Entscheidungen (Akzeptanzkriterium 2) nach dem archivdms-Vorbild: // FOR-UPDATE-Lock gegen Race-Bedingungen bei paralleler Bestaetigung, // Timing-safe Vergleich des Bestaetigungscodes (Akzeptanzkriterium 3). type FourEyes struct { pool *pgxpool.Pool } func NewFourEyes(pool *pgxpool.Pool) *FourEyes { return &FourEyes{pool: pool} } // Request legt einen neuen, zu bestaetigenden Vorgang an (z.B. Loeschbestaetigung, // Rechtevergabe) und liefert einen einmaligen Klartext-Code, der ausserhalb // dieses Systems (z.B. per E-Mail) an eine ZWEITE Person uebermittelt wird — // niemals der anfordernden Person selbst. func (f *FourEyes) Request(ctx context.Context, action, target, requestedBy string) (id, code string, err error) { code, err = generateCode() if err != nil { return "", "", fmt.Errorf("bestaetigungscode erzeugen: %w", err) } hash := hashCode(code) err = f.pool.QueryRow(ctx, ` INSERT INTO security_confirmations (action, target, requested_by, code_hash, status) VALUES ($1, $2, $3, $4, 'pending') RETURNING id `, action, target, requestedBy, hash).Scan(&id) if err != nil { return "", "", fmt.Errorf("bestaetigungsvorgang anlegen: %w", err) } return id, code, nil } // Confirm bestaetigt einen Vorgang. confirmedBy MUSS sich von der // anfordernden Person unterscheiden (echtes Vier-Augen-Prinzip). Der Zugriff // auf die Zeile erfolgt mit FOR UPDATE, damit zwei gleichzeitige // Bestaetigungsversuche serialisiert werden und niemals beide durchgehen // (Akzeptanzkriterium 2 / Pruefung 2). func (f *FourEyes) Confirm(ctx context.Context, id, confirmedBy, code string) error { tx, err := f.pool.Begin(ctx) if err != nil { return fmt.Errorf("transaktion starten: %w", err) } defer func() { _ = tx.Rollback(ctx) }() var requestedBy, status string var codeHash []byte err = tx.QueryRow(ctx, ` SELECT requested_by, status, code_hash FROM security_confirmations WHERE id = $1 FOR UPDATE `, id).Scan(&requestedBy, &status, &codeHash) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrConfirmationNotFound } return fmt.Errorf("bestaetigungsvorgang lesen: %w", err) } if status != string(StatusPending) { return ErrAlreadyDecided } if confirmedBy == requestedBy { return ErrSameActor } if !timingSafeEqual(hashCode(code), codeHash) { return ErrInvalidCode } if _, err := tx.Exec(ctx, ` UPDATE security_confirmations SET status = 'confirmed', confirmed_by = $2, confirmed_at = now() WHERE id = $1 `, id, confirmedBy); err != nil { return fmt.Errorf("bestaetigung speichern: %w", err) } return tx.Commit(ctx) } func generateCode() (string, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } func hashCode(code string) []byte { sum := sha256.Sum256([]byte(code)) return sum[:] }