☰
Python生成高强度随机密码:random与hashlib实现安全存储
2026/10/21 8:07:18 网站建设 项目流程

1. 别再把生日当密码了,先聊点真实的

我的电脑里躺着一个文本文件,记录了近十年在不同平台注册过的密码格式统计,结果一点都不意外:姓名缩写+生日、纯手机号、某个单词加123,这类密码占了一大半。你说用户不懂安全吗?也不是。大家都听说过“密码要复杂”,可真到注册那一刻,脑子能想到的就只剩生日和家里狗的名字了。所以当我看到“用Python的random和hashlib库生成并加密高强度密码”这个思路时,第一反应是:这确实是个被低估的小项目。它解决的不只是“生成一个难猜的字符串”,而是把“密码生成”和“密码存储”两件事一次讲清楚,最后还能沉淀成一个自己随时能用的命令行小工具。这篇文章就从我实际做过的这个脚本出发,把设计和踩坑过程完整展开。适合有基础Python语法、但对安全实践还不熟悉的开发者,也适合想让身边人摆脱弱密码的普通编程爱好者。

先说清楚这个工具到底能干什么。你运行一条命令,它给你产出一个16位或更长的随机密码,同时用哈希算法生成一段不可逆的摘要,你只需要把摘要存下来。以后你想确认自己有没有记错某个密码,就把输入的密码再哈希一次,跟存储的摘要比对,一致就说明没有输错。整个过程里,真实密码不落地,本地文件里只有哈希值。这比那些把明文密码放在Excel备忘录里的做法,安全等级高出一个量级。你别嫌这工具简单,很多正规系统里密码存储的底层逻辑,本质上也就是这一套东西。

2. 整体设计:random负责“随机”,hashlib负责“不可逆”

2.1 两个模块的分工逻辑

这个项目选了Python标准库里的random和hashlib,很典型,也很合理。先看分工:random模块负责生成随机字符,解决“密码不可猜测”的问题;hashlib模块负责把密码转换成摘要,解决“密码不能明文存储”的问题。生成与存储是两条线,各干各的,合在一起就是一个完整闭环。

random模块是Python内置的伪随机数生成器,基于梅森旋转算法,接口简单,choice、shuffle、randint都很好用。这里要提醒一句:在密码生成这个场景里,random生成的随机数在加密学意义上并不是最强的,因为种子一旦确定或多观察一些输出,理论上存在预测可能性。所以更严肃的推荐是使用secrets模块,它直接调用操作系统的安全随机源,接口和random几乎一样。标题里说的是random,我正文里也会先用random把逻辑讲清楚,然后在关键位置给你替换成secrets的版本。至少对于本地个人工具、临时口令等场景,random也不是不能用,但你要清楚它的边界在哪里。

hashlib模块的作用是单向摘要,它是这套方案安全性的地基。所谓单向,就是从输入算出输出很容易,从输出反推输入在计算上不可行。这就是为什么我们可以放心把摘要存进文件,即使文件被翻到,别人也很难还原出原始密码。注意我一直在说“哈希摘要”而不是“加密”。严格来说,加密(Encryption)是可逆的,需要密钥才能解回来;哈希(Hashing)是不可逆的,没有“解”这个操作。标题里说“加密高强度密码”是通俗说法,实际做的时候咱们用的是哈希,这点一定要搞清。

2.2 一次生成、一次存储的设计链路

整个系统的数据流是这样的:你调用生成函数,得到一串随机密码;紧接着把密码原文传给哈希函数,得到盐值、迭代次数和摘要;把这三样拼接成一条记录保存到本地文件;然后在真正需要使用时,用户输入候选密码,系统用相同盐值和迭代次数计算摘要,再与存储摘要对比,返回是否匹配。

这链路里最关键的一点是,密码原文在生成之后只存在于内存里,绝不落盘。如果你的工具支持“生成后顺便保存到文件”,保存的也必须是哈希记录,而不是随机密码本身。这个设计习惯如果你一开始就养成,以后做web后端、做桌面应用、做任何涉及用户密码的系统时,都会少写很多违规操作。我在帮某公司审查过的一个模拟项目里,就发现他们把用户密码直接存在SQLite的明文列里,整张表一旦被拖走,后果就是所有账号裸奔。所以哪怕只是自己写个小脚本,也建议用哈希的方式做,把习惯练对。

2.3 为什么密码哈希不能直接算一次SHA256

很多人第一次接触到的是这样写的:

import hashlib digest = hashlib.sha256(b"mypassword").hexdigest()

然后存下来,就觉得自己完成密码存储了。这个方向没错,但只做一次SHA256远远不够。原因有两个。

第一,同样的密码会得到同样的哈希值。张三设了mypassword,李四也设了mypassword,数据库中两条哈希完全一致。攻击者只要看到重复摘要,就能推断这两个账号密码相同;如果某个常见密码命中,其它同摘要的账号也全部暴露。

第二,攻击者可以预先准备一份常用密码表,对表里的每个候选密码计算哈希,然后跟存储的摘要做对比,快速找出原始密码。这种预计算表有个形象的名字叫彩虹表。对付它的办法,就是让每个密码拥有自己专属的“盐”。

3. 生成高强度随机密码的实操代码

3.1 基础版本:六行代码生成随机密码

先写最简单的一段,让你能快速看到效果:

import random import string def generate_password(length=16): chars = string.ascii_letters + string.digits + string.punctuation return ''.join(random.choice(chars) for _ in range(length)) print(generate_password(16))

这段代码逻辑非常直白:把大小写字母、数字、标点符号拼成一个字符池,然后随机取16次,拼成字符串。string模块里的预设常量是这种场景的好帮手,string.ascii_letters包含52个字母,string.digits包含10个数字,string.punctuation包含32个常见符号,加起来是94个字符。

跑一次能看到类似这样的输出:

q#7Xp@2Lm!9Rt%4W

基础版已经能用了,但你很快会碰到两个实际问题。一是有些网站强制要求密码里必须同时包含大小写字母、数字和特殊字符,而完全随机生成的16位密码有概率缺某一类,结果就是被注册表单拦下来。二是随机字符里可能出现0和O、1和l、I和|这类容易混淆的字符,抄写的时候非常痛苦。

3.2 进阶版本:保证四种字符类型一定出现

针对这两个问题,我改进了一版。思路是先从四类字符里各抽一个,保证底线;再从全字符池里补齐剩余位数;最后洗牌打乱顺序,避免出现“前四位永远是大小写字母数字符号”的固定规律:

import random import string def generate_password(length=16, exclude_ambiguous=True): if length < 4: raise ValueError("长度至少需要4位,因为要保证四类字符各出现一次") lower = string.ascii_lowercase upper = string.ascii_uppercase digits = string.digits punctuation = string.punctuation # 可选:移除容易混淆的字符 if exclude_ambiguous: lower = lower.replace('l', '') upper = upper.replace('I', '').replace('O', '') digits = digits.replace('0', '').replace('1', '') pool = lower + upper + digits + punctuation password_chars = [ random.choice(lower), random.choice(upper), random.choice(digits), random.choice(punctuation), ] password_chars += [random.choice(pool) for _ in range(length - 4)] random.shuffle(password_chars) return ''.join(password_chars)

注意这里的洗牌环节。我见过有的实现里,前四个字符固定是“小写+大写+数字+符号”,虽然密码本身仍然随机,但模式一旦被识别,理论上的搜索空间会略微减小。洗牌可以把这个弱点抹掉。这里用random.shuffle就够,因为洗牌只影响排列顺序,字符本身是通过随机抽取产生的,不会因为洗牌用的随机源强度低就引入实际风险。

排除易混淆字符是可选项。如果你生成的密码是给自己粘贴复制用,可以保留全部94个字符;如果是打印在纸上临时使用,建议排除,能大幅减少看错抄错的情况。我在自己电脑上生成临时WiFi密码时,就一定开启排除,否则半夜连网输密码会输到怀疑人生。

3.3 用密码学安全随机源替换random(关键提醒)

聊完random版,得认真提一下secrets了。Python官方文档里明确写过,random模块不适合用于安全相关的场景,比如生成密码、密钥、会话标识等。原因在于random是可预测的伪随机序列,如果有人拿到了你的部分输出,理论上能推算后续输出。secrets模块则基于操作系统的随机源,每次调用都会从系统熵池里取随机性,更适合密码学场景。

好在迁移成本极低。把上面的代码里的导入和随机调用改成secrets就行:

import secrets import string def generate_password_secure(length=16, exclude_ambiguous=True): if length < 4: raise ValueError("长度至少需要4位") lower = string.ascii_lowercase upper = string.ascii_uppercase digits = string.digits punctuation = string.punctuation if exclude_ambiguous: lower = lower.replace('l', '') upper = upper.replace('I', '').replace('O', '') digits = digits.replace('0', '').replace('1', '') pool = lower + upper + digits + punctuation password_chars = [ secrets.choice(lower), secrets.choice(upper), secrets.choice(digits), secrets.choice(punctuation), ] password_chars += [secrets.choice(pool) for _ in range(length - 4)] random.shuffle(password_chars) return ''.join(password_chars)

注意secrets没有shuffle方法,这里仍用random.shuffle洗牌。刚才已经解释过,不会引入安全问题。所以你的最终脚本里可以做一个开关:默认用secrets,如果实在需要复现或者只是测试,再退回random。我的建议是别纠结,直接默认secrets。既然自己做密码管理工具,目的就是提高安全水平,没必要在随机源这块留个弱项。

3.4 密码强度到底怎么评估

生成了随机字符串,怎么跟别人解释它“强”在哪里?业内常用“熵”来衡量不可猜测性。熵的单位是bit,计算方法是对字符集大小取以2为底的对数,再乘以长度。

举个直观例子。生日密码通常是8位数字,字符集只有10种,熵约等于8 * log2(10),也就是26.6 bit。这意味着攻击者平均尝试2的26.6次方次就能猜中,这在小规模脚本扫描下很容易被撞开。而16位随机混合密码,字符集94种,熵大约是16 * log2(94),约104.7 bit。这两个数字的差距不是两倍四倍,而是指数级别的。每增加一个bit,猜测工作量翻一倍。从26.6涨到104.7,工作量大约是2的78次方倍,已经超出人类直觉能感知的范围了。

所以我在工具里加了一个简易的强度评估函数:

def estimate_entropy(password): pool_size = 0 if any(c.islower() for c in password): pool_size += 26 if any(c.isupper() for c in password): pool_size += 26 if any(c.isdigit() for c in password): pool_size += 10 if any(c in string.punctuation for c in password): pool_size += 32 if pool_size == 0: return 0 return round(len(password) * math.log2(pool_size), 1)

严格来说,这个函数只能估算复杂度,并不能衡量密码是否来自随机生成、是否在密码词典里出现过,但对于日常使用,它已经能帮你直观看出“生日密码”和“16位随机密码”之间的巨大差异。你可以在生成后顺手打印一行,看到104.7这个数字时,对随机密码的信心会强很多。

4. 用hashlib把密码存储成“不可逆摘要”

4.1 第一版:直接SHA256的问题

密码生成完毕,接下来是存储问题。最朴素的做法是直接用hashlib对密码做SHA256摘要。

import hashlib password = "x7Y@b2Lm!9Rt%4W" digest = hashlib.sha256(password.encode("utf-8")).hexdigest() print(digest)

输出是一串64位的十六进制字符串,看着确实像“加密”了。把这段存进文件,似乎原始密码不落地了。但正如前面分析,直接SHA256有两个致命伤:无盐导致相同密码产生相同摘要,容易被彩虹表逆向匹配。这个版本适合用来做文件完整性校验、缓存去重,但绝对不能用于密码存储。我自己早期第一次写密码存储时就踩过这个坑,存了一堆SHA256的摘要,自己看着觉得挺安全,后来了解彩虹表原理后才出了一身冷汗。

4.2 第二版:加盐打散相同的密码

解决相同密码摘要相同的问题,办法是加盐。盐就是一段随机数据,在计算摘要前拼到密码后面。由于每个用户盐不同,即使两人密码完全一样,最终摘要也完全不同。

import hashlib import os salt = os.urandom(16) password = b"x7Y@b2Lm!9Rt%4W" digest = hashlib.sha256(salt + password).hexdigest()

盐的生成推荐用os.urandom(16),16字节即128位随机数,这个长度已经足够防止碰撞攻击。这里核心的一点:盐必须是随机的,而且每个密码条目都要单独生成。如果你图省事,给所有密码用同一个盐,那就等于把“相同密码相同摘要”的问题又搬回来了,只是换了个形式。

单次加盐哈希比裸SHA256强不少,但还没到推荐级别。因为SHA256计算速度极快,现代GPU每秒可以算几亿次。攻击者拿到摘要文件后,可以用硬件并行地尝试海量字典。所以我们需要“故意减速”。

4.3 第三版:用PBKDF2拉伸口令,拖慢暴力枚举

hashlib里其实包含了一个专门用于口令派生的函数:pbkdf2_hmac。PBKDF2的全称是Password-Based Key Derivation Function 2,核心逻辑就是反复对密码加盐哈希数千次甚至数万次,把计算时间拉长到可接受的程度。它不会改变密码本身的复杂度,但会让每一次猜测尝试变慢,进而让大规模枚举变得不划算。

import hashlib import os password = b"x7Y@b2Lm!9Rt%4W" salt = os.urandom(16) iterations = 200_000 dk = hashlib.pbkdf2_hmac("sha256", password, salt, iterations) print(dk.hex())

这里的迭代次数是关键参数。我在本地试过,200000次迭代大约需要几十毫秒,用它做一次密码验证可以接受。如果换到性能较弱的设备,可以降低到100000;如果机器性能很强,往上提到600000也没有问题。原则是:在你可接受延迟的上限内,迭代次数越高越好。

为什么这个方案比直接加盐SHA256更安全?因为SHA256设计之初追求的就是快,而PBKDF2追求的是可控地慢。密码学里有个概念叫“密钥拉伸”,本质就是把输入通过大量重复计算变成输出,让暴力破解的代价成倍上升。除了PBKDF2,hashlib还提供了scrypt,它的内存占用量更大,对GPU和专用硬件更不友好。如果你愿意,直接换成hashlib.scrypt也行,同样传入密码、盐和参数,实现成本也不高。

4.4 验证密码的完整代码

生成摘要只完成了一半,另一半是“验证”。用户输入候选密码后,必须要能判断它是否等于原始密码。因为哈希不可逆,验证的方法是拿同样的盐、同样的迭代次数,对候选密码重新计算PBKDF2,再比较结果是否一致。

import base64 import hashlib import hmac import os def hash_password(password: str, iterations: int = 200_000): salt = os.urandom(16) dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) salt_b64 = base64.b64encode(salt).decode("utf-8") hash_b64 = base64.b64encode(dk).decode("utf-8") return f"pbkdf2_sha256${iterations}${salt_b64}${hash_b64}" def verify_password(password: str, stored: str) -> bool: try: algorithm, iterations_str, salt_b64, hash_b64 = stored.split("$") except ValueError: return False iterations = int(iterations_str) salt = base64.b64decode(salt_b64) expected = base64.b64decode(hash_b64) dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) return hmac.compare_digest(dk, expected)

注意两个细节。第一,存储字符串用$分隔字段,算法名、迭代次数、盐、摘要全部放在一条记录里,这种设计是参考了许多成熟框架的通行做法,解析起来非常方便。第二,比较摘要时用hmac.compare_digest,而不是普通的==。因为==在比较字节串时一旦遇到不匹配会提前返回,时间差异可以被网络侧测量,从而产生时序侧信道问题。compare_digest在内部恒定时间比较,能把这个潜在泄露口堵上。这两个细节看似微小,但对安全代码来说就是专业和业余的分水岭。

5. 整合成命令行小工具

5.1 完整脚本结构

前面几节的函数都很完整了,现在把它们粘合成一个真正的命令行工具。我给它起名叫pass_tool.py,支持三个子命令:generate、store、verify。

generate负责生成密码并打印;store负责生成密码后立刻计算哈希,把记录写入本地文件;verify负责读取本地文件中的哈希记录,校验用户输入的密码是否匹配。下面是我整理的完整骨架:

import argparse import base64 import hashlib import hmac import os import random import secrets import string ITERS = 200_000 STORE_FILE = "passwords.txt" def generate_password(length=16, use_secrets=True, exclude_ambiguous=True): if length < 4: raise ValueError("length must be >= 4") lower = string.ascii_lowercase upper = string.ascii_uppercase digits = string.digits punct = string.punctuation if exclude_ambiguous: lower = lower.replace('l', '') upper = upper.replace('I', '').replace('O', '') digits = digits.replace('0', '').replace('1', '') pool = lower + upper + digits + punct rng = secrets if use_secrets else random chars = [rng.choice(lower), rng.choice(upper), rng.choice(digits), rng.choice(punct)] chars += [rng.choice(pool) for _ in range(length - 4)] random.shuffle(chars) return ''.join(chars) def hash_password(password: str, iterations: int = ITERS): salt = os.urandom(16) dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) salt_b64 = base64.b64encode(salt).decode("utf-8") hash_b64 = base64.b64encode(dk).decode("utf-8") return f"pbkdf2_sha256${iterations}${salt_b64}${hash_b64}" def verify_password(password: str, stored: str) -> bool: try: _, iterations_str, salt_b64, hash_b64 = stored.split("$") except ValueError: return False iterations = int(iterations_str) salt = base64.b64decode(salt_b64) expected = base64.b64decode(hash_b64) dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) return hmac.compare_digest(dk, expected) def cmd_generate(args): pwd = generate_password(args.length, use_secrets=args.secure) print(f"生成的密码: {pwd}") print(f"熵估计: {estimate_entropy(pwd)} bit") def cmd_store(args): pwd = generate_password(args.length, use_secrets=args.secure) record = hash_password(pwd, args.iterations) with open(STORE_FILE, "a", encoding="utf-8") as f: f.write(f"{args.name}${record}\n") print(f"密码已生成并保存,记录名: {args.name}") print(f"密码原文: {pwd}")

这个脚本里,我特意保留了密码原文打印,因为如果不打印,用户没法拿到自己生成的密码去注册网站。但你要记得,打印出来的密码不要贴在聊天记录里,用完就删掉是基本素养。

5.2 使用方式与输出格式

实际跑一下效果:

python pass_tool.py generate --length 20

输出示例:

生成的密码: J4v$xR8pK2wN!mQ7yL3d 熵估计: 130.9 bit 42392

再执行store:

python pass_tool.py store --name github --length 20

输出示例:

密码已生成并保存,记录名: github 密码原文: mF8?Tz2qXv5C!bN7wR4p

这时passwords.txt文件里追加了一行记录:

github$pbkdf2_sha256$200000$base64salt==$base64hash==

这一行里,永远找不到密码原文。即使文件泄露,攻击者也只能拿到一段无法逆推的摘要。

执行verify时,脚本会读取记录、请你输入密码、重新计算摘要并比对:

python pass_tool.py verify --name github

如果输入的是刚才生成的密码,输出是验证通过;如果输入错一个字母,输出就是验证失败。

5.3 存储策略与注意事项

文件存储本身有一些值得优化的小细节。比如现在用的是追加写入,同一条记录名会重复出现;更合理的是用字典结构按记录名覆盖。再比如文件权限,在Linux和macOS上用chmod 600 passwords.txt限定只有当前用户可读写,在Windows上至少不要放在公共目录。我自己实际使用的习惯是,把这个脚本和密码文件都放在一个没有云同步的本地目录里,避免某天云盘里的密码库被拖走。

还应该强调一点:哈希记录是“一次性写入、长期验证”的。假如你忘记了某个平台的密码,哈希不可逆,工具只能告诉你“你输入的密码不正确”,没办法帮你找回原文。这时最好的策略是直接重新生成一个新密码,覆盖旧记录。这也是哈希存储的代价之一。想清楚了这一点,你才会真正明白为什么那些找回密码的功能不是把旧密码发给你,而是让你重置一个新密码。

6. 常见问题与避坑经验

6.1 几个高频问题速查表

现象根本原因解决办法
每次运行生成相同密码显式设置了random.seed,或直接吃了一个固定种子去掉固定seed,统一用secrets模块
生成的密码被某网站拒绝完全随机时缺少数字或特殊字符使用4.2中的进阶版本,保证四类字符各出现一次
两边哈希不一致,验证失败盐值每次不同,没按记录中的盐重新计算验证时从存储字符串解析出原盐,而不是重新生成盐
密码文件被打开直接能看到所有记录文件权限过松使用chmod 600或操作系统级加密卷
感觉PBKDF2太慢,想降低迭代次数对安全逻辑不熟悉,急于求快迭代次数不要低于10万,性能允许时尽量提高

6.2 我踩过的几个坑和最终建议

第一个坑是固定seed。很早之前我图省事,在脚本开头写了一句random.seed(42),想着方便测试复现,结果忘了删。那一周生成的所谓“随机密码”其实都一样,如果搭配外部服务使用,等于所有账号用了同一个密码。后来我把脚本改成了默认使用secrets,才把这个问题从根上解决。

第二个坑是哈希比较。最初版本我用dk == expected比较摘要,跑起来也没什么问题。后来看了恒定时间比较的资料才明白,这个差距虽然细微,但确实是真实存在的侧信道风险。换成hmac.compare_digest之后,代码逻辑几乎没变化,安全性却扎实了一截。

第三个坑是命令行传密码。我在某个版本里支持了verify --password abc123这种参数,方便倒是方便,但命令行的历史记录里会把密码原文留下来。后来我改用隐藏输入方式,或者从环境变量里读取,再没有把敏感信息直接暴露在进程列表里。这个细节对个人工具可能不是大问题,但如果你以后写多人用的系统,一定不要在命令行参数里接受密码。

第四个坑是关于“哈希不可逆”的认知误区。有朋友拿着保存的哈希记录问我,能不能写个脚本反推出原来的密码。这是个很常见的诉求,但不可逆变的设计就是这样:找回密码的唯一方式就是重置。想通这点后,你会更理解为什么现代系统都采用“重置”而不是“找回”密码的设计。

最后再说一点个人体会。做完这个小工具后,我顺手把家里那台总是用生日当密码的NAS、路由器、云服务器账号全部换成了16位随机密码,哈希记录存在本地。每次登录需要密码时,就运行验证命令,用主机自动校验而不是反复翻记录文档。整个过程前后花了不到一小时,换来的却是长期的安全感提升。密码管理这件事不难,难的是迈出第一步。你手头如果也有生日当密码的习惯,不妨今天就用这段代码把密码换掉,然后认真保存好哈希记录。等你习惯了这套流程,再回头看那些到处复制粘贴密码的日子,会庆幸自己当时动了手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询