Генерация случайных парольных фраз из наборов строк с помощью secrets.random не является очень случайной

Мне нужен базовый генератор парольных фраз, который использует заданные списки слов, по одному для каждой позиции в парольной фразе.

 def generate(self) -> str:
        passphrase = "%s-%s-%s-%s-%s%s%s" % (
            self.choose_word(self.verbs),
            self.choose_word(self.determiners),
            self.choose_word(self.adjectives),
            self.choose_word(self.nouns),
            self.choose_word(self.numbers),
            self.choose_word(self.numbers),
            self.choose_word(self.numbers),
        )

Выбранные списки содержат 100 прилагательных, 9 определителей, 217 существительных, 67 глаголов и цифры 1–9, выбор осуществляется с помощью secrets.choice.

    def choose_word(cls, word_list: List[str]) -> str:
        return secrets.choice(word_list)

Теоретически я думал, что это даст мне тень под 13 миллиардами уникальных паролей. Однако я написал тестовый пример, который генерирует 10 000 парольных фраз и проверяет их уникальность посредством проверки членства в последовательности сгенерированных парольных фраз.

def test_can_generate_10000_passphrases_without_collision(passphrase: Passphrase):
    generated_passphrases = []
    for i in range(10000):
        generated_passphrase = passphrase.generate()
        assert generated_passphrase is not None and len(generated_passphrase) > 10
        assert generated_passphrase not in generated_passphrases
        generated_passphrases.append(generated_passphrase)

    assert len(generated_passphrases) == 10000

Однако тест не отражает вероятность появления дубликатов, которую я ожидал. Используя pytest-repeat, я настроил этот тест на запуск 10 000 раз, и он не удался/сгенерировал повторяющиеся пароли 24 раза в 4145 запусках, прежде чем я завершил процесс. В каждом случае выходные данные теста обрезаются, но показывают, что выбранная парольная фраза была найдена «в» наборе сгенерированных фраз, и эта фраза каждый раз разная.

На самом деле у меня нет конкретного вопроса, просто кажется, что я что-то не понимаю: либо мои расчеты вероятности неверны, и мне нужно добавить больше слов в списки, либо что-то в тесте при проверке последовательности дает более слабое совпадение тогда я ожидал.

Я переключился с random.choices на secrets.choices, попробовал повторно создать экземпляр класса генератора паролей между запусками, попробовал добавить проверки на то, что пароль непустой (поскольку пустые строки всегда совпадают), а также попробовал запускать и выходить из докера. контейнер думает, что что-то может испортить случайность.

Добро пожаловать в проблему дня рождения. Оказывается, дубликаты встречаются гораздо чаще, чем ожидает большинство людей, если брать выборку среднего размера из независимо выбранных случайных элементов.

pjs 06.05.2024 21:28

Может быть, моя математика неверна в отношении количества возможностей? Потому что мы говорим о размере выборки в 10 000 из возможного набора в 13 000 000 000 (опять же, если мои расчеты верны). Я мог бы понять, если бы проверял пересечения между наборами из 10 000, но я проверяю уникальность только в каждом наборе из 10 000.

Alec Joy 06.05.2024 21:33

Напишите простой тест, и вы увидите, что это задача о дне рождения. Хотя может быть и мешающий фактор, связанный с частотой запросов. В моих тестах с 13 миллионами вариантов я довольно часто получаю дубликат в пределах 30 тысяч вариантов.

JonSG 06.05.2024 21:36

Нет, я согласен с вашей оценкой размера бассейна. Однако проблема дня рождения подсказывает нам, что в половине случаев коллизии следует ожидать при размерах выборки O(sqrt(размер пула)), что соответствует примерно 115 тыс. для вашего размера пула. Вы берете десятую часть этого количества и получаете 24/4145, или около 0,006, что кажется разумным. Вы можете вычислить точную вероятность с помощью арифметики больших целых чисел, используя формулы на странице Википедии, ссылку на которую я дал выше.

pjs 06.05.2024 21:40

Ладно, интересно. Есть ли способ защититься от этих коллизий без необходимости вести учет каждой сгенерированной парольной фразы?

Alec Joy 06.05.2024 21:45

Одним из способов было бы использовать источник случайности более низкого качества, такой как собственный линейный конгруэнтный генератор с длиной цикла 130851000, то есть он генерирует каждое значение в этом диапазоне, прежде чем повторять какое-либо из них.

pjs 06.05.2024 22:08

Есть ли причина, по которой вам нужны неповторяющиеся парольные фразы? Истинная случайность в ограниченном пространстве обычно приводит к довольно быстрому созданию дубликатов. Если вы хотите избежать дублирования, вам нужно будет удалить часть случайности в системе, что, как правило, является плохой идеей, когда речь идет о безопасности.

The Guy with The Hat 10.05.2024 23:36

@TheGuywithTheHat Мне не нужны 100% неповторяющиеся тесты, это было сделано для того, чтобы сбалансировать написание теста, который завершался неудачей достаточно редко, чтобы это не был один из тех нестабильных тестов, которые заставляют людей игнорировать все неудачные тесты, но при этом иметь приличный флаг для если случайность падает. Генератор парольных фраз предназначен для детей начальной школы, поэтому список доступных слов настраиваемый и относительно короткий. Когда мы добавляем и удаляем слова, я хочу знать, что они по-прежнему «достаточно случайны», чтобы ни один ученик в классе не получил более или менее одинаковый пароль. Цель, которую я поставил, была совсем рядом.

Alec Joy 12.05.2024 22:46
Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
2
8
85
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Характер проблемы

Начнем с утверждения, что существует отображение 1 к 1 между k-мерным списком индексы для списков длины ℓ1,...,ℓk и целых чисел в диапазон [0,...,Π(ℓ1,...,ℓk)-1], где Π обозначает продукт множества значений.

Следующий класс Python показывает реализацию математики, которая выполняет это сопоставление.

class Mapping:

  def __init__(self, dimensions):
    self.dimensions = dimensions
    self.n_dimensions = len(dimensions)
    self.cumulative = dimensions.copy()
    self.cumulative.append(1)
    for i in range(1, self.n_dimensions):
      idx = self.n_dimensions - i
      self.cumulative[idx] *= self.cumulative[idx+1]
    self.capacity = self.cumulative.pop(0)
    self.capacity *= self.cumulative[0]

  def indices_to_int(self, indices):
    result = 0
    for i in range(self.n_dimensions):
      result += indices[i] * self.cumulative[i]
    return result

  def int_to_indices(self, index):
    indices = [None] * self.n_dimensions
    for i in range(self.n_dimensions):
      indices[i] = index // self.cumulative[i]
      index -= indices[i] * self.cumulative[i]
    return indices

Использование этого с небольшим набором индексов довольно четко показывает отображение 1 к 1. Этот код:

list_lengths = [2,3,4]
map = Mapping(list_lengths)

int_set = range(map.capacity)
for number in int_set:
  index_list = map.int_to_indices(number)
  print(number, index_list, map.indices_to_int(index_list))

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

0 [0, 0, 0] 0
1 [0, 0, 1] 1
2 [0, 0, 2] 2
3 [0, 0, 3] 3
4 [0, 1, 0] 4
5 [0, 1, 1] 5
6 [0, 1, 2] 6
7 [0, 1, 3] 7
8 [0, 2, 0] 8
9 [0, 2, 1] 9
10 [0, 2, 2] 10
11 [0, 2, 3] 11
12 [1, 0, 0] 12
13 [1, 0, 1] 13
14 [1, 0, 2] 14
15 [1, 0, 3] 15
16 [1, 1, 0] 16
17 [1, 1, 1] 17
18 [1, 1, 2] 18
19 [1, 1, 3] 19
20 [1, 2, 0] 20
21 [1, 2, 1] 21
22 [1, 2, 2] 22
23 [1, 2, 3] 23

Это означает, что ваша проблема с попыткой создания уникальных комбинаций элементов из набор списков эквивалентен созданию набора уникальных целочисленных значений в четко определенном диапазоне. При длине списка [2,3,4] это range(2*3*4). Для вашей реальной проблемы применимый диапазон — range(100*9*217*67*10*10*10), т. е. range(13_085_100_000).

Вы можете подумать, что если взять выборку размером 10_000 из пула в 13 миллиардов целые числа, вероятность получения дубликатов будет близка к нулю. день рождения Проблема , также известная как парадокс дня рождения, говорит нам об обратном. Когда вы выбираете значения независимо, каждая дополнительная выборка имеет задачу избежать всех значений которые уже были отобраны. Вероятности уклонения изначально высоки, но они меньше 1 и уменьшаются по мере того, как количество выбранных значений увеличивается и умножается (из-за независимой выборки), поэтому вероятность всех из них одновременное отсутствие друг друга удивительно быстро уменьшается до нуля. Оказывается что при 365 днях в году (без учета високосных лет) вероятность получить к тому моменту, когда вы представите 23-го человека, число одного или нескольких повторяющихся дней рождения превысит 1/2. в группу. К тому времени, когда вы достигнете 57 человек, вероятность наличия двух или более дни рождения которых совпадают, превышает 0,99.

Размер пула 13_085_100_000 намного больше, чем 365, но логика работает та же. способ. Следующая программа показывает, что при размере выборки 10_000 целочисленных значений: аналитически полученная вероятность наличия одного или нескольких повторяющихся значений равна около 0,00381. Мы ожидаем, что в 38 из 10 000 таких экспериментов будет по крайней мере один дубликат. При размере выборки 135_000 вероятность дублирования превышает 0,5.

import fractions

pool_size = fractions.Fraction(13_085_100_000)  # use big-integer rational arithmetic
samp_size = 10_000
p_no_duplicates = fractions.Fraction(1)  # no duplicates when one item in the room...
numerator = fractions.Fraction(pool_size + 1)

# ...so start with the second item.
for i in range(2, samp_size): # i is number of items currently in the room
  p_no_duplicates *= fractions.Fraction(numerator - i) / pool_size
print("For " + str(samp_size) + " items and a pool size of " +
  str(pool_size) + ", P(Duplicate) = " + str(1.0 - p_no_duplicates))

# For 10000 items and a pool size of 13085100000, P(Duplicate) = 0.0038127078842439266

Одно возможное решение

Возможное решение — использовать Python random.sample(), который выполняет выборку без замены. Что означает, что как только значение выбрано, оно удаляется из пула оставшихся кандидатов. (Это меняет вероятности всех оставшихся значений в пуле, поэтому это не независимая выборка.) Никаких дубликатов не будет до тех пор, пока весь пул был отобран. Использование random.sample() гарантирует уникальные целые числа, которые затем можно отобразить в уникальный набор индексов, из которого можно составить набор парольных фраз.

Следующее иллюстрирует это с помощью индексов [2,3,4]. из предыдущего примера:

import random

# list_lengths = [100,9,217,67,10,10,10]
list_lengths = [2,3,4]
map = Mapping(list_lengths)

int_set = random.sample(range(map.capacity), map.capacity)
for number in int_set:
  index_list = map.int_to_indices(number)
  # Use index_list to generate a unique password 
  # from the lists of component elements.

Это создаст все наборы индексов уникальным образом, но в случайном порядке. Переключите комментарии к строкам назначения list_lengths и установите размер выборки. до 10_000, чтобы получить рандомизированную подвыборку уникальных индексов в index_list.

Спасибо за подробное объяснение, в конечном итоге я принял то, что вы сказали, и решил уменьшить размер выборки. Поскольку это генератор парольных фраз для школьников, мы вряд ли когда-либо сгенерируем более нескольких тысяч для одной группы, и если есть 1-2 дублирования, это не представляет большой угрозы безопасности, поскольку они не знают имен пользователей друг друга. Ваше объяснение также помогло мне выбрать новую цель, чтобы она не соответствовала разумным ожиданиям. Еще раз спасибо!

Alec Joy 12.05.2024 22:51

Другие вопросы по теме