otp-cj OTP 库:HOTP/TOTP 实现(RFC 4226/6238)、Base32 编解码、otpauth URI 生成及 README

This commit is contained in:
2026-08-21 08:43:24 +08:00
commit 014bdec72a
11 changed files with 652 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
package simcu::otp
public import simcu::otp.core.*
+75
View File
@@ -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<Byte>): 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<Byte> {
let cleaned = toUpper(input).replace(" ", "").replace("-", "").replace("=", "")
let result = ArrayList<Byte>()
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()
}
}
+43
View File
@@ -0,0 +1,43 @@
package simcu::otp.core
import stdx.crypto.digest.SHA1
/**
* HMAC-SHA1 实现(RFC 2104 / RFC 4226 第 5 节)。
* 仓颉标准库 std.crypto.digest 的 HMAC 目前仅支持 SHA512HashType.SHA512),
* 而 HOTP/TOTP 标准基于 HMAC-SHA1,故在此基于 stdx 的 SHA1 自实现(blockSize=64)。
*/
public class HmacSha1 {
private let ipad: Array<Byte>
private let opad: Array<Byte>
public init(key: Array<Byte>) {
let blockSize: Int64 = 64
var tempKey = key
if (key.size > blockSize) {
let sha = SHA1()
sha.write(key)
tempKey = sha.finish()
}
ipad = Array<Byte>(blockSize, repeat: 0)
opad = Array<Byte>(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<Byte>): Array<Byte> {
let inner = SHA1()
inner.write(ipad)
inner.write(data)
let innerHash = inner.finish()
let outer = SHA1()
outer.write(opad)
outer.write(innerHash)
outer.finish()
}
}
+53
View File
@@ -0,0 +1,53 @@
package simcu::otp.core
/**
* HOTPHMAC-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<Byte>, counter: Int64): String {
generate(secret, counter, 6)
}
/// 生成一次性密码,指定位数。counter 为 8 字节大端计数。
public static func generate(secret: Array<Byte>, counter: Int64, digits: Int64): String {
// counter → 8 字节大端
var counterBytes = Array<Byte>(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
}
}
+66
View File
@@ -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<Byte>(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()
}
}
+128
View File
@@ -0,0 +1,128 @@
package simcu::otp.core
import std.time.*
/**
* TOTPTime-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
/// 生成 TOTPsecret 为 Base32 字符串,默认 6 位 / 30s 步长)
public static func generate(secretBase32: String): String {
generateAt(secretBase32, currentUnixSeconds(), 6, DEFAULT_PERIOD)
}
/// 生成 TOTPsecret 为 Base32 字符串,指定位数)
public static func generate(secretBase32: String, digits: Int64): String {
generateAt(secretBase32, currentUnixSeconds(), digits, DEFAULT_PERIOD)
}
/// 生成 TOTPsecret 为 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)
}
/// 生成 TOTPsecret 为原始字节,默认 6 位 / 30s 步长)
public static func generateBytes(secret: Array<Byte>): String {
generateBytesAt(secret, currentUnixSeconds(), 6, DEFAULT_PERIOD)
}
/// 生成 TOTP(secret 为原始字节,指定位数)
public static func generateBytes(secret: Array<Byte>, digits: Int64): String {
generateBytesAt(secret, currentUnixSeconds(), digits, DEFAULT_PERIOD)
}
/// 在指定 Unix 秒生成 TOTP(原始字节密钥,默认 6 位 / 30s 步长)
public static func generateBytesAt(secret: Array<Byte>, atUnix: Int64): String {
generateBytesAt(secret, atUnix, 6, DEFAULT_PERIOD)
}
/// 在指定 Unix 秒生成 TOTP(原始字节密钥,指定位数)
public static func generateBytesAt(secret: Array<Byte>, atUnix: Int64, digits: Int64): String {
generateBytesAt(secret, atUnix, digits, DEFAULT_PERIOD)
}
/// 在指定 Unix 秒生成 TOTP(原始字节密钥,指定位数与步长)
public static func generateBytesAt(secret: Array<Byte>, 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<Byte>, code: String): Bool {
verifyBytes(secret, code, 6, DEFAULT_PERIOD, 1)
}
/// 校验(原始字节密钥,指定位数)
public static func verifyBytes(secret: Array<Byte>, code: String, digits: Int64): Bool {
verifyBytes(secret, code, digits, DEFAULT_PERIOD, 1)
}
/// 校验(原始字节密钥,指定位数与步长)
public static func verifyBytes(secret: Array<Byte>, code: String, digits: Int64, period: Int64): Bool {
verifyBytes(secret, code, digits, period, 1)
}
/// 校验(原始字节密钥,指定位数、步长与窗口)
public static func verifyBytes(secret: Array<Byte>, 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()
}
}
+132
View File
@@ -0,0 +1,132 @@
/*
* otp 库单元测试(cjpm test)。
* 覆盖:Base32 编解码(RFC 4648/ HMAC-SHA1RFC 2202/
* HOTPRFC 4226 附录 D/ TOTPRFC 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<Byte> {
let bytes = Array<Byte>(s.size, repeat: 0)
var i = 0
for (ch in s) {
bytes[i] = UInt8(Int64(ch))
i += 1
}
bytes
}
/// Base32RFC 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-SHA1RFC 2202 测试用例 1
@TestCase
public func hmacSha1Vectors(): Unit {
let key = Array<Byte>(20, repeat: 0x0b)
let data = ascii("Hi There")
let expected = "b617318655057264e28bc0b6fb378c8ef146be00"
let hmac = HmacSha1(key)
let actual = toHexString(hmac.compute(data))
@Expect(actual, expected)
}
/// HOTPRFC 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")
}
/// TOTPRFC 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)
}
/// 随机 secretBase32 合法且可解码
@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<Byte>): 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()
}
}