Compare commits

...

4 Commits

Author SHA1 Message Date
Kharec ee7ec5107c feat: add main source file 2026-04-02 17:31:35 +02:00
Kharec 0b41603ffb test: add unit tests 2026-04-02 17:31:30 +02:00
Kharec e9177b0047 build: manifest 2026-04-02 17:31:18 +02:00
Kharec 32bfe51687 build: add gleam.toml 2026-04-02 17:31:09 +02:00
4 changed files with 153 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
name = "teotipi"
version = "1.0.0"
licences = ["MIT"]
repository = { type = "gitea", host = "git.kharec.info", user = "Kharec", repo = "teotipi" }
links = [
{ title = "Repository", href = "https://git.kharec.info/Kharec/teotipi" },
]
[dependencies]
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
thirtytwo = ">= 1.0.0 and < 2.0.0"
gleam_time = ">= 1.8.0 and < 2.0.0"
gleam_crypto = ">= 1.5.1 and < 2.0.0"
argv = ">= 1.0.0 and < 2.0.0"
[dev_dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"
+19
View File
@@ -0,0 +1,19 @@
# This file was generated by Gleam
# You typically do not need to edit this file
packages = [
{ name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" },
{ name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" },
{ name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" },
{ name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" },
{ name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
{ name = "thirtytwo", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "thirtytwo", source = "hex", outer_checksum = "AB56E6C649BF73E6102F7DFCB5B24556CD77E87EA4DAA8E83D2BAACBE064FFC5" },
]
[requirements]
argv = { version = ">= 1.0.0 and < 2.0.0" }
gleam_crypto = { version = ">= 1.5.1 and < 2.0.0" }
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
gleam_time = { version = ">= 1.8.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
thirtytwo = { version = ">= 1.0.0 and < 2.0.0" }
+51
View File
@@ -0,0 +1,51 @@
import argv
import gleam/bit_array
import gleam/crypto
import gleam/int
import gleam/io
import gleam/result
import gleam/string
import gleam/time/timestamp
import thirtytwo
pub fn main() {
case argv.load().arguments {
[secret, ..] ->
case totp_string(secret) {
Ok(code) -> io.println(code)
Error(_) -> io.println("Error: invalid secret")
}
[] -> io.println("Usage: teotipi <base32-secret>")
}
}
pub fn totp_string(secret_b32: String) -> Result(String, Nil) {
use code <- result.map(totp(secret_b32))
code |> int.to_string |> string.pad_start(6, "0")
}
pub fn totp(secret_b32: String) -> Result(Int, Nil) {
use secret <- result.try(thirtytwo.decode(secret_b32))
let now = timestamp.system_time()
let #(seconds, _nanoseconds) = timestamp.to_unix_seconds_and_nanoseconds(now)
let counter = seconds / 30
let counter_bits = <<counter:big-int-size(64)>>
let mac = crypto.hmac(counter_bits, crypto.Sha1, secret)
Ok(truncate(mac))
}
fn truncate(mac: BitArray) -> Int {
let assert <<_:bytes-size(19), last:int>> = mac
let offset = int.bitwise_and(last, 0x0f)
let assert Ok(slice) = bit_array.slice(mac, offset, 4)
let assert <<b0:int, b1:int, b2:int, b3:int>> = slice
let code =
int.bitwise_and(b0, 0x7f)
|> int.bitwise_shift_left(24)
|> int.bitwise_or(int.bitwise_shift_left(b1, 16))
|> int.bitwise_or(int.bitwise_shift_left(b2, 8))
|> int.bitwise_or(b3)
code % 1_000_000
}
+65
View File
@@ -0,0 +1,65 @@
import gleam/string
import gleeunit
import gleeunit/should
import teotipi
pub fn main() -> Nil {
gleeunit.main()
}
pub fn totp_string_test() {
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|> teotipi.totp_string
|> should.be_ok
|> string.length
|> should.equal(6)
}
pub fn totp_string_padding_test() {
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|> teotipi.totp_string
|> should.be_ok
}
pub fn totp_string_invalid_secret_test() {
teotipi.totp_string("INVALID!!!")
|> should.be_error
}
pub fn totp_function_test() {
let code =
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|> teotipi.totp
|> should.be_ok
should.be_true(code > 0)
should.be_true(code < 1_000_000)
}
pub fn totp_invalid_secret_test() {
teotipi.totp("INVALID!!!")
|> should.be_error
}
pub fn totp_consistency_test() {
let secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
let result1 = teotipi.totp(secret)
let result2 = teotipi.totp(secret)
should.equal(result1, result2)
}
pub fn totp_different_secrets_test() {
let code1 =
"GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
|> teotipi.totp
|> should.be_ok
let code2 =
"JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
|> teotipi.totp
|> should.be_ok
should.be_true(code1 > 0)
should.be_true(code2 > 0)
}