From 014bdec72ada2810f421d11492d03e54fa6265ca Mon Sep 17 00:00:00 2001 From: xRain Date: Fri, 21 Aug 2026 08:43:24 +0800 Subject: [PATCH] =?UTF-8?q?otp-cj=20OTP=20=E5=BA=93:HOTP/TOTP=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0(RFC=204226/6238)=E3=80=81Base32=20=E7=BC=96=E8=A7=A3?= =?UTF-8?q?=E7=A0=81=E3=80=81otpauth=20URI=20=E7=94=9F=E6=88=90=E5=8F=8A?= =?UTF-8?q?=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++ README.md | 121 ++++++++++++++++++++++++++++++++++++++ cjpm.lock | 3 + cjpm.toml | 24 ++++++++ src/Otp.cj | 3 + src/core/Base32.cj | 75 ++++++++++++++++++++++++ src/core/HmacSha1.cj | 43 ++++++++++++++ src/core/Hotp.cj | 53 +++++++++++++++++ src/core/OtpUri.cj | 66 +++++++++++++++++++++ src/core/Totp.cj | 128 ++++++++++++++++++++++++++++++++++++++++ src/tests/Otp_test.cj | 132 ++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 652 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cjpm.lock create mode 100644 cjpm.toml create mode 100644 src/Otp.cj create mode 100644 src/core/Base32.cj create mode 100644 src/core/HmacSha1.cj create mode 100644 src/core/Hotp.cj create mode 100644 src/core/OtpUri.cj create mode 100644 src/core/Totp.cj create mode 100644 src/tests/Otp_test.cj diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44bd8e9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# 构建产物 +target/ +# 临时测试产物 +probe.cjo diff --git a/README.md b/README.md new file mode 100644 index 0000000..dbc3377 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# otp-cj + +仓颉(Cangjie)一次性密码(OTP)库,实现 **HOTP**(RFC 4226)与 **TOTP**(RFC 6238),提供 Base32 密钥编解码(RFC 4648)与 `otpauth://` URI 生成,可直接与 Google Authenticator / Microsoft Authenticator 等常见 OTP App 互通。 + +组织:`simcu` | 包名:`otp` | 版本:`1.0.0` + +## 特性 + +- **TOTP**:基于时间步长的动态码,默认 6 位 / 30 秒步长,支持自定义位数、步长与时钟偏移窗口 +- **HOTP**:基于计数器的一次性密码,默认 6 位 +- **Base32**:RFC 4648 无填充变体编解码,解码时忽略大小写、空格、短横线与 `=` +- **otpauth:// URI**:生成可用于二维码的认证链接,并附带随机密钥生成工具 +- **零三方依赖**:仅依赖仓颉标准库 `stdx.crypto.digest.SHA1` +- 密钥支持 **Base32 字符串**(Google Authenticator 惯例)与**原始字节**两种传入方式 + +## 安装 + +在 `cjpm.toml` 中声明依赖(可改用中央仓版本号): + +```toml +[dependencies] +"simcu::otp" = { path = "../../cangjie/otp-cj" } +# 或发布到中央仓后:"simcu::otp" = "1.0.0" +``` + +代码中引用: + +```cangjie +import simcu::otp.core.* +``` + +## 快速开始 + +### 1. 生成密钥并绑定 OTP + +```cangjie +// 生成随机 Base32 密钥(默认 20 字节 = 160 bit) +let secret = OtpUri.generateSecret() // 例如 "N6C5HNT2F633..." + +// 生成 otpauth:// 链接,前端渲染成二维码供用户绑定 +let uri = OtpUri.build(issuer: "GamePlatform", account: "18600127718", secretBase32: secret) +// otpauth://totp/GamePlatform%3A18600127718?secret=N6C5HNT2F633...&issuer=GamePlatform&algorithm=SHA1&digits=6&period=30 +``` + +### 2. 生成动态码 + +```cangjie +// TOTP:默认 6 位 / 30s 步长,基于当前时间 +let code = Totp.generate(secret) // "351047" + +// 自定义位数与步长 +let code2 = Totp.generate(secret, digits: 8, period: 60) + +// 原始字节密钥 +let raw = Base32.decode(secret) +let code3 = Totp.generateBytes(raw) + +// HOTP:基于计数器(第 N 次动态码) +let hotp = Hotp.generate(raw, counter: 0) // 默认 6 位 +let hotp2 = Hotp.generate(raw, counter: 0, digits: 8) +``` + +### 3. 校验动态码 + +```cangjie +// TOTP 校验:默认允许前后各 1 个时间步的时钟偏移 +let ok = Totp.verify(secret, code) // Bool + +// 放宽/收紧偏移窗口 +let ok2 = Totp.verify(secret, code, digits: 6, period: 30, window: 2) + +// 字节密钥版本 +let ok3 = Totp.verifyBytes(raw, code) +``` + +### 4. Base32 编解码 + +```cangjie +let raw = Base32.decode("N6C5HNT2F633") // 忽略空格/短横线/大小写 +let encoded = Base32.encode(raw) // 往返后还原 "N6C5HNT2F633" +``` + +## API 参考 + +包结构:`simcu::otp.core` + +| 类型 | 说明 | +|------|------| +| `Base32` | RFC 4648 无填充 Base32 编解码;`encode(data)` / `decode(input)`,非法字符抛 `IllegalArgumentException` | +| `HmacSha1` | RFC 2104 / 4226 HMAC-SHA1(blockSize=64);`init(key)` / `compute(data)` | +| `Hotp` | RFC 4226;`generate(secret, counter[, digits])` | +| `Totp` | RFC 6238;`generate/generateAt/generateBytes(..., digits, period)` 与 `verify/verifyBytes(..., digits, period, window)` 多种重载;默认 `period=30`、`window=1` | +| `OtpUri` | `generateSecret([bytes])` 随机密钥;`build(issuer, account, secret[, digits, period])` 生成 otpauth:// 链接 | + +常用常量: + +- `Totp.DEFAULT_PERIOD = 30` +- `Totp.DEFAULT_T0 = 0` + +## 说明 + +- **HMAC-SHA1 为何自实现**:仓颉标准库 `std.crypto.digest` 的 HMAC 目前仅支持 SHA512,而 HOTP/TOTP 标准基于 HMAC-SHA1,因此本库基于 `stdx.crypto.digest.SHA1` 实现了 RFC 2104 的 HMAC-SHA1。 +- **兼容性**:`Totp.generate` / `Totp.verify` 的结果与 Google Authenticator 完全一致,可交叉验证。 +- **服务端校验建议**:绑定阶段将 Base32 secret 持久化到账号表(`otp_secret` 字段);校验时用 `Totp.verify(secret, code)`。 + +## 运行测试 + +```bash +cjpm test +``` + +测试覆盖:Base32 编解码往返、HOTP/TOTP 与 RFC 官方测试向量的对拍、时钟偏移窗口、otpauth:// URI 生成等。 + +## 配合 SimApi 使用 + +在 simapi 后端中作为账号二次验证通道: + +```cangjie +// AuthCodeService 示例:绑定 OTP 的账号优先接受 TOTP 动态码 +let otpOk = Totp.verify(acc.otpSecret, code) +``` diff --git a/cjpm.lock b/cjpm.lock new file mode 100644 index 0000000..c42311c --- /dev/null +++ b/cjpm.lock @@ -0,0 +1,3 @@ +version = 0 + +[requires] diff --git a/cjpm.toml b/cjpm.toml new file mode 100644 index 0000000..839b9d2 --- /dev/null +++ b/cjpm.toml @@ -0,0 +1,24 @@ +[package] + cjc-version = "1.1.3" + name = "otp" + organization = "simcu" + description = "SimApi OTP(一次性密码)库:HOTP/TOTP 实现(RFC 4226/6238),Base32 密钥与 otpauth:// URI 生成" + version = "1.0.0" + target-dir = "" + output-type = "static" + +[dependencies] + +[target] + [target.x86_64-w64-mingw32] + compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated" + [target.x86_64-w64-mingw32.bin-dependencies] + path-option = [ "${CANGJIE_STDX_PATH}" ] + [target.x86_64-unknown-linux-gnu] + compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated" + [target.x86_64-unknown-linux-gnu.bin-dependencies] + path-option = [ "${CANGJIE_STDX_PATH}" ] + [target.aarch64-unknown-linux-gnu] + compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated" + [target.aarch64-unknown-linux-gnu.bin-dependencies] + path-option = [ "${CANGJIE_STDX_PATH}" ] diff --git a/src/Otp.cj b/src/Otp.cj new file mode 100644 index 0000000..6d56f66 --- /dev/null +++ b/src/Otp.cj @@ -0,0 +1,3 @@ +package simcu::otp + +public import simcu::otp.core.* diff --git a/src/core/Base32.cj b/src/core/Base32.cj new file mode 100644 index 0000000..4c946a7 --- /dev/null +++ b/src/core/Base32.cj @@ -0,0 +1,75 @@ +package simcu::otp.core + +import std.collection.* + +/** + * Base32 编解码(RFC 4648 无填充变体)。 + * OTP 的共享密钥(secret)惯例使用 Base32 表示(Google Authenticator / otpauth URI 标准)。 + */ +public class Base32 { + private static let ALPHABET: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + + /// 字节数组 → Base32 字符串(无 padding) + public static func encode(data: Array): String { + var sb = StringBuilder() + var buffer: Int64 = 0 + var bits = 0 + for (b in data) { + buffer = (buffer << 8) | Int64(b) + bits += 8 + while (bits >= 5) { + bits -= 5 + let idx = (buffer >> bits) & 0x1f + sb.append(Rune(ALPHABET[idx])) + } + } + if (bits > 0) { + let idx = (buffer << (5 - bits)) & 0x1f + sb.append(Rune(ALPHABET[idx])) + } + sb.toString() + } + + /// Base32 字符串 → 字节数组(忽略空格/短横线/大小写;含非法字符抛 IllegalArgumentException) + public static func decode(input: String): Array { + let cleaned = toUpper(input).replace(" ", "").replace("-", "").replace("=", "") + let result = ArrayList() + var buffer: Int64 = 0 + var bits = 0 + for (ch in cleaned) { + let idx = indexInAlphabet(ch) + buffer = (buffer << 5) | idx + bits += 5 + if (bits >= 8) { + bits -= 8 + result.add(UInt8((buffer >> bits) & 0xff)) + } + } + result.toArray() + } + + /// 查找字符(UInt8)在字母表中的位置,非法字符抛 IllegalArgumentException + private static func indexInAlphabet(ch: UInt8): Int64 { + var i: Int64 = 0 + for (c in ALPHABET) { + if (c == ch) { + return i + } + i += 1 + } + throw IllegalArgumentException("invalid base32 character: ${ch}") + } + + /// 手工转大写(仓颉 String 无 toUpperCase 成员;遍历得到 UInt8) + private static func toUpper(s: String): String { + var sb = StringBuilder() + for (ch in s) { + if (ch >= UInt8(97) && ch <= UInt8(122)) { + sb.append(Rune(Int64(ch) - 32)) + } else { + sb.append(Rune(ch)) + } + } + sb.toString() + } +} diff --git a/src/core/HmacSha1.cj b/src/core/HmacSha1.cj new file mode 100644 index 0000000..8717949 --- /dev/null +++ b/src/core/HmacSha1.cj @@ -0,0 +1,43 @@ +package simcu::otp.core + +import stdx.crypto.digest.SHA1 + +/** + * HMAC-SHA1 实现(RFC 2104 / RFC 4226 第 5 节)。 + * 仓颉标准库 std.crypto.digest 的 HMAC 目前仅支持 SHA512(HashType.SHA512), + * 而 HOTP/TOTP 标准基于 HMAC-SHA1,故在此基于 stdx 的 SHA1 自实现(blockSize=64)。 + */ +public class HmacSha1 { + private let ipad: Array + private let opad: Array + + public init(key: Array) { + let blockSize: Int64 = 64 + var tempKey = key + if (key.size > blockSize) { + let sha = SHA1() + sha.write(key) + tempKey = sha.finish() + } + ipad = Array(blockSize, repeat: 0) + opad = Array(blockSize, repeat: 0) + tempKey.copyTo(ipad, 0, 0, tempKey.size) + tempKey.copyTo(opad, 0, 0, tempKey.size) + for (i in 0..blockSize) { + ipad[i] ^= 0x36 + opad[i] ^= 0x5c + } + } + + /// 计算 HMAC-SHA1(key, data) + public func compute(data: Array): Array { + let inner = SHA1() + inner.write(ipad) + inner.write(data) + let innerHash = inner.finish() + let outer = SHA1() + outer.write(opad) + outer.write(innerHash) + outer.finish() + } +} diff --git a/src/core/Hotp.cj b/src/core/Hotp.cj new file mode 100644 index 0000000..b1a0b05 --- /dev/null +++ b/src/core/Hotp.cj @@ -0,0 +1,53 @@ +package simcu::otp.core + +/** + * HOTP(HMAC-based One-Time Password, RFC 4226)。 + * 公式:HOTP(K,C) = Truncate(HMAC-SHA1(K, C)) mod 10^digits + */ +public class Hotp { + /// 生成一次性密码(默认 6 位)。counter 为 8 字节大端计数。 + public static func generate(secret: Array, counter: Int64): String { + generate(secret, counter, 6) + } + + /// 生成一次性密码,指定位数。counter 为 8 字节大端计数。 + public static func generate(secret: Array, counter: Int64, digits: Int64): String { + // counter → 8 字节大端 + var counterBytes = Array(8, repeat: 0) + var c = counter + var i = 7 + while (i >= 0) { + counterBytes[i] = UInt8(c & 0xff) + c = c >> 8 + i -= 1 + } + let hmac = HmacSha1(secret) + let hs = hmac.compute(counterBytes) + let offset = Int64(hs[19]) & 0x0f + let binCode = ((Int64(hs[offset]) & 0x7f) << 24) | + (Int64(hs[offset + 1]) << 16) | + (Int64(hs[offset + 2]) << 8) | + Int64(hs[offset + 3]) + let mod = pow10(digits) + let code = binCode % mod + padLeft(code.toString(), digits) + } + + private static func pow10(n: Int64): Int64 { + var r: Int64 = 1 + var i = 0 + while (i < n) { + r *= 10 + i += 1 + } + r + } + + private static func padLeft(s: String, width: Int64): String { + var r = s + while (r.size < width) { + r = "0" + r + } + r + } +} diff --git a/src/core/OtpUri.cj b/src/core/OtpUri.cj new file mode 100644 index 0000000..01aa0fc --- /dev/null +++ b/src/core/OtpUri.cj @@ -0,0 +1,66 @@ +package simcu::otp.core + +import std.random.* +import std.convert.* + +/** + * otpauth:// URI 生成(Google Authenticator / 常见 OTP App 标准)。 + * 格式:otpauth://totp/{issuer}:{account}?secret={base32}&issuer={issuer}&algorithm=SHA1&digits=6&period=30 + * 同时提供随机 Base32 secret 生成工具。 + */ +public class OtpUri { + /// 生成随机 Base32 secret(默认 20 字节 = 160 bit,与 RFC 4226 密钥长度一致) + public static func generateSecret(): String { + generateSecret(20) + } + + /// 生成指定字节数的随机 Base32 secret + public static func generateSecret(bytes: Int64): String { + let rnd = Random() + let data = Array(bytes, repeat: 0) + for (i in 0..bytes) { + data[i] = UInt8(rnd.nextUInt64() & 0xff) + } + Base32.encode(data) + } + + /// 生成 otpauth:// 链接(默认 6 位 / 30s 步长) + public static func build(issuer: String, account: String, secretBase32: String): String { + build(issuer, account, secretBase32, 6, 30) + } + + /// 生成 otpauth:// 链接(指定位数) + public static func build(issuer: String, account: String, secretBase32: String, digits: Int64): String { + build(issuer, account, secretBase32, digits, 30) + } + + /// 生成 otpauth:// 链接(指定位数与步长) + public static func build(issuer: String, account: String, secretBase32: String, digits: Int64, period: Int64): String { + let label = issuer + ":" + account + var sb = StringBuilder() + sb.append("otpauth://totp/") + sb.append(urlEncode(label)) + sb.append("?secret=") + sb.append(secretBase32) + sb.append("&issuer=") + sb.append(urlEncode(issuer)) + sb.append("&algorithm=SHA1&digits=") + sb.append(digits.toString()) + sb.append("&period=") + sb.append(period.toString()) + sb.toString() + } + + private static func urlEncode(s: String): String { + var sb = StringBuilder() + for (ch in s) { + match (ch) { + case ':' => sb.append("%3A") + case '/' => sb.append("%2F") + case ' ' => sb.append("%20") + case _ => sb.append(Rune(ch)) + } + } + sb.toString() + } +} diff --git a/src/core/Totp.cj b/src/core/Totp.cj new file mode 100644 index 0000000..0a8fe9f --- /dev/null +++ b/src/core/Totp.cj @@ -0,0 +1,128 @@ +package simcu::otp.core + +import std.time.* + +/** + * TOTP(Time-based One-Time Password, RFC 6238)。 + * 公式:TOTP(K) = HOTP(K, T),T = floor((UnixTime - T0) / X),X 默认 30 秒。 + * 支持密钥以 Base32 字符串(Google Authenticator 标准)或原始字节传入。 + */ +public class Totp { + public static let DEFAULT_PERIOD: Int64 = 30 + public static let DEFAULT_T0: Int64 = 0 + + /// 生成 TOTP(secret 为 Base32 字符串,默认 6 位 / 30s 步长) + public static func generate(secretBase32: String): String { + generateAt(secretBase32, currentUnixSeconds(), 6, DEFAULT_PERIOD) + } + + /// 生成 TOTP(secret 为 Base32 字符串,指定位数) + public static func generate(secretBase32: String, digits: Int64): String { + generateAt(secretBase32, currentUnixSeconds(), digits, DEFAULT_PERIOD) + } + + /// 生成 TOTP(secret 为 Base32 字符串,指定位数与步长) + public static func generate(secretBase32: String, digits: Int64, period: Int64): String { + generateAt(secretBase32, currentUnixSeconds(), digits, period) + } + + /// 在指定 Unix 秒生成 TOTP(默认 6 位 / 30s 步长) + public static func generateAt(secretBase32: String, atUnix: Int64): String { + generateAt(secretBase32, atUnix, 6, DEFAULT_PERIOD) + } + + /// 在指定 Unix 秒生成 TOTP(指定位数) + public static func generateAt(secretBase32: String, atUnix: Int64, digits: Int64): String { + generateAt(secretBase32, atUnix, digits, DEFAULT_PERIOD) + } + + /// 在指定 Unix 秒生成 TOTP(指定位数与步长) + public static func generateAt(secretBase32: String, atUnix: Int64, digits: Int64, period: Int64): String { + let counter = (atUnix - DEFAULT_T0) / period + Hotp.generate(Base32.decode(secretBase32), counter, digits) + } + + /// 生成 TOTP(secret 为原始字节,默认 6 位 / 30s 步长) + public static func generateBytes(secret: Array): String { + generateBytesAt(secret, currentUnixSeconds(), 6, DEFAULT_PERIOD) + } + + /// 生成 TOTP(secret 为原始字节,指定位数) + public static func generateBytes(secret: Array, digits: Int64): String { + generateBytesAt(secret, currentUnixSeconds(), digits, DEFAULT_PERIOD) + } + + /// 在指定 Unix 秒生成 TOTP(原始字节密钥,默认 6 位 / 30s 步长) + public static func generateBytesAt(secret: Array, atUnix: Int64): String { + generateBytesAt(secret, atUnix, 6, DEFAULT_PERIOD) + } + + /// 在指定 Unix 秒生成 TOTP(原始字节密钥,指定位数) + public static func generateBytesAt(secret: Array, atUnix: Int64, digits: Int64): String { + generateBytesAt(secret, atUnix, digits, DEFAULT_PERIOD) + } + + /// 在指定 Unix 秒生成 TOTP(原始字节密钥,指定位数与步长) + public static func generateBytesAt(secret: Array, atUnix: Int64, digits: Int64, period: Int64): String { + let counter = (atUnix - DEFAULT_T0) / period + Hotp.generate(secret, counter, digits) + } + + /// 校验:允许前后 window 个时间步的时钟偏移(默认允许当前步 ±1) + public static func verify(secretBase32: String, code: String): Bool { + verify(secretBase32, code, 6, DEFAULT_PERIOD, 1) + } + + /// 校验(指定位数) + public static func verify(secretBase32: String, code: String, digits: Int64): Bool { + verify(secretBase32, code, digits, DEFAULT_PERIOD, 1) + } + + /// 校验(指定位数与步长) + public static func verify(secretBase32: String, code: String, digits: Int64, period: Int64): Bool { + verify(secretBase32, code, digits, period, 1) + } + + /// 校验(指定位数、步长与窗口) + public static func verify(secretBase32: String, code: String, digits: Int64, period: Int64, window: Int64): Bool { + let nowSeconds = currentUnixSeconds() + for (i in -window..window + 1) { + let expected = generateAt(secretBase32, nowSeconds + i * period, digits, period) + if (expected == code) { + return true + } + } + false + } + + /// 校验(原始字节密钥,默认窗口 ±1) + public static func verifyBytes(secret: Array, code: String): Bool { + verifyBytes(secret, code, 6, DEFAULT_PERIOD, 1) + } + + /// 校验(原始字节密钥,指定位数) + public static func verifyBytes(secret: Array, code: String, digits: Int64): Bool { + verifyBytes(secret, code, digits, DEFAULT_PERIOD, 1) + } + + /// 校验(原始字节密钥,指定位数与步长) + public static func verifyBytes(secret: Array, code: String, digits: Int64, period: Int64): Bool { + verifyBytes(secret, code, digits, period, 1) + } + + /// 校验(原始字节密钥,指定位数、步长与窗口) + public static func verifyBytes(secret: Array, code: String, digits: Int64, period: Int64, window: Int64): Bool { + let nowSeconds = currentUnixSeconds() + for (i in -window..window + 1) { + let expected = generateBytesAt(secret, nowSeconds + i * period, digits, period) + if (expected == code) { + return true + } + } + false + } + + private static func currentUnixSeconds(): Int64 { + DateTime.now().toUnixTimeStamp().toSeconds() + } +} diff --git a/src/tests/Otp_test.cj b/src/tests/Otp_test.cj new file mode 100644 index 0000000..190b8be --- /dev/null +++ b/src/tests/Otp_test.cj @@ -0,0 +1,132 @@ +/* + * otp 库单元测试(cjpm test)。 + * 覆盖:Base32 编解码(RFC 4648)/ HMAC-SHA1(RFC 2202)/ + * HOTP(RFC 4226 附录 D)/ TOTP(RFC 6238 附录 B)/ + * otpauth URI 生成 / 随机 secret。 + */ + +package simcu::otp.tests + +import std.unittest.* +import std.unittest.testmacro.* +import std.convert.* +import simcu::otp.* + +@Test +public class OtpTests { + private static func ascii(s: String): Array { + let bytes = Array(s.size, repeat: 0) + var i = 0 + for (ch in s) { + bytes[i] = UInt8(Int64(ch)) + i += 1 + } + bytes + } + + /// Base32:RFC 4648 无填充测试向量 + @TestCase + public func base32Vectors(): Unit { + @Expect(Base32.encode(ascii("")), "") + @Expect(Base32.encode(ascii("f")), "MY") + @Expect(Base32.encode(ascii("fo")), "MZXQ") + @Expect(Base32.encode(ascii("foo")), "MZXW6") + @Expect(Base32.encode(ascii("foob")), "MZXW6YQ") + @Expect(Base32.encode(ascii("fooba")), "MZXW6YTB") + @Expect(Base32.encode(ascii("foobar")), "MZXW6YTBOI") + // 解码回原串 + @Expect(String.fromUtf8(Base32.decode("MZXW6YTBOI")), "foobar") + @Expect(String.fromUtf8(Base32.decode("mzxw6ytboi")), "foobar") + @Expect(String.fromUtf8(Base32.decode("MZXW6YTBOI======")), "foobar") + } + + /// HMAC-SHA1:RFC 2202 测试用例 1 + @TestCase + public func hmacSha1Vectors(): Unit { + let key = Array(20, repeat: 0x0b) + let data = ascii("Hi There") + let expected = "b617318655057264e28bc0b6fb378c8ef146be00" + let hmac = HmacSha1(key) + let actual = toHexString(hmac.compute(data)) + @Expect(actual, expected) + } + + /// HOTP:RFC 4226 附录 D 官方向量(6 位) + @TestCase + public func hotpVectors(): Unit { + let key = ascii("12345678901234567890") + let expected = ["755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583", "399871", "520489"] + for (i in 0..10) { + @Expect(Hotp.generate(key, i), expected[i]) + } + // 8 位(counter=0 正确值为 84755224,由 RFC 4226 动态截断独立验证) + @Expect(Hotp.generate(key, 0, 8), "84755224") + } + + /// TOTP:RFC 6238 附录 B 官方向量(8 位,30s 步长) + @TestCase + public func totpVectors(): Unit { + let secretBase32 = Base32.encode(ascii("12345678901234567890")) + let cases: Array<(Int64, String)> = [ + (59, "94287082"), + (1111111109, "07081804"), + (1111111111, "14050471"), + (1234567890, "89005924"), + (2000000000, "69279037"), + (20000000000, "65353130"), + ] + for (c in cases) { + @Expect(Totp.generateAt(secretBase32, c[0], 8), c[1]) + } + // 原始字节密钥路径 + @Expect(Totp.generateBytesAt(ascii("12345678901234567890"), 59, 8), "94287082") + } + + /// TOTP 校验:正确码通过,错误码拒绝 + @TestCase + public func totpVerify(): Unit { + let secret = "JBSWY3DPEHPK3PXP" // Base32("Hello!\xDE\xAD\xBE\xEF") + let code = Totp.generate(secret) + @Expect(Totp.verify(secret, code), true) + @Expect(Totp.verify(secret, "000000"), false) + // 窗口偏移 0 → 严格当前步 + @Expect(Totp.verify(secret, code, 6, 30, 0), true) + } + + /// otpauth URI 生成 + @TestCase + public func otpUri(): Unit { + let secret = "JBSWY3DPEHPK3PXP" + let uri = OtpUri.build("MyGame", "player1", secret) + @Expect(uri.startsWith("otpauth://totp/"), true) + @Expect(uri.contains("secret=JBSWY3DPEHPK3PXP"), true) + @Expect(uri.contains("issuer=MyGame"), true) + @Expect(uri.contains("digits=6"), true) + @Expect(uri.contains("period=30"), true) + } + + /// 随机 secret:Base32 合法且可解码 + @TestCase + public func randomSecret(): Unit { + let s = OtpUri.generateSecret(20) + @Expect(s.size, 32) + let decoded = Base32.decode(s) + @Expect(decoded.size, 20) + } + + private static func toHexString(data: Array): String { + var sb = StringBuilder() + for (b in data) { + sb.append(byteToHex(b)) + } + sb.toString() + } + + private static func byteToHex(b: Byte): String { + let hex = "0123456789abcdef" + var s = StringBuilder() + s.append(Rune(hex[Int64(b) >> 4])) + s.append(Rune(hex[Int64(b) & 0x0f])) + s.toString() + } +}