mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Add basic TOTP support with mocked creation.
This commit is contained in:
committed by
Alex Hart
parent
6a032e4f27
commit
62897a309c
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user