2026/10/21 8:07:21

Python生成高强度随机密码:random与hashlib实现安全存储

Python生成高强度随机密码:random与hashlib实现安全存储 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(bmypassword).hexdigest()然后存下来就觉得自己完成密码存储了。这个方向没错但只做一次SHA256远远不够。原因有两个。第一同样的密码会得到同样的哈希值。张三设了mypassword李四也设了mypassword数据库中两条哈希完全一致。攻击者只要看到重复摘要就能推断这两个账号密码相同如果某个常见密码命中其它同摘要的账号也全部暴露。第二攻击者可以预先准备一份常用密码表对表里的每个候选密码计算哈希然后跟存储的摘要做对比快速找出原始密码。这种预计算表有个形象的名字叫彩虹表。对付它的办法就是让每个密码拥有自己专属的“盐”。3. 生成高强度随机密码的实操代码3.1 基础版本六行代码生成随机密码先写最简单的一段让你能快速看到效果import random import string def generate_password(length16): 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#7Xp2Lm!9Rt%4W基础版已经能用了但你很快会碰到两个实际问题。一是有些网站强制要求密码里必须同时包含大小写字母、数字和特殊字符而完全随机生成的16位密码有概率缺某一类结果就是被注册表单拦下来。二是随机字符里可能出现0和O、1和l、I和|这类容易混淆的字符抄写的时候非常痛苦。3.2 进阶版本保证四种字符类型一定出现针对这两个问题我改进了一版。思路是先从四类字符里各抽一个保证底线再从全字符池里补齐剩余位数最后洗牌打乱顺序避免出现“前四位永远是大小写字母数字符号”的固定规律import random import string def generate_password(length16, exclude_ambiguousTrue): 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(length16, exclude_ambiguousTrue): 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 x7Yb2Lm!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 bx7Yb2Lm!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 bx7Yb2Lm!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追求的是可控地慢。密码学里有个概念叫“密钥拉伸”本质就是把输入通过大量重复计算变成输出让暴力破解的代价成倍上升。除了PBKDF2hashlib还提供了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 fpbkdf2_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(length16, use_secretsTrue, exclude_ambiguousTrue): 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 fpbkdf2_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_secretsargs.secure) print(f生成的密码: {pwd}) print(f熵估计: {estimate_entropy(pwd)} bit) def cmd_store(args): pwd generate_password(args.length, use_secretsargs.secure) record hash_password(pwd, args.iterations) with open(STORE_FILE, a, encodingutf-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再执行storepython 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位随机密码哈希记录存在本地。每次登录需要密码时就运行验证命令用主机自动校验而不是反复翻记录文档。整个过程前后花了不到一小时换来的却是长期的安全感提升。密码管理这件事不难难的是迈出第一步。你手头如果也有生日当密码的习惯不妨今天就用这段代码把密码换掉然后认真保存好哈希记录。等你习惯了这套流程再回头看那些到处复制粘贴密码的日子会庆幸自己当时动了手。