Move PinValidityCheck to :core:util and kotlinize it.

This commit is contained in:
Greyson Parrelli
2026-07-29 16:23:29 -04:00
committed by Alex Hart
parent 66ff18c310
commit 5d72248bb5
9 changed files with 91 additions and 148 deletions
@@ -7,7 +7,7 @@ import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.whispersystems.signalservice.api.kbs.PinValidityChecker;
import org.signal.network.pin.PinValidityChecker;
import org.signal.network.util.Preconditions;
public final class CreateSvrPinViewModel extends ViewModel implements BaseSvrPinViewModel {
@@ -1,38 +0,0 @@
package org.thoughtcrime.securesms.registration.v2;
import org.junit.Test;
import org.signal.core.util.StreamUtil;
import org.thoughtcrime.securesms.registration.testdata.PinValidityVector;
import org.whispersystems.signalservice.api.kbs.PinValidityChecker;
import org.signal.network.util.JsonUtil;
import java.io.IOException;
import java.io.InputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public final class PinValidityChecker_validity_Test {
@Test
public void vectors_valid() throws IOException {
for (PinValidityVector vector : getKbsPinValidityTestVectorList()) {
boolean valid = PinValidityChecker.valid(vector.getPin());
assertEquals(String.format("%s [%s]", vector.getName(), vector.getPin()),
vector.isValid(),
valid);
}
}
private static PinValidityVector[] getKbsPinValidityTestVectorList() throws IOException {
try (InputStream resourceAsStream = ClassLoader.getSystemClassLoader().getResourceAsStream("data/kbs_pin_validity_vectors.json")) {
PinValidityVector[] data = JsonUtil.fromJson(StreamUtil.readFullyAsString(resourceAsStream), PinValidityVector[].class);
assertTrue(data.length > 0);
return data;
}
}
}
@@ -1,27 +0,0 @@
package org.thoughtcrime.securesms.registration.testdata;
import com.fasterxml.jackson.annotation.JsonProperty;
public class PinValidityVector {
@JsonProperty("name")
private String name;
@JsonProperty("pin")
private String pin;
@JsonProperty("valid")
private boolean valid;
public String getName() {
return name;
}
public String getPin() {
return pin;
}
public boolean isValid() {
return valid;
}
}
@@ -17,12 +17,12 @@ import kotlinx.coroutines.flow.onEach
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.network.pin.PinValidityChecker
import org.signal.registration.NetworkController
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.RegistrationFlowState
import org.signal.registration.RegistrationRepository
import org.signal.registration.RestoreDecision
import org.whispersystems.signalservice.api.kbs.PinValidityChecker
import kotlin.time.toKotlinDuration
/**
@@ -5,9 +5,9 @@
package org.whispersystems.signalservice.api.kbs;
final class PinString {
public final class PinString {
static boolean allNumeric(CharSequence pin) {
public static boolean allNumeric(CharSequence pin) {
for (int i = 0; i < pin.length(); i++) {
if (!Character.isDigit(pin.charAt(i))) return false;
}
@@ -17,7 +17,7 @@ final class PinString {
/**
* Converts a string of not necessarily Arabic numerals to Arabic 0..9 characters.
*/
static String toArabic(CharSequence numerals) {
public static String toArabic(CharSequence numerals) {
int length = numerals.length();
char[] arabic = new char[length];
@@ -1,78 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.kbs;
public final class PinValidityChecker {
public static boolean valid(String pin) {
pin = pin.trim();
if (pin.isEmpty()) {
return false;
}
if (PinString.allNumeric(pin)) {
pin = PinString.toArabic(pin);
return !sequential(pin) &&
!sequential(reverse(pin)) &&
!allTheSame(pin);
} else {
return true;
}
}
private static String reverse(String string) {
char[] chars = string.toCharArray();
for (int i = 0; i < chars.length / 2; i++) {
char temp = chars[i];
chars[i] = chars[chars.length - i - 1];
chars[chars.length - i - 1] = temp;
}
return new String(chars);
}
private static boolean sequential(String pin) {
int length = pin.length();
if (length == 0) {
return false;
}
char c = pin.charAt(0);
for (int i = 1; i < length; i++) {
char n = pin.charAt(i);
if (n != c + 1) {
return false;
}
c = n;
}
return true;
}
private static boolean allTheSame(String pin) {
int length = pin.length();
if (length == 0) {
return false;
}
char c = pin.charAt(0);
for (int i = 1; i < length; i++) {
char n = pin.charAt(i);
if (n != c) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,40 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.network.pin
import org.whispersystems.signalservice.api.kbs.PinString
/**
* Rejects PINs that are trivially guessable. A numeric PIN must not be empty, sequential in either
* direction, or a single repeated digit. Non-numeric PINs are only checked for emptiness.
*/
object PinValidityChecker {
@JvmStatic
fun valid(pin: String): Boolean {
val trimmed = pin.trim()
if (trimmed.isEmpty()) {
return false
}
if (!PinString.allNumeric(trimmed)) {
return true
}
val arabic = PinString.toArabic(trimmed)
return !arabic.isSequential() && !arabic.reversed().isSequential() && !arabic.isSingleRepeatedChar()
}
private fun String.isSequential(): Boolean {
return zipWithNext().all { (previous, next) -> next == previous + 1 }
}
private fun String.isSingleRepeatedChar(): Boolean {
return all { it == this[0] }
}
}
@@ -0,0 +1,46 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.network.pin
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isNotEmpty
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.junit.Test
class PinValidityCheckerTest {
@Test
fun `validity matches the shared test vectors`() {
val vectors = loadVectors()
assertThat(vectors).isNotEmpty()
for (vector in vectors) {
assertThat(PinValidityChecker.valid(vector.pin), "${vector.name} [${vector.pin}]").isEqualTo(vector.valid)
}
}
private fun loadVectors(): List<PinValidityVector> {
val json = checkNotNull(javaClass.classLoader.getResourceAsStream(VECTOR_RESOURCE)) { "Missing $VECTOR_RESOURCE" }
.bufferedReader()
.use { it.readText() }
return Json.decodeFromString(json)
}
@Serializable
private data class PinValidityVector(
val name: String,
val pin: String,
val valid: Boolean
)
companion object {
private const val VECTOR_RESOURCE = "data/kbs_pin_validity_vectors.json"
}
}