Files
Matewave/app/src/main/java/cc/skysparkle/matewave/security/SessionAuth.kt
T
Claude 6ac5a582db Add app source for F-Droid release (v1.0.43)
Adds the full Android source tree with unit tests, fastlane store
metadata and the Gradle wrapper. The archive shipped without
gradle-wrapper.jar, so the official Gradle 9.7.1 wrapper jar is
included to make ./gradlew work from a fresh clone. .gitignore now
also excludes build output and keystores. README gains build
instructions and an updated structure tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015g6ECVSKcBG8CJPrqC3G5o
2026-10-06 18:23:20 +00:00

109 lines
3.6 KiB
Kotlin

package cc.skysparkle.matewave.security
import android.util.Base64
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Per-session message protection: AES-256-GCM for confidentiality and a truncated
* HMAC-SHA256 tag bound to the sender's direction. A sliding window of sequence numbers
* rejects replays while tolerating out-of-order delivery.
*/
class SessionAuth(private val secret: ByteArray) {
private var lastAcceptedSeq = 0
private var outgoingSeq = 0
private val seenInWindow = java.util.TreeSet<Int>()
fun nextSeq(): Int {
outgoingSeq += 1
return outgoingSeq
}
fun acceptSeq(seq: Int): Boolean {
if (seq <= 0) return false
if (seq > lastAcceptedSeq) {
lastAcceptedSeq = seq
seenInWindow.add(seq)
while (seenInWindow.isNotEmpty() && seenInWindow.first() < lastAcceptedSeq - REPLAY_WINDOW) {
seenInWindow.pollFirst()
}
return true
}
if (seq < lastAcceptedSeq - REPLAY_WINDOW) return false
return seenInWindow.add(seq)
}
fun tag(message: String): String? = try {
val mac = Mac.getInstance(MAC_ALGORITHM)
mac.init(SecretKeySpec(secret, MAC_ALGORITHM))
val full = mac.doFinal(message.toByteArray(Charsets.UTF_8))
Base64.encodeToString(full.copyOfRange(0, TAG_BYTES), Base64.NO_WRAP)
} catch (e: Exception) {
null
}
fun verify(message: String, providedTag: String?): Boolean {
if (providedTag == null) return false
val expected = tag(message) ?: return false
if (expected.length != providedTag.length) return false
var diff = 0
for (i in expected.indices) {
diff = diff or (expected[i].code xor providedTag[i].code)
}
return diff == 0
}
private val encryptionKey: ByteArray by lazy {
java.security.MessageDigest.getInstance("SHA-256")
.digest(secret + KDF_LABEL)
}
fun encrypt(plaintext: String): String? = try {
val nonce = ByteArray(GCM_NONCE_BYTES).also { SecureRandom().nextBytes(it) }
val cipher = Cipher.getInstance(CIPHER_ALGORITHM)
cipher.init(
Cipher.ENCRYPT_MODE,
SecretKeySpec(encryptionKey, "AES"),
GCMParameterSpec(GCM_TAG_BITS, nonce)
)
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
Base64.encodeToString(nonce + ciphertext, Base64.NO_WRAP)
} catch (e: Exception) {
null
}
fun decrypt(payload: String): String? = try {
val raw = Base64.decode(payload, Base64.NO_WRAP)
if (raw.size <= GCM_NONCE_BYTES) null else {
val nonce = raw.copyOfRange(0, GCM_NONCE_BYTES)
val ciphertext = raw.copyOfRange(GCM_NONCE_BYTES, raw.size)
val cipher = Cipher.getInstance(CIPHER_ALGORITHM)
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(encryptionKey, "AES"),
GCMParameterSpec(GCM_TAG_BITS, nonce)
)
String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}
} catch (e: Exception) {
null
}
private companion object {
const val MAC_ALGORITHM = "HmacSHA256"
const val TAG_BYTES = 12
const val CIPHER_ALGORITHM = "AES/GCM/NoPadding"
const val GCM_NONCE_BYTES = 12
const val GCM_TAG_BITS = 128
const val REPLAY_WINDOW = 256
val KDF_LABEL = "chessapp-session-aes-gcm-v1".toByteArray(Charsets.UTF_8)
}
}