Как написать генератор паролей на Go?

Как написать генератор паролей на Go

Разберем задачу, которая часто встречается на собеседованиях и в реальных проектах. Она простая, но именно на таких задачах проверяют, как вы мыслите и что знаете помимо базового синтаксиса.

Задача: Напишите функцию, которая принимает целое число n и возвращает строку длиной n из символов a-z, A-Z и 0-9. По сути — генератор паролей.

Казалось бы, чего проще? Но давайте представим, что мы на собеседовании. Мы не просто напишем код, а пройдем путь от самого простого решения к профессиональному, обсудив все подводные камни.

Шаг 1. Самый простой вариант

Вот самое прямолинейное решение, которое приходит в голову:

package main

import (
    "fmt"
    "math/rand"
    "time"
)

func GeneratePassword(n int) string {
    // задаем все возможные символы для пароля
    symbols := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

    // "перемешиваем" генератор случайных чисел,
    // чтобы каждый раз получать разные пароли
    rand.Seed(time.Now().UnixNano())

    // создаем пустой срез байтов нужной длины
    result := make([]byte, n)

    // заполняем каждый байт случайным символом из symbols
    for i := 0; i < n; i++ {
        // rand.Intn(62) дает число от 0 до 61
        // symbols[число] берет символ под этим номером
        result[i] = symbols[rand.Intn(len(symbols))]
    }

    // превращаем срез байтов в строку
    return string(result)
}

func main() {
    // генерируем пароль из 8 символов
    password := GeneratePassword(8)
    fmt.Println(password)
}

Что здесь происходит?

  1. symbols — это строка со всеми разрешенными символами. Их тут 62: 26 маленьких букв + 26 больших + 10 цифр.
  2. rand.Seed(time.Now().UnixNano()) — инициализируем генератор случайных чисел текущим временем в наносекундах. Если этого не сделать, rand будет выдавать одни и те же числа при каждом запуске.
  3. make([]byte, n) — создаем срез из n байтов. Почему не строку? Потому что строки в Go не изменяемы, а с байтами работать удобнее.
  4. В цикле берем случайное число от 0 до 61 и используем его как индекс в строке symbols, получая случайный символ.

Минусы этого решения

Базовое решение работает, но у него есть несколько недочетов, которые следует учитывать в production-коде. Давайте разберем их по порядку.

Шаг 2. Разбираем проблемы по порядку

Проблема 1. Устаревший способ инициализации

В Go 1.20 (вышел в 2023 году) перестали требовать ручной вызов rand.Seed(). Теперь при импорте пакета math/rand генератор инициализируется автоматически один раз. Если вызываем Seed внутри функции, то мы переинициализируем его при каждом вызове. Это медленно и бессмысленно.

Что делать? Просто убрать эту строку. Но тогда возникает следующая проблема…

Проблема 2. Предсказуемость

math/rand — это не настоящий случайный генератор. Это псевдослучайный генератор, который использует математическую формулу. Если знать seed (начальное число), можно предсказать все следующие «случайные» числа.

Но главная проблема даже не в этом. Представьте, что вы вызываете функцию несколько раз подряд:

pwd1 := GeneratePassword(10)
pwd2 := GeneratePassword(10)
pwd3 := GeneratePassword(10)

На быстрых компьютерах эти вызовы могут произойти в одну и ту же наносекунду. И тогда time.Now().UnixNano() вернет одинаковое значение для всех трех вызовов! А значит, все три пароля будут идентичными.

Для паролей (даже для тестовых) это недопустимо!

Проблема 3: Нет проверки входных данных

Что будет, если передать n = 0? Получим пустую строку. Может, так и задумано? А если передать отрицательное число? В цикле условие i < n никогда не выполнится (потому что отрицательное число меньше нуля), и мы снова получим пустую строку.

Но главное — пароль длиной 0 или -5 — это ошибка. Функция должна явно сообщать об этом.

Проблема 4: Нет обработки ошибок

rand.Intn() всегда работает без ошибок. Но что, если мы захотим использовать криптографически безопасный генератор? Там ошибки могут возникнуть (например, закончилась энтропия в системе). Наш код к этому не готов.

Шаг 3. Пишем улучшенную версию

Теперь, зная все эти проблемы, давайте перепишем функцию правильно:

package main

import (
    "crypto/rand"
    "errors"
    "math/big"
)

// charset содержит все разрешенные символы
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

// GeneratePassword создает криптостойкий пароль длиной n.
// Возвращает ошибку, если длина некорректна или генератор отказал.
func GeneratePassword(n int) (string, error) {
    // Проверяем входные данные
    if n <= 0 {
        return "", errors.New("длина пароля должна быть положительным числом")
    }

    // Узнаем длину набора символов как *big.Int
    // big.Int нужен для работы с crypto/rand
    charsetLen := big.NewInt(int64(len(charset)))

    // Создаем срез байтов нужной длины
    result := make([]byte, n)

    // Заполняем срез случайными символами
    for i := 0; i < n; i++ {
        // Получаем случайное число от 0 до charsetLen-1
        // Используем криптографически безопасный генератор
        index, err := rand.Int(rand.Reader, charsetLen)
        if err != nil { 
            // Если случилась ошибка (например, нет энтропии),
            // возвращаем пустую строку и ошибку
            return "", err
        }

        // index.Int64() превращает *big.Int в обычный int64
        // подставляем полученный индекс в charset
        result[i] = charset[index.Int64()]
    }

    // Превращаем срез байтов в строку и возвращаем
    return string(result), nil
}

func main() { 
    // генерируем пароль из 8 символов 
    password := GeneratePassword(8) 
    fmt.Println(password) 
}

Разбираем код по строкам

Строка 4-6: Импортируем нужные пакеты:

  • crypto/rand — криптографически безопасный генератор случайных чисел
  • errors — для создания ошибок
  • math/big — для работы с большими числами (нужен для crypto/rand)

Строка 10: charset — это набор символов. Вынесли в константу, чтобы:

  • Не пересоздавать строку при каждом вызове
  • Легко было изменить набор символов в будущем

Строка 16: Проверяем длину. Если n ≤ 0 — возвращаем ошибку. В Go принято возвращать ошибку последним параметром.

Строка 22: big.NewInt() создает большое число. Почему не обычный int? Потому что rand.Int() из пакета crypto/rand работает только с *big.Int. Это особенность API.

Строка 25: make([]byte, n) — выделяем память сразу под n байтов. Это эффективно: мы не будем расширять срез в процессе.

Строка 28-41: Главный цикл:

  • rand.Int(rand.Reader, charsetLen) — получаем случайное число от 0 до charsetLen-1. rand.Reader — это глобальный источник случайных чисел из пакета crypto/rand.
  • Если произошла ошибка (например, системе не хватило «случайности» для генерации числа), возвращаем ошибку.
  • index.Int64() — конвертируем *big.Int в обычный int64, чтобы использовать как индекс.

Строка 40: charset[index.Int64()] — берем символ по индексу. Это безопасно, потому что индекс гарантированно меньше длины строки.

Строка 44: Возвращаем готовую строку и nil (нет ошибки).

Шаг 4. Как тестировать случайность?

Отлично, функция готова. Но как ее тестировать? Ведь каждый раз она возвращает разный результат! Тут нам помогут тесты свойств (property-based tests). Мы не проверяем конкретные значения, а проверяем, что функция соблюдает свои обещания.

Тест 1: Проверка длины

Самый простой тест: убедимся, что пароль имеет нужную длину:

func TestGeneratePasswordLength(t *testing.T) {
    // задаем нужную длину
    length := 10

    // генерируем пароль
    pwd, err := GeneratePassword(length)

    // проверяем, что нет ошибки
    if err != nil {
        t.Fatal("Не ожидали ошибку, но получили:", err)
    }

    // проверяем длину
    if len(pwd) != length {
        t.Errorf("Хотели пароль длиной %d, получили %d", length, len(pwd))
    }
}

Тест 2: Проверка допустимых символов

Убедимся, что в пароле нет посторонних символов:

func TestGeneratePasswordCharset(t *testing.T) {
    // генерируем длинный пароль, чтобы проверить много символов
    pwd, err := GeneratePassword(1000)
    if err != nil {
        t.Fatal("Ошибка генерации:", err)
    }

    // проверяем каждый символ
    for _, char := range pwd {
        // strings.ContainsRune проверяет, есть ли символ в строке
        if !strings.ContainsRune(charset, char) {
            t.Errorf("Найден недопустимый символ: %с", char)
        }
    }
}

Важно: Используем strings.ContainsRune, потому что символы в Go — это руны (Unicode-коды), а байты могут не вместить весь символ, если добавим, например, кириллицу.

Тест 3: Проверка уникальности

Хороший генератор не должен повторяться (по крайней мере, часто):

func TestGeneratePasswordUniqueness(t *testing.T) {
    // карта для отслеженных уже увиденных паролей
    seen := make(map[string]bool)

    // генерируем 1000 паролей
    for i := 0; i < 1000; i++ {
        pwd, err := GeneratePassword(10)
        if err != nil {
            t.Fatal("Ошибка генерации:", err)
        }

        // если пароль уже был - это проблема
        if seen[pwd] {
            t.Errorf("Найден повторяющийся пароль: %s", pwd)
        }
        seen[pwd] = true
    }
}

Заметка: Технически повторы возможны (это же случайность), но вероятность получить повтор среди 1000 паролей длиной 10 символов из 62-символьного алфавита ничтожна мала. Если тест упал — значит, генератор работает неправильно.

Тест 4: Проверка обработки ошибок

Убедимся, что на некорректные данные функция реагирует правильно:

func TestGeneratePasswordErrors(t *testing.T) {
    // тестируем разные варианты неправильной длины
    testCases := []int{0, -1, -100}

    for _, length := range testCases {
        _, err := GeneratePassword(length)
        if err == nil {
            t.Errorf("Для длины %d ожидали ошибку, но ее нет", length)
        }
    }
}

Шаг 5. Можно ли предсказать пароль для тестов?

Это хороший вопрос. Если бы мы использовали math/rand с фиксированным seed’ом, мы могли бы точно предсказать, какой пароль сгенерируется. Например:

rand.Seed(42) // фиксируем seed
pwd := GeneratePassword(5) // всегда будет "aB3xK"

Это было бы удобно для тестов, но опасно для реального использования. В продакшене пароли должны быть непредсказуемыми.

Поэтому здесь выбор пал на crypto/rand — он использует системный источник энтропии (шумы от устройств, движения мыши, нажатия клавиш). Это делает генерацию по-настоящему случайной и непредсказуемой. Для тестов это минус (сложнее проверять), но для безопасности — огромный плюс.

Ключевой вывод

Главное, что нужно вынести из этой задачи: простота реализации не равна качеству кода. Даже в такой базовой функции, как генератор паролей, важно учитывать безопасность (выбор crypto/rand вместо math/rand), корректную обработку ошибок и проверку входных данных. Именно такие детали отличают код, который просто работает, от кода, который можно использовать в реальных проектах.

А как вы решаете подобные задачи на своих проектах? Используете готовые библиотеки или пишете свои генераторы? Сталкивались с неочевидными проблемами при генерации случайных данных? Поделитесь опытом в комментариях — интересно обсудить!

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: