otp-cj OTP 库:HOTP/TOTP 实现(RFC 4226/6238)、Base32 编解码、otpauth URI 生成及 README
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
package simcu::otp
|
||||
|
||||
public import simcu::otp.core.*
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<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()
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<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()
|
||||
}
|
||||
}
|
||||
@@ -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<Byte> {
|
||||
let bytes = Array<Byte>(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<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)
|
||||
}
|
||||
|
||||
/// 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<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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user