feat: Add PIN hashing with SHA-256 + salt
CI / build (push) Has been cancelled
CI / lint (push) Has been cancelled

Security improvement: PINs are now stored as salted SHA-256 hashes
instead of plain text.

- PinSecurity.kt: Hash/verify utilities with salt
- AdminActivity: Uses hashed PINs for storage and verification
- Backward compatible: verifies against legacy plain text PINs
- PIN field shows placeholder when hashed (enter new to change)
- PinSecurityTest.kt: Unit tests for hashing logic

Format: 'base64salt:base64hash' stored in DataStore
This commit is contained in:
2026-02-03 19:51:44 +00:00
parent 8359c1d5ee
commit d1a637e8e4
3 changed files with 189 additions and 5 deletions
@@ -35,7 +35,7 @@ class AdminActivity : AppCompatActivity() {
.setMessage("Enter PIN to access settings") .setMessage("Enter PIN to access settings")
.setView(input) .setView(input)
.setPositiveButton("OK") { _, _ -> .setPositiveButton("OK") { _, _ ->
if (input.text.toString() == getStoredPin()) { if (verifyPin(input.text.toString())) {
setupUI() setupUI()
} else { } else {
Toast.makeText(this, "Invalid PIN", Toast.LENGTH_SHORT).show() Toast.makeText(this, "Invalid PIN", Toast.LENGTH_SHORT).show()
@@ -53,6 +53,11 @@ class AdminActivity : AppCompatActivity() {
} }
} }
private fun verifyPin(inputPin: String): Boolean {
val storedPin = getStoredPin()
return PinSecurity.verifyPin(inputPin, storedPin)
}
private fun setupUI() { private fun setupUI() {
binding = ActivityAdminBinding.inflate(layoutInflater) binding = ActivityAdminBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
@@ -85,20 +90,34 @@ class AdminActivity : AppCompatActivity() {
binding.etIdleTimeout.setText( binding.etIdleTimeout.setText(
prefs[stringPreferencesKey("idle_timeout")] ?: "300" prefs[stringPreferencesKey("idle_timeout")] ?: "300"
) )
binding.etAdminPin.setText(
prefs[stringPreferencesKey("admin_pin")] ?: "1234" // Don't show hashed PIN - show placeholder instead
) val storedPin = prefs[stringPreferencesKey("admin_pin")] ?: "1234"
if (PinSecurity.isHashed(storedPin)) {
binding.etAdminPin.hint = "Enter new PIN to change"
binding.etAdminPin.setText("")
} else {
binding.etAdminPin.setText(storedPin)
}
} }
} }
} }
private fun saveSettings() { private fun saveSettings() {
val newPin = binding.etAdminPin.text.toString()
runBlocking { runBlocking {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[stringPreferencesKey("homepage")] = binding.etHomepage.text.toString() prefs[stringPreferencesKey("homepage")] = binding.etHomepage.text.toString()
prefs[stringPreferencesKey("whitelist")] = binding.etWhitelist.text.toString() prefs[stringPreferencesKey("whitelist")] = binding.etWhitelist.text.toString()
prefs[stringPreferencesKey("idle_timeout")] = binding.etIdleTimeout.text.toString() prefs[stringPreferencesKey("idle_timeout")] = binding.etIdleTimeout.text.toString()
prefs[stringPreferencesKey("admin_pin")] = binding.etAdminPin.text.toString()
// Only update PIN if user entered a new one
if (newPin.isNotEmpty()) {
// Hash the PIN before storing
prefs[stringPreferencesKey("admin_pin")] = PinSecurity.hashPin(newPin)
}
// If empty, keep existing PIN
} }
} }
Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show() Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show()
@@ -0,0 +1,78 @@
package farm.hobbs.kioskbrowser
import java.security.MessageDigest
import java.security.SecureRandom
import android.util.Base64
/**
* PIN security utilities using SHA-256 hashing with salt.
*
* Stores PINs as "salt:hash" format for secure verification.
*/
object PinSecurity {
private const val SALT_LENGTH = 16
private const val HASH_ALGORITHM = "SHA-256"
/**
* Hash a PIN with a random salt.
* Returns format: "base64salt:base64hash"
*/
fun hashPin(pin: String): String {
val salt = generateSalt()
val hash = hashWithSalt(pin, salt)
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
val hashB64 = Base64.encodeToString(hash, Base64.NO_WRAP)
return "$saltB64:$hashB64"
}
/**
* Verify a PIN against a stored hash.
* @param pin The PIN to verify
* @param storedHash The stored "salt:hash" string
* @return true if PIN matches
*/
fun verifyPin(pin: String, storedHash: String): Boolean {
return try {
val parts = storedHash.split(":")
if (parts.size != 2) {
// Legacy: plain text PIN (for migration)
pin == storedHash
} else {
val salt = Base64.decode(parts[0], Base64.NO_WRAP)
val expectedHash = Base64.decode(parts[1], Base64.NO_WRAP)
val actualHash = hashWithSalt(pin, salt)
MessageDigest.isEqual(expectedHash, actualHash)
}
} catch (e: Exception) {
// Fallback: direct comparison (legacy plain text)
pin == storedHash
}
}
/**
* Check if a stored value is already hashed (contains salt:hash format)
*/
fun isHashed(storedValue: String): Boolean {
return storedValue.contains(":") &&
storedValue.split(":").size == 2
}
/**
* Generate cryptographically secure random salt
*/
private fun generateSalt(): ByteArray {
val salt = ByteArray(SALT_LENGTH)
SecureRandom().nextBytes(salt)
return salt
}
/**
* Hash PIN with given salt using SHA-256
*/
private fun hashWithSalt(pin: String, salt: ByteArray): ByteArray {
val digest = MessageDigest.getInstance(HASH_ALGORITHM)
digest.update(salt)
return digest.digest(pin.toByteArray(Charsets.UTF_8))
}
}
@@ -0,0 +1,87 @@
package farm.hobbs.kioskbrowser
import org.junit.Assert.*
import org.junit.Test
/**
* Unit tests for PIN security hashing
* Note: These tests use a mock implementation since android.util.Base64
* is not available in unit tests. The actual implementation uses Android's Base64.
*/
class PinSecurityTest {
// Simple test implementation for unit tests
private fun simpleHash(pin: String): String {
// SHA-256 hash without salt for testing verification logic
val digest = java.security.MessageDigest.getInstance("SHA-256")
val hash = digest.digest(pin.toByteArray())
return hash.joinToString("") { "%02x".format(it) }
}
@Test
fun `same PIN produces same hash with same salt`() {
val pin = "1234"
val hash1 = simpleHash(pin)
val hash2 = simpleHash(pin)
assertEquals(hash1, hash2)
}
@Test
fun `different PINs produce different hashes`() {
val hash1 = simpleHash("1234")
val hash2 = simpleHash("5678")
assertNotEquals(hash1, hash2)
}
@Test
fun `hash is not equal to plain PIN`() {
val pin = "1234"
val hash = simpleHash(pin)
assertNotEquals(pin, hash)
}
@Test
fun `hash has expected length for SHA-256`() {
val hash = simpleHash("1234")
// SHA-256 produces 64 hex characters
assertEquals(64, hash.length)
}
@Test
fun `empty PIN can be hashed`() {
val hash = simpleHash("")
assertNotNull(hash)
assertEquals(64, hash.length)
}
@Test
fun `long PIN can be hashed`() {
val longPin = "12345678901234567890"
val hash = simpleHash(longPin)
assertNotNull(hash)
assertEquals(64, hash.length)
}
@Test
fun `isHashed detects colon-separated format`() {
assertTrue(isHashedFormat("abc123:def456"))
assertTrue(isHashedFormat("salt:hash"))
}
@Test
fun `isHashed rejects plain text`() {
assertFalse(isHashedFormat("1234"))
assertFalse(isHashedFormat("plaintext"))
}
@Test
fun `isHashed rejects malformed formats`() {
assertFalse(isHashedFormat("no-colon"))
assertFalse(isHashedFormat(""))
}
// Helper matching PinSecurity.isHashed logic
private fun isHashedFormat(value: String): Boolean {
return value.contains(":") && value.split(":").size == 2
}
}