足够安全的8位短唯一随机字符串

71

我正在尝试为成千上万的文件计算8个字符的短唯一随机文件名,以避免可能的命名冲突。这种方法安全吗?

base64.urlsafe_b64encode(hashlib.md5(os.urandom(128)).digest())[:8]

编辑

为了更清晰,我正在尝试实现对上传到存储中的文件名称进行最简单的混淆。

我发现采用8个字符的随机字符串是一种非常有效且简单的方法,可以正确地实现存储数万个文件而不会产生冲突。我不需要确保唯一性,只需要高概率避免名称冲突(仅涉及数千个名称)。

由于文件在并发环境中被存储,因此增加共享计数器是可行但复杂的。将计数器存储在数据库中效率低下。

我还面临这样一个事实,即在某些情况下,不同进程中的random()函数会返回相同的伪随机序列。


3
“安全足够”,是指“我根本不需要处理碰撞”,还是“碰撞很少发生,如果我处理得不够高效也没关系”? - abarnert
3
停止使用MD5!它是一种损坏的哈希函数,因为其PRNG输出存在问题,使输出结果变得不够随机。请改用其他替代方案。 - rook
4
@Rook: +1。人们通常使用它是因为"它比库中其他所有东西都快得多,而且我不 真的 需要安全散列……" 但是当然,如果您真的不需要安全散列,那么您也不需要MD5,那么为什么不一开始就使用urlsafe_b64encode(os.urandom(6))呢? - abarnert
2
@Rook 在这里使用MD5虽然毫无意义,但不会增加碰撞率。 - Nick Johnson
3
@PhilGan:你在用什么语言写那里? - abarnert
显示剩余4条评论
8个回答

79

哪种方法具有更少的冲突,速度更快,易于阅读?

简短回答

random_choice最快的,冲突最少的方法,但在我看来稍微难以阅读

shortuuid_random最易读的方法,但它需要外部依赖,稍微慢一些,而且有6倍的冲突。

这些方法


alphabet = string.ascii_lowercase + string.digits
su = shortuuid.ShortUUID(alphabet=alphabet)

def random_choice():
    return ''.join(random.choices(alphabet, k=8))

def truncated_uuid4():
    return str(uuid.uuid4())[:8]

def shortuuid_random():
    return su.random(length=8)

def secrets_random_choice():
    return ''.join(secrets.choice(alphabet) for _ in range(8))

结果

所有方法都使用abcdefghijklmnopqrstuvwxyz0123456789字母表生成8个字符的UUID。冲突是从一次运行中的1000万次绘制计算出来的。时间报告为平均函数执行时间±标准偏差,二者都是在100次1,000次绘制的运行中计算出的。总时间是冲突测试的总执行时间。

random_choice: collisions 22 - time (s) 0.00229 ± 0.00016 - total (s) 29.70518
truncated_uuid4: collisions 11711 - time (s) 0.00439 ± 0.00021 - total (s) 54.03649
shortuuid_random: collisions 124 - time (s) 0.00482 ± 0.00029 - total (s) 51.19624
secrets_random_choice: collisions 15 - time (s) 0.02113 ± 0.00072 - total (s) 228.23106

注释

  • 默认的shortuuid字母表包含大写字符,因此会产生较少的冲突。为了进行公平比较,我们需要选择与其他方法相同的字母表。
  • secrets模块的token_hextoken_urlsafe方法可能更快,但它们具有不同的字母表,因此不能参与比较。
  • alphabet和基于类的shortuuid方法被拆分为模块变量,从而加速方法执行。这不应影响TLDR。

完整测试细节

import random
import secrets
from statistics import mean
from statistics import stdev
import string
import time
import timeit
import uuid

import shortuuid


alphabet = string.ascii_lowercase + string.digits
su = shortuuid.ShortUUID(alphabet=alphabet)


def random_choice():
    return ''.join(random.choices(alphabet, k=8))


def truncated_uuid4():
    return str(uuid.uuid4())[:8]


def shortuuid_random():
    return su.random(length=8)


def secrets_random_choice():
    return ''.join(secrets.choice(alphabet) for _ in range(8))


def test_collisions(fun):
    out = set()
    count = 0
    for _ in range(10_000_000):
        new = fun()
        if new in out:
            count += 1
        else:
            out.add(new)
    return count


def run_and_print_results(fun):
    round_digits = 5
    now = time.time()
    collisions = test_collisions(fun)
    total_time = round(time.time() - now, round_digits)

    trials = 1_000
    runs = 100
    func_time = timeit.repeat(fun, repeat=runs, number=trials)
    avg = round(mean(func_time), round_digits)
    std = round(stdev(func_time), round_digits)

    print(f'{fun.__name__}: collisions {collisions} - '
          f'time (s) {avg} ± {std} - '
          f'total (s) {total_time}')


if __name__ == '__main__':
    run_and_print_results(random_choice)
    run_and_print_results(truncated_uuid4)
    run_and_print_results(shortuuid_random)
    run_and_print_results(secrets_random_choice)

我更喜欢使用shortuuid ^^ https://dev59.com/dWYr5IYBdhLWcg3wq8CI#55890039 - Nam G VU
1
@NamGVU shortuuidrandom.choices 稍微慢一些,冲突也更多,但绝对更易读。 - Oleg

68

你目前的方法应该足够安全,但你也可以查看 uuid 模块。例如:

import uuid

print str(uuid.uuid4())[:8]

输出:

ef21b9ad

11
请注意,uuid4 中的 "4" 部分非常重要。例如,"uuid1" 生成带有基于主机 ID 的固定前缀的字符串。 - BoppreH
49
这让我在10万个名称中产生了3次冲突。这可能是一个不好的方法,因为你只使用了16个字符。 - JBernardo
18
我在其他地方读到,截断 UUID 并不是生成短随机字符串的好方法。 - zahory
5
@zahory:部分原因是BoppreH提出的观点。在您了解每种类型的UUID如何工作之前,您必须知道以不同方式截断它们的安全性有多高。 - abarnert
23
在对一个包含一百万个元素的列表进行测试时,出现了43981次碰撞。 - sandiprb
显示剩余3条评论

39
你可以尝试使用 shortuuid 库。
安装方式为:pip install shortuuid 然后只需要这样简单操作:
> import shortuuid
> shortuuid.uuid()
'vytxeTZskVKR7C7WgdSP3d'

10
shortuuid.uuid()[:8]进行了一亿次测试,没有发现冲突。但是似乎会牺牲一定的速度。 - dangel
30
仅供娱乐,10亿次抽奖结果中有55次重复。 - dangel
1
在我看来,这应该是被选中的答案! - Nam G VU
我猜这是一个Base64编码的UUID,去掉了填充符“=”? - Nick
我看到他们实际上在使用Base57,而没有使用Base64中的任何非字母数字字符。很棒! - Nick

29

你为什么不能使用 tempfile 生成文件名?

mkstempNamedTemporaryFile 这样的函数绝对能够给你唯一的文件名;基于随机字节的方法无法保证这一点。

如果由于某种原因你实际上不想创建该文件(例如,你正在生成用于某个远程服务器的文件名),那么你是无法完全保证安全的,但是相比随机生成的名称,mktemp 仍然更加安全。

或者只需在“足够全局”的某个位置存储一个48位计数器,这样就可以确保在发生冲突之前经过完整的名称循环,并且还可以保证在何时会发生冲突。

它们都比读取 urandom 并进行 md5 更安全、更简单、更高效。

如果你真的希望生成随机名称,''.join(random.choice(my_charset) for _ in range(8)) 的方法也比你所做的更简单、更高效。即使使用 urlsafe_b64encode(os.urandom(6)) 也与 MD5 哈希一样随机,并且更加简单和高效。

密码学随机性和/或密码哈希函数的唯一好处在于避免可预测性。如果这对你不是问题,为什么要付出这个代价呢?而且,如果你确实需要避免可预测性,你几乎肯定需要避免竞争和其他更简单的攻击,因此避免使用 mkstempNamedTemporaryFile 是一个非常糟糕的想法。

更不用说,正如 Root 在评论中指出的那样,如果你需要安全性,MD5 实际上并不能提供它。


我正在重命名上传到同一位置的文件,因此没有使用tempfileUuid4对于任何内容都足够唯一,但太长了。我相信8个字符足以存储数千个名称而不发生冲突,但我不确定如何做到这一点。通过哈希urandom,我试图实现更好的随机性。 - zahory
1
@NickJohnson 这个文件名太长了,不好看 :) 在列表中,长的文件名看起来很丑陋。正如我之前所写的那样,使用8个字符应该足以存储数百个随机唯一名称,而不会发生概率碰撞,我只是不知道如何增加不可预测性的最佳方式是什么。 - zahory
4
根据我的测试,''.join([random.choice(string.ascii_letters+string.digits+'-_') for ch in range(8)]) 在数百万个字符串中没有发现冲突,并且比使用 urandom() 编码要更加高效。因此我决定采用这种方式。 - zahory
@abarnert 谢谢你提供 mktemp 的想法。我需要调查一下它的效率以及生成名称的方法(我想知道它是否会触碰文件系统?)。 - zahory
mktemp 只能保证文件在一个文件系统上是唯一的。它不能保证上传服务器上的文件名是唯一的。此外,一旦文件从本地系统中删除,文件名就可以被重新使用。 - shrewmouse
显示剩余6条评论

8
从Python 3.6开始,你应该使用secrets模块。对于你的情况,secrets.token_urlsafe()似乎可以很好地工作,并且它保证使用密码安全的随机源。

4

最快的确定性方法

import random
import binascii
e = random.Random(seed)
binascii.b2a_base64(random.getrandbits(48).to_bytes(6, 'little'), newline=False)

最快的系统随机方法

import os
import binascii
binascii.b2a_base64(os.urandom(6), newline=False)

安全的URL方法

使用os.urandom

import os
import base64
base64.urlsafe_b64encode(os.urandom(6)).decode()

使用random.Random.choices(慢但灵活)

import random
import string
alphabet = string.ascii_letters + string.digits + '-_'
''.join(random.choices(alphabet, k=8))

使用 random.Random.getrandbits (比 random.Random.randbytes 更快)
import random
import base64
base64.urlsafe_b64encode(random.getrandbits(48).to_bytes(6, 'little')).decode()

使用 random.Random.randbytes (Python >= 3.9)

import random
import base64
base64.urlsafe_b64encode(random.randbytes(6)).decode()

使用 random.SystemRandom.randbytes (Python >= 3.9)

import random
import base64
e = random.SystemRandom()
base64.urlsafe_b64encode(e.randbytes(6)).decode()

random.SystemRandom.getrandbits不建议在Python版本>=3.9中使用,因为与random.SystemRandom.randbytes相比需要的时间多2.5倍,并且更加复杂。

请使用secrets.token_bytes(Python >= 3.6)。

import secrets
import base64
base64.urlsafe_b64encode(secrets.token_bytes(6)).decode()

使用 secrets.token_urlsafe (Python >= 3.6)

import secrets
secrets.token_urlsafe(6) # 6 byte base64 has 8 char

更进一步的讨论

Python 3.9中secrets.token_urlsafe的实现

tok = token_bytes(nbytes)
base64.urlsafe_b64encode(tok).rstrip(b'=').decode('ascii')

由于ASCII字节的.decode().decode('ascii')更快,当nbytes % 6 == 0时,.rstrip(b'=')无用。

base64.urlsafe_b64encode(secrets.token_bytes(nbytes)).decode()更快(约20%)。

在Windows10上,当nbytes为6(8个字符)时,基于字节的方法比文本方法快2倍,当nbytes为24(32个字符)时,快5倍。

在Windows 10(我的笔记本电脑)上,secrets.token_bytesrandom.Random.randbytes需要类似的时间,而base64.urlsafe_b64encode生成比随机字节慢。

在Ubuntu 20.04(我的云服务器,可能缺乏熵),secrets.token_bytes需要比random.Random.randbytes慢15倍,但需要的时间与random.SystemRandom.randbytes相似。

由于secrets.token_bytes使用random.SystemRandom.randbytes使用os.urandom(因此它们完全相同),如果性能很关键,可以将secrets.token_bytes替换为os.urandom

在Python3.9中,base64.urlsafe_b64encodebase64.b64encodebytes.translate的组合,因此需要更多时间(约30%)。

random.Random.randbytes(n)通过random.Random.getrandbits(n * 8).to_bytes(n, 'little')实现,因此慢3倍。(但是,random.SystemRandom.getrandbits是使用random.SystemRandom.randbytes实现的)

base64.b32encodebase64.b64encode慢得多(对于6个字节慢5倍,对于480个字节慢17倍),因为在base64.b32encode中有许多Python代码,而base64.b64encode只调用binascii.b2a_base64(C实现)。

然而,在base64.b64encode中存在一个Python分支语句if altchars is not None:,会在处理小数据时引入非常可观的开销,binascii.b2a_base64(data, newline=False)可能更好。


2
我正在使用hashids将时间戳转换为唯一的ID。(如果需要,您甚至可以将其转换回时间戳)。

但是,缺点是如果您创建ID太快,就会出现重复。但是,如果您在它们之间生成时间,则这是一个选项。

以下是示例:

from hashids import Hashids
from datetime import datetime
hashids = Hashids(salt = "lorem ipsum dolor sit amet", alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890")
print(hashids.encode(int(datetime.today().timestamp()))) #'QJW60PJ1' when I ran it

1
你可以尝试这个。
import random
uid_chars = ('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u',
             'v', 'w', 'x', 'y', 'z','1','2','3','4','5','6','7','8','9','0')
uid_length=8
def short_uid():
    count=len(uid_chars)-1
    c=''
    for i in range(0,uid_length):
        c+=uid_chars[random.randint(0,count)]
    return c

例如:

print short_uid()
nogbomcv

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接