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
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user