bigywater.tech
Java · 363

Java SASL:可插拔的认证与安全层

SASL 把认证机制从协议里抽出来。这篇不连任何外部服务,直接让 JDK 自带的客户端与服务端互相认证,看有哪些机制、报文怎么走、协商出的保护层是什么,以及 unwrap 在遇到篡改时的真实行为。

4 个服务端机制
JDK 21 的 SaslServerFactory 提供的机制:DIGEST-MD5、NTLM、CRAM-MD5、GSSAPI(没有 PLAIN 与 SCRAM)
byte[0]
auth-int / auth-conf 下 unwrap 被篡改的报文时的返回值(不抛异常,只写一条 INFO 日志)
+16 / +23 B
39 字节明文经 auth-int / auth-conf wrap 后多出的字节数

有哪些机制

实测 · 本机执行输出
  [mechs]
    客户端工厂:[ClientFactoryImpl[EXTERNAL, CRAM-MD5, PLAIN], FactoryImpl[GSSAPI], FactoryImpl[NTLM], ClientFactoryImpl[EXTERNAL, CRAM-MD5, PLAIN], FactoryImpl[DIGEST-MD5], ClientFactoryImpl[EXTERNAL, CRAM-MD5, PLAIN]]
    服务端工厂:[FactoryImpl[DIGEST-MD5], FactoryImpl[NTLM], ServerFactoryImpl[CRAM-MD5], FactoryImpl[GSSAPI]]
        SunSASL SaslClientFactory DIGEST-MD5
        SunSASL SaslServerFactory DIGEST-MD5
        SunSASL SaslClientFactory EXTERNAL
        SunSASL SaslClientFactory CRAM-MD5
        SunSASL SaslServerFactory CRAM-MD5
        SunSASL SaslClientFactory PLAIN
        SunSASL SaslServerFactory NTLM
        SunSASL SaslClientFactory NTLM
        JdkSASL SaslClientFactory GSSAPI
        JdkSASL SaslServerFactory GSSAPI
        Sasl.createSaslClient("PLAIN"       ) = 有;createSaslServer = null(没有)
        Sasl.createSaslClient("SCRAM-SHA-256") = null(没有);createSaslServer = null(没有)
        Sasl.createSaslClient("OAUTHBEARER" ) = null(没有);createSaslServer = null(没有)
        Sasl.createSaslClient("ANONYMOUS"   ) = null(没有);createSaslServer = null(没有)
        Sasl.createSaslClient("LOGIN"       ) = null(没有);createSaslServer = null(没有)
    mechanism 数组里有多个时按顺序选第一个能用的:PLAIN
    机制列表 [PLAIN, CRAM-MD5]:不加属性选中 PLAIN;加 Sasl.POLICY_NOPLAINTEXT=true 选中 CRAM-MD5;加 POLICY_NOANONYMOUS / POLICY_NOACTIVE / POLICY_NODICTIONARY=true 对 CRAM-MD5 与 DIGEST-MD5 的影响:NODICTIONARY:CRAM-MD5 → null;DIGEST-MD5 → null

CRAM-MD5:报文交换与确定性响应

实测 · 本机执行输出
  [cram]
    CRAM-MD5 好口令:成功;客户端 isComplete=true,服务端 isComplete=true;授权 ID amy;服务端回调 [NameCallback, PasswordCallback, AuthorizeCallback];交换了 3 个报文
        客户端先发初始响应? false
        服务端第 1 步 → <-6192225565412829630.1791056115839@localhost>(46B);isComplete=false
        客户端 → amy 897d4b39b641c033227ddb06a0785bc3(36B);c.isComplete=true
        服务端 → (null);s.isComplete=true
        协商出的属性:QOP=auth,STRENGTH=null,MAX_BUFFER=null,RAW_SEND_SIZE=null
    CRAM-MD5 错口令:失败 SaslException: Invalid response;交换了 2 个报文
        客户端先发初始响应? false
        服务端第 1 步 → <-2697646539720517548.1791056115851@localhost>(46B);isComplete=false
        客户端 → amy 0888e6f48c3401854aebb065004553ba(36B);c.isComplete=true
    CRAM-MD5 的 hasInitialResponse = false;getMechanismName = CRAM-MD5
    同样的挑战、同样的口令,两次响应是否相同(确定性 HMAC-MD5,没有客户端随机数):true;响应 = amy ecc5ee0f37841e7fa4fd8acdff50eac0(36B)
    手算 HMAC-MD5(口令, 挑战) 得到的响应:amy ecc5ee0f37841e7fa4fd8acdff50eac0;与客户端一致? true
    已完成的客户端再 evaluateChallenge:IllegalStateException: CRAM-MD5 authentication already completed
    服务端生成的挑战:<1656850057871703639.1791056115861@localhost>(45B)
    响应的最后一个字节被改动:SaslException: Invalid response
    用上一个服务端会话的挑战算出的响应去验证新会话(重放):SaslException: Invalid response

DIGEST-MD5 的 4 个报文

实测 · 本机执行输出
  [digest]
    DIGEST-MD5 好口令(默认 QOP):成功;客户端 isComplete=true,服务端 isComplete=true;授权 ID amy;服务端回调 [RealmCallback, NameCallback, PasswordCallback, AuthorizeCallback];交换了 4 个报文
        客户端先发初始响应? false
        服务端第 1 步 → realm="localhost",nonce="EY4BrUzSJ3T94LYcp8bhgO5OOABB7Ucr1H1tlCbW",charset=utf-8,algorithm=md5-sess(99B);isComplete=false
        客户端 → charset=utf-8,username="amy",realm="localhost",nonce="EY4BrUzSJ3T94LYcp8bhgO5OOABB7Ucr1H1tlCbW",nc=00000001,cnonce="CZx1xhxcOiq94HjrI913ORFX2bhgTzygJI…(249B);c.isComplete=false
        服务端 → rspauth=548261afff7a988660ea466304a4fb6b(40B);s.isComplete=true
        客户端 → (null);c.isComplete=true
        协商出的属性:QOP=auth,STRENGTH=null,MAX_BUFFER=65536,RAW_SEND_SIZE=0
    DIGEST-MD5 错口令:失败 SaslException: DIGEST-MD5: digest response format violation. Mismatched response.;交换了 2 个报文
        客户端先发初始响应? false
        服务端第 1 步 → realm="localhost",nonce="lhZQy18BdIhsTqmdtihxVBZ0ngNJvzGMNCZxk1Om",charset=utf-8,algorithm=md5-sess(99B);isComplete=false
        客户端 → charset=utf-8,username="amy",realm="localhost",nonce="lhZQy18BdIhsTqmdtihxVBZ0ngNJvzGMNCZxk1Om",nc=00000001,cnonce="V0+cii9q4ybQ5Mxwu//hwpahFrCy3ZAA+g…(249B);c.isComplete=false

QOP 与强度协商

实测 · 本机执行输出
  [qop]
    QOP 请求 “auth”:成功;客户端 isComplete=true,服务端 isComplete=true;授权 ID amy;服务端回调 [RealmCallback, NameCallback, PasswordCallback, AuthorizeCallback];交换了 4 个报文
        协商出的属性:QOP=auth,STRENGTH=null,MAX_BUFFER=65536,RAW_SEND_SIZE=0
    QOP 请求 “auth-int”:成功;客户端 isComplete=true,服务端 isComplete=true;授权 ID amy;服务端回调 [RealmCallback, NameCallback, PasswordCallback, AuthorizeCallback];交换了 4 个报文
        协商出的属性:QOP=auth-int,STRENGTH=null,MAX_BUFFER=65536,RAW_SEND_SIZE=65520
    QOP 请求 “auth-conf”:成功;客户端 isComplete=true,服务端 isComplete=true;授权 ID amy;服务端回调 [RealmCallback, NameCallback, PasswordCallback, AuthorizeCallback];交换了 4 个报文
        协商出的属性:QOP=auth-conf,STRENGTH=high,MAX_BUFFER=65536,RAW_SEND_SIZE=65510
    QOP 请求 “auth-conf,auth-int,auth”:成功;客户端 isComplete=true,服务端 isComplete=true;授权 ID amy;服务端回调 [RealmCallback, NameCallback, PasswordCallback, AuthorizeCallback];交换了 4 个报文
        协商出的属性:QOP=auth-conf,STRENGTH=high,MAX_BUFFER=65536,RAW_SEND_SIZE=65510
    客户端要 auth-conf、服务端只允许 auth:失败 SaslException: DIGEST-MD5: No common protection layer between client and server;交换了 1 个报文
    auth-conf + STRENGTH=high:成功;客户端 isComplete=true,服务端 isComplete=true;授权 ID amy;服务端回调 [RealmCallback, NameCallback, PasswordCallback, AuthorizeCallback];交换了 4 个报文
        协商出的属性:QOP=auth-conf,STRENGTH=high,MAX_BUFFER=65536,RAW_SEND_SIZE=65510
    auth-conf + STRENGTH=low:成功;客户端 isComplete=true,服务端 isComplete=true;授权 ID amy;服务端回调 [RealmCallback, NameCallback, PasswordCallback, AuthorizeCallback];交换了 4 个报文
        协商出的属性:QOP=auth-conf,STRENGTH=low,MAX_BUFFER=65536,RAW_SEND_SIZE=65510

wrap 与 unwrap

实测 · 本机执行输出
  [wrap]
    [auth-int] 明文 39 字节 → wrap 后 55 字节(多 16 字节);包含明文? true;前 12 字节 61747461636b206174206461…(55B)
        服务端 unwrap:attack at dawn, payload payload payload
        改动 wrap 后字节中间一位再 unwrap:没有抛异常,返回 byte[0]
        同一条消息第二次 wrap 的密文是否与第一次相同:false
        把第一条 wrap 的字节再 unwrap 一次(重放,序号已前进):SaslException: DIGEST-MD5: Out of order sequencing of messages from server. Got: 0 Expected: 1
        先 unwrap 第二条:attack at dawn, payload payload payload
        CRAM-MD5(没有 QOP 协商)上调用 wrap:IllegalStateException: CRAM-MD5 authentication not completed
    [auth-conf] 明文 39 字节 → wrap 后 62 字节(多 23 字节);包含明文? false;前 12 字节 9351eb15118a6530dc6e052a…(62B)
        服务端 unwrap:attack at dawn, payload payload payload
        改动 wrap 后字节中间一位再 unwrap:没有抛异常,返回 byte[0]
        同一条消息第二次 wrap 的密文是否与第一次相同:false
        把第一条 wrap 的字节再 unwrap 一次(重放,序号已前进):没有抛异常,返回 byte[0]
        先 unwrap 第二条:attack at dawn, payload payload payload
        CRAM-MD5(没有 QOP 协商)上调用 wrap:IllegalStateException: CRAM-MD5 authentication not completed
  Oct 04, 2026 3:35:17 AM com.sun.security.sasl.digest.DigestMD5Base$DigestIntegrity unwrap
  INFO: DIGEST23:Unmatched MACs
  Oct 04, 2026 3:35:17 AM com.sun.security.sasl.digest.DigestMD5Base$DigestPrivacy unwrap
  INFO: DIGEST41:Unmatched MACs
  Oct 04, 2026 3:35:17 AM com.sun.security.sasl.digest.DigestMD5Base$DigestPrivacy unwrap
  INFO: DIGEST41:Unmatched MACs

EXTERNAL 与边界

实测 · 本机执行输出
  [client]
    EXTERNAL 客户端:hasInitialResponse = true;初始响应 amy(3B)(授权 ID 直接作为响应,认证由底层(如 TLS 客户端证书)完成)
    EXTERNAL 不带授权 ID:(0B)
    createSaslClient 的 handler 为 null 而机制需要口令:SaslException: Callback handler to get username/password required
    DIGEST-MD5 服务端没有 handler:SaslException: Callback handler with support for AuthorizeCallback, RealmCallback, NameCallback, and PasswordCallback required
    协议名 / 服务器名为 null:成功 / 成功

分步演示

SASL
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK(Temurin 17.0.20、Homebrew 21.0.12 与 27,每篇注明实际用的版本);用 javac 编译、java 直接运行,需要第三方库时用 Maven 解析依赖;计时用 System.nanoTime,数字每次运行会有小幅抖动。JDK 21.0.12,SunSASL / JdkSASL;客户端与服务端在同一个 JVM 里用内存传递报文,没有真实的 LDAP / IMAP / SMTP 服务器,所以也没有测网络上的行为
    • 没有验证:GSSAPI(需要 KDC)、NTLM 机制的报文、DIGEST-MD5 的 realm 选择(RealmChoiceCallback)与 cipher 选项(javax.security.sasl.digest.cipher)、服务器端 Sasl.SERVER_AUTH(双向认证)属性、DIGEST-MD5 与 CRAM-MD5 在真实 LDAP / SMTP 服务器上的互操作、JDK 17 / 27 上 unwrap 篡改行为是否相同
    京ICP备2026067502号-1京公网安备11010502063487号