Add basic TOTP support with mocked creation.

This commit is contained in:
Greyson Parrelli
2026-09-02 16:11:32 -03:00
committed by Alex Hart
parent 6a032e4f27
commit 62897a309c
163 changed files with 6688 additions and 5119 deletions
@@ -0,0 +1,80 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
/**
* RFC 4648 base32. Padding is omitted on encode but tolerated on decode.
*/
object Base32 {
private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
private const val PADDING = '='
private val DECODE_TABLE: IntArray = IntArray(128) { -1 }.apply {
ALPHABET.forEachIndexed { index, char ->
this[char.code] = index
this[char.lowercaseChar().code] = index
}
}
/** Encodes [data] as unpadded, uppercase base32. */
fun encode(data: ByteArray): String {
if (data.isEmpty()) {
return ""
}
val out = StringBuilder((data.size * 8 + 4) / 5)
var buffer = 0L
var bitsBuffered = 0
for (byte in data) {
buffer = (buffer shl 8) or (byte.toLong() and 0xFF)
bitsBuffered += 8
while (bitsBuffered >= 5) {
bitsBuffered -= 5
out.append(ALPHABET[((buffer shr bitsBuffered) and 0x1F).toInt()])
}
}
if (bitsBuffered > 0) {
out.append(ALPHABET[((buffer shl (5 - bitsBuffered)) and 0x1F).toInt()])
}
return out.toString()
}
/**
* Decodes base32 [input], ignoring padding and whitespace, or null if [input] contains anything else that isn't in
* the base32 alphabet.
*/
fun decodeOrNull(input: String): ByteArray? {
val out = ArrayList<Byte>(input.length * 5 / 8 + 1)
var buffer = 0L
var bitsBuffered = 0
for (char in input) {
if (char == PADDING || char.isWhitespace()) {
continue
}
val value = if (char.code < DECODE_TABLE.size) DECODE_TABLE[char.code] else -1
if (value < 0) {
return null
}
buffer = (buffer shl 5) or value.toLong()
bitsBuffered += 5
if (bitsBuffered >= 8) {
bitsBuffered -= 8
out += ((buffer shr bitsBuffered) and 0xFF).toByte()
}
}
return out.toByteArray()
}
}
@@ -0,0 +1,67 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNull
import org.junit.Test
class Base32Test {
/** The RFC 4648 section 10 vectors, minus the padding we don't emit. */
@Test
fun `encode - matches the RFC 4648 test vectors`() {
assertThat(Base32.encode("".toByteArray())).isEqualTo("")
assertThat(Base32.encode("f".toByteArray())).isEqualTo("MY")
assertThat(Base32.encode("fo".toByteArray())).isEqualTo("MZXQ")
assertThat(Base32.encode("foo".toByteArray())).isEqualTo("MZXW6")
assertThat(Base32.encode("foob".toByteArray())).isEqualTo("MZXW6YQ")
assertThat(Base32.encode("fooba".toByteArray())).isEqualTo("MZXW6YTB")
assertThat(Base32.encode("foobar".toByteArray())).isEqualTo("MZXW6YTBOI")
}
@Test
fun `decodeOrNull - matches the RFC 4648 test vectors`() {
assertThat(Base32.decodeOrNull("")?.decodeToString()).isEqualTo("")
assertThat(Base32.decodeOrNull("MY")?.decodeToString()).isEqualTo("f")
assertThat(Base32.decodeOrNull("MZXQ")?.decodeToString()).isEqualTo("fo")
assertThat(Base32.decodeOrNull("MZXW6")?.decodeToString()).isEqualTo("foo")
assertThat(Base32.decodeOrNull("MZXW6YQ")?.decodeToString()).isEqualTo("foob")
assertThat(Base32.decodeOrNull("MZXW6YTB")?.decodeToString()).isEqualTo("fooba")
assertThat(Base32.decodeOrNull("MZXW6YTBOI")?.decodeToString()).isEqualTo("foobar")
}
@Test
fun `decodeOrNull - tolerates the padding we don't emit`() {
assertThat(Base32.decodeOrNull("MZXW6YTBOI======")?.decodeToString()).isEqualTo("foobar")
}
@Test
fun `decodeOrNull - tolerates the spaces and lowercase a pasted key arrives with`() {
assertThat(Base32.decodeOrNull("mzxw 6ytb oi")?.decodeToString()).isEqualTo("foobar")
}
@Test
fun `decodeOrNull - rejects characters outside the alphabet`() {
assertThat(Base32.decodeOrNull("MZXW6YTB1")).isNull()
assertThat(Base32.decodeOrNull("MZXW6YTB0")).isNull()
assertThat(Base32.decodeOrNull("MZXW6YTB!")).isNull()
assertThat(Base32.decodeOrNull("MZXW6YTBé")).isNull()
}
@Test
fun `round trip - survives every byte value`() {
val data = ByteArray(256) { it.toByte() }
assertThat(Base32.decodeOrNull(Base32.encode(data))?.toList()).isEqualTo(data.toList())
}
@Test
fun `encode - produces a 52 character key for the 32 byte keys the service generates`() {
assertThat(Base32.encode(ByteArray(32)).length).isEqualTo(52)
}
}