Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第二十六 口令(password)

口令概述

计算机、互联网中为了保护系统安全、认证用户身份,经常使用账号/密码(准确来说应该为“口令“)对用户进行访问控制。口令(password)是信息安全领域最古老、最广泛使用的身份认证机制,从1961年 MIT 的 CTSS 时间共享系统首次引入口令机制至今,口令仍然是绝大多数系统用户认证的第一道防线。

口令在计算机互联网中扮演非常重要的作用。据统计,超过80%的数据泄露事件与弱口令或口令管理不当有关。理解口令安全原理、掌握口令保护技术,是每个开发者和安全从业者的必修课。

口令安全威胁

口令面临着多种攻击方式的威胁,了解这些威胁是保护口令安全的第一步。

暴力破解

暴力破解(Brute Force Attack)是最直接的攻击方式,攻击者尝试所有可能的字符组合来猜测口令。对于一个长度为 $n$、字符集大小为 $k$ 的口令,其可能的组合数为 $k^n$。

口令长度仅小写字母 ($k=26$)大小写+数字 ($k=62$)大小写+数字+符号 ($k=95$)
6 位$3.08 \times 10^8$$5.68 \times 10^{10}$$7.35 \times 10^{11}$
8 位$2.09 \times 10^{11}$$2.18 \times 10^{14}$$6.63 \times 10^{15}$
10 位$1.41 \times 10^{14}$$8.39 \times 10^{17}$$5.99 \times 10^{19}$
12 位$9.54 \times 10^{16}$$3.23 \times 10^{21}$$5.41 \times 10^{23}$

现代 GPU 可以每秒尝试数十亿次简单哈希运算,因此短口令在暴力破解面前几乎不堪一击。

字典攻击

字典攻击(Dictionary Attack)比暴力破解更高效。攻击者使用预先收集的常用密码、常见单词、人名、地名等组成的“字典“,逐一尝试。由于大多数人倾向于使用容易记忆的密码,字典攻击的成功率远高于暴力破解。

根据统计,最常用的弱密码包括:

排名密码出现频率
1123456约 1%
2password约 0.8%
312345678约 0.6%
4qwerty约 0.4%
5abc123约 0.3%
6888888约 0.2%
7111111约 0.2%
8123123约 0.15%

这些密码在字典攻击面前毫无抵抗力。

彩虹表攻击

彩虹表攻击(Rainbow Table Attack)是一种利用预计算来加速口令破解的技术。攻击者预先计算大量口令的哈希值,并存储在查找表中(彩虹表)。当获取到系统的哈希值后,只需在表中查找即可反推出原始口令。

彩虹表的核心思想是用空间换时间。设口令空间为 $N$,直接暴力破解需要 $O(N)$ 次哈希运算,而彩虹表预计算后查找只需 $O(N^{2/3})$ 的空间和 $O(N^{2/3})$ 的时间。

防御彩虹表攻击的关键手段是使用盐值(salt),即在每个口令哈希前加入随机数据,使得预计算表失效。

钓鱼攻击

钓鱼攻击(Phishing)不直接攻击口令本身,而是通过伪装成合法网站或服务,诱骗用户主动输入口令。钓鱼攻击的技术手段包括:

  • 邮件钓鱼:发送伪装成银行、社交媒体等官方通知的邮件
  • 网站克隆:搭建与目标网站高度相似的假网站
  • 中间人攻击:在用户与服务器之间截获通信
  • 短信钓鱼(Smishing):通过短信诱导用户访问钓鱼网站

键盘记录与社会工程学

键盘记录(Keylogging)通过在用户设备上安装恶意软件,记录用户的键盘输入来窃取口令。社会工程学(Social Engineering)则通过心理操纵,让用户主动透露口令信息,例如冒充IT支持人员要求用户提供密码。

如何构造强口令

口令如此重要,如何保护好自己的口令呢?首先口令本身应该足够复杂,足够抵御攻击。

密码长度、复杂度与熵

口令的安全强度可以用信息熵(Information Entropy)来量化。信息熵的计算公式为:

$$H = L \times \log_2(k)$$

其中 $L$ 为口令长度,$k$ 为字符集大小。

口令示例长度字符集熵(bit)安全等级
1234566数字(10)19.9极弱
password8小写(26)37.6
P@ssw0rd8混合(95)52.6中等
ARG2on&@!9混合(95)59.2较强
ARG2on&@!略10混合+中文80+

一般来说,熵值达到 60 bit 以上才被认为具有较好的安全性,80 bit 以上则相当安全。

密码短语(Passphrase)

密码短语(Passphrase)是由多个随机单词组成的口令,例如 “correct-horse-battery-staple”。密码短语的核心优势在于:

  • 长度更长:即使每个单词只有4-5个字母,4个单词组成的短语也有16-20个字符
  • 更容易记忆:相比随机字符串,有意义的单词组合更容易记住
  • 熵值更高:假设使用包含7776个单词的词典(类似 diceware),4个单词的熵为 $4 \times \log_2(7776) \approx 51.7$ bit;5个单词则达到约64.6 bit

密码强度评估

前面我们提到密码有强弱之分,那么该如何量化评估一个密码的强度呢?可以参考这篇博客计算密码强度《如何使用Rust编程语言来产生密码或是计算密码强度?密码又该如何安全保存呢?》。

在 Rust 中,我们可以使用 passwords 库来评估密码强度:

#![allow(unused)]
fn main() {
use passwords::{analyzer, scorer};

fn check_password_strength() {
    let pwd = "ARG2on&@!略";
    let analyzed = analyzer::analyze(pwd);
    println!("Password: {}", pwd);
    println!("Length: {} characters", analyzed.length());
    println!("Score: {}", scorer::score(&analyzed));

    // 分析密码组成
    let weak_pwd = "123456";
    let weak_analyzed = analyzer::analyze(weak_pwd);
    println!("\nWeak Password: {}", weak_pwd);
    println!("Score: {}", scorer::score(&weak_analyzed));

    let strong_pwd = "Tr0ub4dor&3xYz!@#";
    let strong_analyzed = analyzer::analyze(strong_pwd);
    println!("\nStrong Password: {}", strong_pwd);
    println!("Score: {}", scorer::score(&strong_analyzed));
}
}

passwords 库的 scorer::score() 函数会综合考虑密码长度、字符多样性、常见模式等因素,返回一个0-100的分数。分数越高,密码强度越大。

口令存储安全

绝不存储明文密码

在系统设计中,有一条铁律:永远不要存储用户的明文密码。历史上无数数据泄露事件证明,存储明文密码是极其危险的行为。即使数据库被攻破,攻击者也不应该能直接获取用户的原始密码。

哈希 + 盐值

正确的口令存储方式是使用密码哈希函数对口令进行单向变换,并附加盐值(salt)来防御彩虹表攻击。

盐值(Salt) 是一个随机生成的字符串,每个用户的盐值都不同。存储时将盐值与口令拼接后再进行哈希:

$$\text{stored} = \text{salt} \parallel \text{hash}(\text{salt} \parallel \text{password})$$

盐值的作用:

  • 使相同口令产生不同的哈希值
  • 使预计算的彩虹表失效
  • 增加攻击者需要独立破解每个口令的成本

Argon2:现代密码哈希算法

Argon2 是密码哈希竞赛(Password Hashing Competition, PHC)的获胜者,被认为是目前最安全的密码哈希算法。Argon2 有三个变体:

  • Argon2d:纯数据依赖型,抗 GPU 攻击最强,但易受侧信道攻击
  • Argon2i:纯独立型,抗侧信道攻击,但对 GPU 攻击的抵抗力稍弱
  • Argon2id:混合型,先进行一遍独立型再进行数据依赖型,兼顾两者优势

Argon2 的核心思想是内存硬(memory-hard):它需要大量内存来完成计算,这使得 GPU、ASIC 等并行计算硬件的优势被大大削弱。

#![allow(unused)]
fn main() {
fn argon2() -> Result<(), argon2::password_hash::errors::Error> {
    use argon2::{
        password_hash::{
            rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
        },
        Argon2,
    };
    use passwords::{analyzer, scorer};

    let pwd = "ARG2on&@!略";
    let password = pwd.as_bytes(); // Bad password; don't actually use!
    let salt = SaltString::generate(&mut OsRng);

    // Argon2 with default params (Argon2id v19)
    let argon2 = Argon2::default();

    // Hash password to PHC string ($argon2id$v=19$...)
    let password_hash = argon2.hash_password(password, &salt)?.to_string();

    // Verify password against PHC string.
    //
    // NOTE: hash params from `parsed_hash` are used instead of what is configured in the
    // `Argon2` instance.
    let parsed_hash = PasswordHash::new(&password_hash)?;
    assert!(Argon2::default()
        .verify_password(password, &parsed_hash)
        .is_ok());

    println!("{}", &password_hash);
    Ok(())
}
}

Argon2 的 PHC 字符串格式为:

$argon2id$v=19$m=19456,t=2,p=1$salt$hash

其中 m 表示内存成本(KB),t 表示时间迭代次数,p 表示并行度。

PBKDF2:基于密钥派生的方案

PBKDF2(Password-Based Key Derivation Function 2)是一种通过多次迭代哈希运算来增加破解成本的算法。PBKDF2 的核心思想是:

$$DK = \text{PBKDF2}(PRF, \text{password}, \text{salt}, c, dkLen)$$

其中 $c$ 为迭代次数,$PRF$ 为伪随机函数(通常使用 HMAC-SHA256 或 HMAC-SHA512)。迭代次数越多,攻击者每次尝试的成本越高。

#![allow(unused)]
fn main() {
fn pbkdf2() {
    use ring::digest;
    const CREDENTIAL_LEN: usize = digest::SHA512_OUTPUT_LEN;
    let n_iter = NonZeroU32::new(100_000).unwrap();
    let rng = rand::SystemRandom::new();

    let mut salt = [0u8; CREDENTIAL_LEN];
    rng.fill(&mut salt);

    let password = "Guess Me If You Can!";
    let mut pbkdf2_hash = [0u8; CREDENTIAL_LEN];
    pbkdf2::derive(
        pbkdf2::PBKDF2_HMAC_SHA512,
        n_iter,
        &salt,
        password.as_bytes(),
        &mut pbkdf2_hash,
    );
    println!("Salt: {}", HEXUPPER.encode(&salt));
    println!("PBKDF2 hash: {}", HEXUPPER.encode(&pbkdf2_hash));

    let should_succeed = pbkdf2::verify(
        pbkdf2::PBKDF2_HMAC_SHA512,
        n_iter,
        &salt,
        password.as_bytes(),
        &pbkdf2_hash,
    );
    let wrong_password = "Definitely not the correct password";
    let should_fail = pbkdf2::verify(
        pbkdf2::PBKDF2_HMAC_SHA512,
        n_iter,
        &salt,
        wrong_password.as_bytes(),
        &pbkdf2_hash,
    );

    assert!(should_succeed.is_ok());
    assert!(!should_fail.is_ok());
}
}

PBKDF2 的迭代次数建议至少 100,000 次(2026年标准),且应随硬件性能提升而定期增加。

bcrypt 与 scrypt

bcrypt 是一种专门为密码哈希设计的算法,内置盐值并使用 Blowfish 密码的变体进行多次加密。bcrypt 的特点是:

  • 内置盐值,无需单独管理
  • 自适应成本因子(cost factor),可随硬件升级调整
  • 故意设计为计算缓慢,增加暴力破解成本

scrypt 在 bcrypt 的基础上增加了内存硬特性,需要大量内存才能完成计算,有效抵御 GPU/ASIC 攻击。scrypt 的参数包括:

  • $N$(CPU/内存成本因子)
  • $r$(块大小)
  • $p$(并行因子)

密码哈希算法对比

特性Argon2bcryptscryptPBKDF2
抗暴力破解优秀良好优秀一般
抗GPU/ASIC优秀(内存硬)一般优秀(内存硬)
抗侧信道攻击良好(Argon2i)一般一般良好
可调参数内存/时间/并行度成本因子CPU/内存/并行度迭代次数
标准化PHC winner广泛使用RFC 7914RFC 2898
推荐程度首选推荐推荐可用
Rust 库argon2rust-bcryptscryptpbkdf2

推荐策略:新项目首选 Argon2id;已有项目使用 bcrypt 也是可接受的;PBKDF2 虽然广泛支持,但在抗 GPU 攻击方面不如前三者。

如何管理你的口令

生活中各种形形色色的密码(口令)数不胜数,如何管理这些密码也是一件十分重要的事情。

密码管理器

使用诸如 LastPass、1Password、Bitwarden 等专业的密码管理器能够同时兼顾密码的安全和便捷。密码管理器的核心功能包括:

  • 自动生成强密码:为每个账号生成唯一的随机强密码
  • 安全存储:使用强加密算法(如 AES-256)存储所有密码
  • 自动填充:在浏览器和移动应用中自动填写密码
  • 跨设备同步:在手机、电脑、平板之间安全同步密码库
  • 安全审计:检测重复密码、弱密码、已泄露密码

单点登录(SSO)

单点登录(Single Sign-On, SSO)允许用户使用一组凭证登录多个相关系统。常见的 SSO 协议包括:

  • OAuth 2.0:授权框架,广泛用于第三方登录(如微信、GitHub 登录)
  • SAML:企业级 SSO 标准,常用于企业内部系统
  • OpenID Connect:基于 OAuth 2.0 的身份认证层

SSO 的优势是减少用户需要记忆的密码数量,但风险在于一旦 SSO 凭证泄露,所有关联系统都将受到影响。

OTP(一次性口令)

OTP(One Time Password,一次性口令)是一种每次使用后即失效的动态密码机制,可以有效防止口令重放攻击。

HOTP:基于计数器的 OTP

HOTP(HMAC-Based One-Time Password)基于一个递增的计数器和共享密钥来生成一次性口令。其计算公式为:

$$HOTP(K, C) = \text{Truncate}(\text{HMAC-SHA1}(K, C))$$

其中 $K$ 为共享密钥,$C$ 为计数器值。Truncate 函数将 HMAC 的输出截取为指定位数(通常6位)的数字。

HOTP 的问题是客户端和服务端的计数器必须保持同步,如果客户端生成了 OTP 但未使用(计数器递增),服务端的计数器就会落后,需要通过“滑动窗口“机制来容错。

TOTP:基于时间的 OTP

TOTP(Time-Based One-Time Password)是 HOTP 的改进版本,使用时间戳代替计数器:

$$TOTP(K, T) = HOTP(K, T)$$

其中 $T = \lfloor \frac{\text{current_time} - T_0}{X} \rfloor$,$T_0$ 为起始时间(通常为 Unix 纪元),$X$ 为时间步长(通常为30秒)。

TOTP 的优势在于不需要计数器同步,只需保证客户端和服务端的时钟大致同步即可。这就是 Google Authenticator、Microsoft Authenticator 等认证器应用的工作原理。

Rust 实现 TOTP

以下是一个使用 Rust 实现 TOTP 的示例:

#![allow(unused)]
fn main() {
use hmac::{Hmac, Mac};
use sha1::Sha1;

type HmacSha1 = Hmac<Sha1>;

/// 生成 TOTP 一次性口令
///
/// # 参数
/// - `secret`: 共享密钥(Base32编码)
/// - `time_step`: 时间步长(秒),通常为30
/// - `digits`: 输出位数,通常为6
///
/// # 返回
/// 指定位数的数字字符串
fn generate_totp(secret: &[u8], time_step: u64, digits: usize) -> String {
    let time = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs();

    // 计算时间计数器
    let counter = time / time_step;

    // 将计数器转换为8字节大端序
    let counter_bytes: [u8; 8] = counter.to_be_bytes();

    // 计算 HMAC-SHA1
    let mut mac = HmacSha1::new_from_slice(secret).expect("HMAC can take key of any size");
    mac.update(&counter_bytes);
    let result = mac.finalize().into_bytes();

    // 动态截断
    let offset = (result[19] & 0x0f) as usize;
    let binary: u32 = ((result[offset] & 0x7f) as u32) << 24
        | (result[offset + 1] as u32) << 16
        | (result[offset + 2] as u32) << 8
        | (result[offset + 3] as u32);

    // 取指定位数
    let otp = binary % 10u32.pow(digits as u32);
    format!("{:0width$}", otp, width = digits)
}

#[test]
fn test_totp() {
    // 示例密钥(实际应用中应使用安全的随机密钥)
    let secret = b"12345678901234567890"; // 20字节密钥

    let code = generate_totp(secret, 30, 6);
    println!("Current TOTP code: {}", code);
    assert_eq!(code.len(), 6);
}
}

在实际生产环境中,推荐使用成熟的 crate 如 totp-rs

[dependencies]
totp-rs = "^5.0"
#![allow(unused)]
fn main() {
use totp_rs::{Algorithm, TOTP, Secret};

fn totp_with_lib() {
    // 创建 TOTP 实例
    let totp = TOTP::new(
        Algorithm::SHA1,           // 哈希算法
        6,                         // 位数
        1,                         // skew(允许的时间偏差步数)
        30,                        // 时间步长(秒)
        Secret::Encoded("12345678901234567890".to_string())
            .to_bytes()
            .unwrap(),
    ).unwrap();

    // 生成当前 TOTP 码
    let code = totp.generate_current().unwrap();
    println!("TOTP code: {}", code);

    // 验证 TOTP 码
    let valid = totp.check_current(&code).unwrap();
    println!("Valid: {}", valid);
}
}

多因素认证(MFA)简介

多因素认证(Multi-Factor Authentication, MFA)通过组合多种认证因素来提高安全性。认证因素分为三类:

  1. 知识因素(Something you know):口令、PIN码、安全问题答案
  2. 持有因素(Something you have):手机、安全令牌、智能卡
  3. 固有因素(Something you are):指纹、面部识别、虹膜扫描

MFA 的安全增益可以用概率来理解。假设口令被破解的概率为 $p_1$,OTP 被截获的概率为 $p_2$,则 MFA 被攻破的概率约为 $p_1 \times p_2$,远低于单一因素。

常见的 MFA 方案:

  • 口令 + 短信验证码
  • 口令 + TOTP(Google Authenticator)
  • 口令 + 硬件安全密钥(YubiKey、FIDO2)
  • 口令 + 生物识别(指纹、面部)

趣味阅读:历史上的暗语和化名

口令和暗语不仅是计算机时代的产物,在人类历史上,暗语和化名一直扮演着重要角色。

暗语

鸡肋
河山统一
天王盖地虎,宝塔镇河妖天王
宫廷玉液酒,一百八一杯
识时务者为俊杰
风浪越大鱼越贵
龙门飞甲,便知真假

在中国近现代史上,暗语更是被广泛使用:

  • 邱小姐:中国第一颗原子弹的代号
  • 黄河:中国第一次核试验的代号
  • 长江:中国氢弹试验的代号

这些暗语在特定的历史时期和特定的群体中发挥了保密通信的作用。

化名

李得胜
伍豪
军统六哥——郑耀先
军统店小二——许忠义

在革命战争年代,化名是保护身份安全的重要手段。毛泽东曾使用“李得胜“作为化名,周恩来使用“伍豪“(这也是“豪密“命名的由来)。在谍战领域,化名更是特工的基本操作。

拓展思考

常用的账号密码基本都是大小写英文字母、数字、特殊字符的组合,有没有可能使用简体/繁体中文或者其他字符呢?就好比图形验证码通常都使用数字、大小写英文字母,后来演变成12306选择同类图片、手动补齐图片拼图,依次点击指定的文字符号等方式?

这个问题实际上涉及字符编码和系统兼容性的深层问题。现代系统(如 UTF-8 编码的数据库和应用)理论上完全支持中文密码,但在实践中仍面临以下挑战:

  • 输入法依赖:中文输入需要输入法,在某些设备或场景下可能不便
  • 键盘布局:在非中文键盘上输入中文密码极为困难
  • 兼容性:部分老旧系统可能不支持非 ASCII 字符的密码
  • 长度计算:中文字符在 UTF-8 中占3个字节,可能影响密码长度限制的判断

不过,在 Rust 中处理 Unicode 密码是完全可行的,正如本章代码示例中的 ARG2on&@!略 就包含了中文字符。

参考链接

总结

主题关键要点推荐实践
口令威胁暴力破解、字典攻击、彩虹表、钓鱼多层防御,不依赖单一措施
口令构造长度、复杂度、熵12位以上,混合字符集,或使用密码短语
口令存储哈希+盐值,绝不存明文Argon2id > bcrypt > scrypt > PBKDF2
口令管理密码管理器、SSO使用专业密码管理器,启用MFA
动态口令HOTP/TOTP30秒步长,6位数字,结合MFA使用
多因素认证知识+持有+固有至少启用双因素认证

练习题

  1. 安全分析:假设一个攻击者使用 GPU 集群每秒可以尝试 $10^{10}$ 次 SHA-256 哈希运算。对于一个8位纯数字密码(如生日),平均需要多长时间才能破解?如果是12位混合字符密码呢?

  2. 编程实践:使用 Rust 实现一个密码强度检测器,要求:

    • 计算密码的信息熵
    • 检测是否包含常见弱密码模式(如123456、password等)
    • 检测是否包含用户名或常见单词
    • 给出密码强度评分和改进建议
  3. 算法对比:为什么 Argon2 被认为是比 PBKDF2 更安全的密码哈希算法?请从内存硬度、抗 GPU/ASIC 攻击等角度分析。

  4. TOTP 实现:基于本章的 TOTP 示例代码,实现一个完整的命令行 TOTP 认证器:

    • 支持添加/删除密钥
    • 支持显示当前所有 TOTP 码
    • 支持密钥的 Base32 编码/解码
    • 数据持久化存储(加密保存)
  5. 思考题:在密码管理器中,主密码的安全性至关重要。如果主密码被遗忘,用户将无法恢复所有存储的密码。请分析密码管理器的设计如何在安全性和可用性之间取得平衡?是否有更好的替代方案?

  6. 实战挑战:使用 Rust 实现一个简单的用户认证系统,要求:

    • 用户注册:使用 Argon2id 哈希密码并存储
    • 用户登录:验证密码并返回 JWT token
    • 密码修改:验证旧密码后更新为新密码
    • TOTP 二次验证:登录时要求输入 TOTP 码
    • 使用 SQLite 存储用户数据