Initial commit

This commit is contained in:
defiQUG
2025-12-26 10:48:33 -08:00
commit 97f75e144f
270 changed files with 35886 additions and 0 deletions

48
core/as4/build.gradle.kts Normal file
View File

@@ -0,0 +1,48 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
namespace = "com.smoa.core.as4"
compileSdk = AppConfig.compileSdk
defaultConfig {
minSdk = AppConfig.minSdk
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(project(":core:common"))
implementation(project(":core:security"))
implementation(Dependencies.androidxCoreKtx)
// AS4/XML Security
implementation(Dependencies.cxfCore)
implementation(Dependencies.cxfRtFrontendJaxws)
implementation(Dependencies.cxfRtBindingsSoap)
implementation(Dependencies.santuario)
// Cryptography
implementation(Dependencies.bouncycastle)
implementation(Dependencies.bouncycastlePkix)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}

View File

@@ -0,0 +1,26 @@
package com.smoa.core.as4
import com.smoa.core.as4.domain.AS4Service
import com.smoa.core.common.Result
import javax.inject.Inject
/**
* AS4 Gateway - Main entry point for AS4 messaging.
* Delegates to AS4Service for actual implementation.
*/
class AS4Gateway @Inject constructor(
private val as4Service: AS4Service
) {
// Gateway methods delegate to service
suspend fun sendMessage(fromParty: com.smoa.core.as4.domain.AS4Party, toParty: com.smoa.core.as4.domain.AS4Party, payload: ByteArray): Result<String> {
val messageResult = as4Service.createMessage(fromParty, toParty, payload, null)
return when (messageResult) {
is Result.Success -> {
val sendResult = as4Service.sendMessage(messageResult.data)
sendResult
}
is Result.Error -> Result.Error(messageResult.exception)
is Result.Loading -> Result.Loading
}
}
}

View File

@@ -0,0 +1,65 @@
package com.smoa.core.as4.domain
import java.util.Date
/**
* AS4 message models per OASIS AS4 Profile 1.0.
*/
data class AS4Message(
val messageId: String,
val timestamp: Date,
val fromParty: AS4Party,
val toParty: AS4Party,
val conversationId: String?,
val service: String?,
val action: String?,
val payload: ByteArray,
val security: AS4Security,
val reliability: AS4Reliability?
)
data class AS4Party(
val partyId: String,
val role: String?
)
data class AS4Security(
val signature: XMLSignature,
val encryption: XMLEncryption?,
val certificate: String // X.509 certificate
)
data class XMLSignature(
val signatureValue: String,
val signatureMethod: String,
val canonicalizationMethod: String,
val signedInfo: SignedInfo
)
data class SignedInfo(
val canonicalizationMethod: String,
val signatureMethod: String,
val references: List<Reference>
)
data class Reference(
val uri: String,
val digestMethod: String,
val digestValue: String
)
data class XMLEncryption(
val encryptionMethod: String,
val cipherData: CipherData
)
data class CipherData(
val cipherValue: String
)
data class AS4Reliability(
val messageSequenceNumber: Long,
val acknowledgmentRequested: Boolean,
val duplicateElimination: Boolean
)

View File

@@ -0,0 +1,98 @@
package com.smoa.core.as4.domain
import com.smoa.core.common.Result
import com.smoa.core.security.AuditLogger
import com.smoa.core.security.AuditEventType
import java.util.Date
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* AS4 Gateway service for secure inter-agency messaging.
* Per OASIS AS4 Profile 1.0 specification.
*
* Full implementation will include:
* - WS-Security SOAP header construction
* - XML Digital Signature (XMLDSig)
* - XML Encryption (XMLEnc)
* - WS-ReliableMessaging
* - Receipt generation with non-repudiation
*/
@Singleton
class AS4Service @Inject constructor(
private val auditLogger: AuditLogger
) {
/**
* Create AS4 message envelope.
* TODO: Full implementation with Apache CXF and Santuario
*/
suspend fun createMessage(
fromParty: AS4Party,
toParty: AS4Party,
payload: ByteArray,
action: String?
): Result<AS4Message> {
return try {
val message = AS4Message(
messageId = UUID.randomUUID().toString(),
timestamp = Date(),
fromParty = fromParty,
toParty = toParty,
conversationId = null,
service = "http://docs.oasis-open.org/ebxml-msg/ebms/v3.0/ns/core/200704/service",
action = action,
payload = payload,
security = AS4Security(
signature = XMLSignature(
signatureValue = "", // TODO: Generate signature
signatureMethod = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
canonicalizationMethod = "http://www.w3.org/2001/10/xml-exc-c14n#",
signedInfo = SignedInfo(
canonicalizationMethod = "http://www.w3.org/2001/10/xml-exc-c14n#",
signatureMethod = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
references = emptyList()
)
),
encryption = null, // TODO: Add encryption if needed
certificate = "" // TODO: Include X.509 certificate
),
reliability = AS4Reliability(
messageSequenceNumber = 1L,
acknowledgmentRequested = true,
duplicateElimination = true
)
)
auditLogger.logEvent(
AuditEventType.COMMUNICATION_SESSION_START,
userId = fromParty.partyId,
module = "as4",
details = "AS4 message created: ${message.messageId}"
)
Result.Success(message)
} catch (e: Exception) {
Result.Error(e)
}
}
/**
* Send AS4 message.
* TODO: Implement actual sending via HTTP/HTTPS with SOAP
*/
suspend fun sendMessage(message: AS4Message): Result<String> {
// Placeholder - full implementation will use Apache CXF
return Result.Success("Message sent (simulated)")
}
/**
* Receive and process AS4 message.
*/
suspend fun receiveMessage(messageData: ByteArray): Result<AS4Message> {
// Placeholder - full implementation will parse SOAP envelope
return Result.Error(NotImplementedError("AS4 message reception not yet implemented"))
}
}

View File

@@ -0,0 +1,45 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
namespace = "com.smoa.core.auth"
compileSdk = AppConfig.compileSdk
defaultConfig {
minSdk = AppConfig.minSdk
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(project(":core:common"))
implementation(project(":core:security"))
implementation(Dependencies.androidxCoreKtx)
implementation(Dependencies.androidxLifecycleRuntimeKtx)
implementation(Dependencies.securityCrypto)
implementation(Dependencies.biometric)
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
// Testing
testImplementation(Dependencies.junit)
testImplementation(Dependencies.mockk)
testImplementation(Dependencies.coroutinesTest)
testImplementation(Dependencies.truth)
}

View File

@@ -0,0 +1,134 @@
package com.smoa.core.auth
import androidx.fragment.app.FragmentActivity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthCoordinator @Inject constructor(
private val pinManager: PinManager,
private val biometricManager: BiometricManager,
private val dualBiometricManager: DualBiometricManager,
private val sessionManager: SessionManager
) {
private val _authState = MutableStateFlow<AuthState>(AuthState.Unauthenticated)
val authState: StateFlow<AuthState> = _authState.asStateFlow()
/**
* Initiate three-factor authentication flow.
* Requires: PIN + Fingerprint + Facial Recognition
*/
suspend fun authenticate(
pin: String,
activity: FragmentActivity,
onBiometricSuccess: () -> Unit,
onError: (String) -> Unit
): AuthResult {
// Factor 1: Verify PIN
val pinResult = pinManager.verifyPin(pin)
when (pinResult) {
is PinManager.PinVerificationResult.Success -> {
// PIN verified, proceed to biometrics
}
is PinManager.PinVerificationResult.Failed -> {
return AuthResult.Failure(
"PIN incorrect. ${pinResult.remainingAttempts} attempts remaining."
)
}
PinManager.PinVerificationResult.Locked -> {
return AuthResult.Failure("Account locked due to too many failed attempts.")
}
PinManager.PinVerificationResult.NotSet -> {
return AuthResult.Failure("PIN not set. Please set up authentication.")
}
}
// Factor 2 & 3: Dual biometric authentication (fingerprint + facial recognition)
// Both must pass sequentially for true three-factor authentication
if (!dualBiometricManager.areBothBiometricsAvailable()) {
return AuthResult.Failure("Biometric authentication not available. Please enroll fingerprint and face.")
}
// Perform dual biometric authentication (fingerprint then face)
val dualBiometricResult = dualBiometricManager.authenticateDualBiometric(
activity = activity,
onProgress = { message ->
// Progress updates can be shown to user
}
)
return when (dualBiometricResult) {
is DualBiometricResult.Success -> {
// All three factors verified - create session
sessionManager.startSession()
_authState.value = AuthState.Authenticated
onBiometricSuccess()
AuthResult.Success
}
is DualBiometricResult.Failure -> {
AuthResult.Failure("Biometric authentication failed: ${dualBiometricResult.message}")
}
is DualBiometricResult.Cancelled -> {
AuthResult.Cancelled
}
is DualBiometricResult.NotAvailable -> {
AuthResult.Failure("Biometric authentication not available")
}
}
}
/**
* Step-up authentication for sensitive operations.
*/
suspend fun stepUpAuthentication(
pin: String,
activity: FragmentActivity,
onSuccess: () -> Unit,
onError: (String) -> Unit
): AuthResult {
// For step-up, we require PIN + biometric again
return authenticate(pin, activity, onSuccess, onError)
}
/**
* Check if user is currently authenticated.
*/
fun isAuthenticated(): Boolean {
return sessionManager.isSessionActive() && _authState.value is AuthState.Authenticated
}
/**
* Lock the session (manual lock).
*/
fun lock() {
sessionManager.endSession()
_authState.value = AuthState.Locked
}
/**
* Logout and clear session.
*/
fun logout() {
sessionManager.endSession()
_authState.value = AuthState.Unauthenticated
}
sealed class AuthState {
object Unauthenticated : AuthState()
object Authenticated : AuthState()
object Locked : AuthState()
data class Authenticating(val factorsCompleted: Int) : AuthState()
}
sealed class AuthResult {
object Success : AuthResult()
data class Failure(val message: String) : AuthResult()
object Cancelled : AuthResult()
object Pending : AuthResult()
}
}

View File

@@ -0,0 +1,109 @@
package com.smoa.core.auth
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class BiometricManager @Inject constructor(
@ApplicationContext private val context: Context
) {
/**
* Check if biometric authentication is available on the device.
*/
fun isBiometricAvailable(): BiometricAvailability {
val biometricManager = BiometricManager.from(context)
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
BiometricManager.BIOMETRIC_SUCCESS -> BiometricAvailability.Available
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> BiometricAvailability.NoHardware
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> BiometricAvailability.HardwareUnavailable
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricAvailability.NotEnrolled
else -> BiometricAvailability.Unknown
}
}
/**
* Check if fingerprint authentication is available.
*/
fun isFingerprintAvailable(): Boolean {
val biometricManager = BiometricManager.from(context)
return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Check if facial recognition is available.
*/
fun isFacialRecognitionAvailable(): Boolean {
val biometricManager = BiometricManager.from(context)
return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
/**
* Create a biometric prompt for authentication.
* Requires both fingerprint and facial recognition (both factors must pass).
*/
fun createBiometricPrompt(
activity: FragmentActivity,
onSuccess: () -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit
): BiometricPrompt {
val executor = ContextCompat.getMainExecutor(context)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
when (errorCode) {
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> onCancel()
else -> onError(errString.toString())
}
}
override fun onAuthenticationFailed() {
onError("Biometric authentication failed")
}
}
return BiometricPrompt(activity, executor, callback)
}
/**
* Prompt for biometric authentication (fingerprint or face).
*/
fun authenticate(
activity: FragmentActivity,
onSuccess: () -> Unit,
onError: (String) -> Unit,
onCancel: () -> Unit
) {
val prompt = createBiometricPrompt(activity, onSuccess, onError, onCancel)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication")
.setSubtitle("Use your fingerprint or face to authenticate")
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
prompt.authenticate(promptInfo)
}
enum class BiometricAvailability {
Available,
NoHardware,
HardwareUnavailable,
NotEnrolled,
Unknown
}
}

View File

@@ -0,0 +1,171 @@
package com.smoa.core.auth
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
/**
* Dual Biometric Manager for true three-factor authentication.
* Requires: PIN + Fingerprint + Facial Recognition (both biometrics must pass).
*
* Note: Android's BiometricPrompt API doesn't support requiring both
* fingerprint AND face separately in a single prompt. This implementation
* uses sequential prompts to require both factors.
*/
@Singleton
class DualBiometricManager @Inject constructor(
@ApplicationContext private val context: Context,
private val biometricManager: BiometricManager
) {
/**
* Authenticate with both fingerprint and facial recognition sequentially.
* Both must succeed for authentication to pass.
*/
suspend fun authenticateDualBiometric(
activity: FragmentActivity,
onProgress: (String) -> Unit = {}
): DualBiometricResult {
// Step 1: Fingerprint authentication
onProgress("Please authenticate with your fingerprint")
val fingerprintResult = authenticateFingerprint(activity)
when (fingerprintResult) {
is DualBiometricResult.Success -> {
// Fingerprint passed, proceed to face
}
is DualBiometricResult.Failure -> {
return DualBiometricResult.Failure("Fingerprint authentication failed: ${fingerprintResult.message}")
}
is DualBiometricResult.Cancelled -> {
return DualBiometricResult.Cancelled
}
else -> return fingerprintResult
}
// Step 2: Facial recognition authentication
onProgress("Please authenticate with your face")
val faceResult = authenticateFacialRecognition(activity)
return when (faceResult) {
is DualBiometricResult.Success -> {
DualBiometricResult.Success("Both biometric factors verified")
}
is DualBiometricResult.Failure -> {
DualBiometricResult.Failure("Facial recognition failed: ${faceResult.message}")
}
is DualBiometricResult.Cancelled -> {
DualBiometricResult.Cancelled
}
else -> faceResult
}
}
/**
* Authenticate with fingerprint only.
*/
private suspend fun authenticateFingerprint(
activity: FragmentActivity
): DualBiometricResult = suspendCancellableCoroutine { continuation ->
val executor = ContextCompat.getMainExecutor(context)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
continuation.resume(DualBiometricResult.Success("Fingerprint verified"))
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
when (errorCode) {
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
continuation.resume(DualBiometricResult.Cancelled)
}
else -> {
continuation.resume(DualBiometricResult.Failure(errString.toString()))
}
}
}
override fun onAuthenticationFailed() {
continuation.resume(DualBiometricResult.Failure("Fingerprint authentication failed"))
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Fingerprint Authentication")
.setSubtitle("Use your fingerprint to continue")
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
prompt.authenticate(promptInfo)
}
/**
* Authenticate with facial recognition only.
*/
private suspend fun authenticateFacialRecognition(
activity: FragmentActivity
): DualBiometricResult = suspendCancellableCoroutine { continuation ->
val executor = ContextCompat.getMainExecutor(context)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
continuation.resume(DualBiometricResult.Success("Face verified"))
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
when (errorCode) {
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
continuation.resume(DualBiometricResult.Cancelled)
}
else -> {
continuation.resume(DualBiometricResult.Failure(errString.toString()))
}
}
}
override fun onAuthenticationFailed() {
continuation.resume(DualBiometricResult.Failure("Facial recognition failed"))
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Facial Recognition")
.setSubtitle("Use your face to continue")
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
.build()
prompt.authenticate(promptInfo)
}
/**
* Check if both fingerprint and facial recognition are available.
*/
fun areBothBiometricsAvailable(): Boolean {
val biometricManager = BiometricManager.from(context)
return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
}
}
/**
* Result of dual biometric authentication.
*/
sealed class DualBiometricResult {
data class Success(val message: String) : DualBiometricResult()
data class Failure(val message: String) : DualBiometricResult()
object Cancelled : DualBiometricResult()
object NotAvailable : DualBiometricResult()
}

View File

@@ -0,0 +1,148 @@
package com.smoa.core.auth
import com.smoa.core.common.Result
import com.smoa.core.security.KeyManager
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PinManager @Inject constructor(
private val keyManager: KeyManager
) {
companion object {
private const val PIN_KEY = "user_pin_hash"
private const val PIN_RETRY_COUNT_KEY = "pin_retry_count"
private const val MAX_RETRY_ATTEMPTS = 5
private const val MIN_PIN_LENGTH = 6
private const val MAX_PIN_LENGTH = 12
}
/**
* Set a new PIN after validating complexity requirements.
*/
fun setPin(pin: String): Result<Unit> {
return try {
validatePinComplexity(pin)
val pinHash = hashPin(pin)
keyManager.putSecureString(PIN_KEY, pinHash)
keyManager.putSecureString(PIN_RETRY_COUNT_KEY, "0")
Result.Success(Unit)
} catch (e: Exception) {
Result.Error(e)
}
}
/**
* Verify a PIN against the stored hash.
*/
fun verifyPin(pin: String): PinVerificationResult {
val currentRetries = getRetryCount()
if (currentRetries >= MAX_RETRY_ATTEMPTS) {
return PinVerificationResult.Locked
}
val storedHash = keyManager.getSecureString(PIN_KEY)
if (storedHash == null) {
return PinVerificationResult.NotSet
}
val inputHash = hashPin(pin)
val isValid = storedHash == inputHash
if (isValid) {
keyManager.putSecureString(PIN_RETRY_COUNT_KEY, "0")
return PinVerificationResult.Success
} else {
val newRetryCount = currentRetries + 1
keyManager.putSecureString(PIN_RETRY_COUNT_KEY, newRetryCount.toString())
return if (newRetryCount >= MAX_RETRY_ATTEMPTS) {
PinVerificationResult.Locked
} else {
PinVerificationResult.Failed(remainingAttempts = MAX_RETRY_ATTEMPTS - newRetryCount)
}
}
}
/**
* Check if a PIN is set.
*/
fun isPinSet(): Boolean {
return keyManager.getSecureString(PIN_KEY) != null
}
/**
* Get the current retry count.
*/
fun getRetryCount(): Int {
return keyManager.getSecureString(PIN_RETRY_COUNT_KEY)?.toIntOrNull() ?: 0
}
/**
* Reset retry count (used after successful authentication).
*/
fun resetRetryCount() {
keyManager.putSecureString(PIN_RETRY_COUNT_KEY, "0")
}
/**
* Check if account is locked due to too many failed attempts.
*/
fun isLocked(): Boolean {
return getRetryCount() >= MAX_RETRY_ATTEMPTS
}
/**
* Validate PIN complexity requirements.
*/
private fun validatePinComplexity(pin: String) {
if (pin.length < MIN_PIN_LENGTH || pin.length > MAX_PIN_LENGTH) {
throw IllegalArgumentException("PIN must be between $MIN_PIN_LENGTH and $MAX_PIN_LENGTH characters")
}
if (!pin.all { it.isDigit() }) {
throw IllegalArgumentException("PIN must contain only digits")
}
// Check for simple patterns (e.g., 111111, 123456)
if (pin.all { it == pin[0] } || isSequential(pin)) {
throw IllegalArgumentException("PIN cannot be a simple pattern")
}
}
/**
* Hash PIN using SHA-256 (in production, use a proper password hashing algorithm like bcrypt).
*/
private fun hashPin(pin: String): String {
val digest = java.security.MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(pin.toByteArray())
return hashBytes.joinToString("") { "%02x".format(it) }
}
/**
* Check if PIN is sequential (e.g., 123456, 654321).
*/
private fun isSequential(pin: String): Boolean {
var isAscending = true
var isDescending = true
for (i in 1 until pin.length) {
val current = pin[i].digitToInt()
val previous = pin[i - 1].digitToInt()
if (current != previous + 1) isAscending = false
if (current != previous - 1) isDescending = false
}
return isAscending || isDescending
}
sealed class PinVerificationResult {
object Success : PinVerificationResult()
data class Failed(val remainingAttempts: Int) : PinVerificationResult()
object Locked : PinVerificationResult()
object NotSet : PinVerificationResult()
}
}

View File

@@ -0,0 +1,86 @@
package com.smoa.core.auth
import com.smoa.core.security.KeyManager
import javax.inject.Inject
import javax.inject.Singleton
/**
* Policy Manager for dynamic policy enforcement and updates.
*/
@Singleton
class PolicyManager @Inject constructor(
private val keyManager: KeyManager
) {
companion object {
private const val POLICY_VERSION_KEY = "policy_version"
private const val POLICY_DATA_KEY = "policy_data"
private const val SESSION_TIMEOUT_KEY = "session_timeout_ms"
private const val OFFLINE_TIMEOUT_KEY = "offline_timeout_ms"
private const val LOCK_ON_FOLD_KEY = "lock_on_fold"
private const val LOCK_ON_BACKGROUND_KEY = "lock_on_background"
}
/**
* Policy data structure.
*/
data class Policy(
val version: Int,
val sessionTimeoutMs: Long,
val offlineTimeoutMs: Long,
val lockOnFold: Boolean,
val lockOnBackground: Boolean,
val allowedModules: Set<String>,
val allowedUrls: Set<String>
)
/**
* Get current policy version.
*/
fun getPolicyVersion(): Int {
return keyManager.getSecureString(POLICY_VERSION_KEY)?.toIntOrNull() ?: 0
}
/**
* Update policy from server (should be called on trusted connectivity).
*/
fun updatePolicy(policy: Policy) {
keyManager.putSecureString(POLICY_VERSION_KEY, policy.version.toString())
keyManager.putSecureString(SESSION_TIMEOUT_KEY, policy.sessionTimeoutMs.toString())
keyManager.putSecureString(OFFLINE_TIMEOUT_KEY, policy.offlineTimeoutMs.toString())
keyManager.putSecureString(LOCK_ON_FOLD_KEY, policy.lockOnFold.toString())
keyManager.putSecureString(LOCK_ON_BACKGROUND_KEY, policy.lockOnBackground.toString())
// Store policy data as JSON (simplified - use proper serialization in production)
keyManager.putSecureString(POLICY_DATA_KEY, policy.toString())
}
/**
* Get session timeout from policy.
*/
fun getSessionTimeoutMs(): Long {
return keyManager.getSecureString(SESSION_TIMEOUT_KEY)?.toLongOrNull()
?: 30 * 60 * 1000L // Default 30 minutes
}
/**
* Get offline timeout from policy.
*/
fun getOfflineTimeoutMs(): Long {
return keyManager.getSecureString(OFFLINE_TIMEOUT_KEY)?.toLongOrNull()
?: 7 * 24 * 60 * 60 * 1000L // Default 7 days
}
/**
* Check if lock on fold is enabled.
*/
fun shouldLockOnFold(): Boolean {
return keyManager.getSecureString(LOCK_ON_FOLD_KEY)?.toBoolean() ?: false
}
/**
* Check if lock on background is enabled.
*/
fun shouldLockOnBackground(): Boolean {
return keyManager.getSecureString(LOCK_ON_BACKGROUND_KEY)?.toBoolean() ?: true
}
}

View File

@@ -0,0 +1,150 @@
package com.smoa.core.auth
import javax.inject.Inject
import javax.inject.Singleton
/**
* Role-Based Access Control framework for SMOA.
* Enforces access control at module, feature, and data levels.
*/
@Singleton
class RBACFramework @Inject constructor() {
/**
* User role definitions.
*/
enum class Role {
ADMIN,
OPERATOR,
VIEWER,
GUEST
}
/**
* Permission definitions for modules and features.
*/
enum class Permission {
// Credentials module
VIEW_CREDENTIALS,
DISPLAY_CREDENTIALS,
// Directory module
VIEW_DIRECTORY,
SEARCH_DIRECTORY,
VIEW_UNIT_DIRECTORY,
// Communications module
USE_RADIO,
JOIN_CHANNEL,
CREATE_CHANNEL,
// Meetings module
JOIN_MEETING,
HOST_MEETING,
SCREEN_SHARE,
// Browser module
ACCESS_BROWSER,
NAVIGATE_URL
}
/**
* Module access definitions.
*/
enum class Module {
CREDENTIALS,
DIRECTORY,
COMMUNICATIONS,
MEETINGS,
BROWSER
}
/**
* Check if a role has a specific permission.
*/
fun hasPermission(role: Role, permission: Permission): Boolean {
return getPermissionsForRole(role).contains(permission)
}
/**
* Check if a role can access a module.
*/
fun canAccessModule(role: Role, module: Module): Boolean {
return getModulesForRole(role).contains(module)
}
/**
* Get all permissions for a role.
*/
private fun getPermissionsForRole(role: Role): Set<Permission> {
return when (role) {
Role.ADMIN -> setOf(
Permission.VIEW_CREDENTIALS,
Permission.DISPLAY_CREDENTIALS,
Permission.VIEW_DIRECTORY,
Permission.SEARCH_DIRECTORY,
Permission.VIEW_UNIT_DIRECTORY,
Permission.USE_RADIO,
Permission.JOIN_CHANNEL,
Permission.CREATE_CHANNEL,
Permission.JOIN_MEETING,
Permission.HOST_MEETING,
Permission.SCREEN_SHARE,
Permission.ACCESS_BROWSER,
Permission.NAVIGATE_URL
)
Role.OPERATOR -> setOf(
Permission.VIEW_CREDENTIALS,
Permission.DISPLAY_CREDENTIALS,
Permission.VIEW_DIRECTORY,
Permission.SEARCH_DIRECTORY,
Permission.VIEW_UNIT_DIRECTORY,
Permission.USE_RADIO,
Permission.JOIN_CHANNEL,
Permission.JOIN_MEETING,
Permission.SCREEN_SHARE,
Permission.ACCESS_BROWSER
)
Role.VIEWER -> setOf(
Permission.VIEW_CREDENTIALS,
Permission.VIEW_DIRECTORY,
Permission.SEARCH_DIRECTORY,
Permission.JOIN_MEETING
)
Role.GUEST -> setOf(
Permission.VIEW_CREDENTIALS
)
}
}
/**
* Get all modules accessible by a role.
*/
private fun getModulesForRole(role: Role): Set<Module> {
return when (role) {
Role.ADMIN -> setOf(
Module.CREDENTIALS,
Module.DIRECTORY,
Module.COMMUNICATIONS,
Module.MEETINGS,
Module.BROWSER
)
Role.OPERATOR -> setOf(
Module.CREDENTIALS,
Module.DIRECTORY,
Module.COMMUNICATIONS,
Module.MEETINGS,
Module.BROWSER
)
Role.VIEWER -> setOf(
Module.CREDENTIALS,
Module.DIRECTORY,
Module.MEETINGS
)
Role.GUEST -> setOf(
Module.CREDENTIALS
)
}
}
}

View File

@@ -0,0 +1,112 @@
package com.smoa.core.auth
import com.smoa.core.security.KeyManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SessionManager @Inject constructor(
private val keyManager: KeyManager
) {
companion object {
private const val SESSION_START_TIME_KEY = "session_start_time"
private const val SESSION_ACTIVE_KEY = "session_active"
private const val DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000L // 30 minutes
}
private val _sessionState = MutableStateFlow<SessionState>(SessionState.Inactive)
val sessionState: StateFlow<SessionState> = _sessionState.asStateFlow()
private var sessionStartTime: Long = 0
var sessionTimeoutMs: Long = DEFAULT_SESSION_TIMEOUT_MS
/**
* Start a new session.
*/
fun startSession() {
sessionStartTime = System.currentTimeMillis()
keyManager.putSecureString(SESSION_START_TIME_KEY, sessionStartTime.toString())
keyManager.putSecureString(SESSION_ACTIVE_KEY, "true")
_sessionState.value = SessionState.Active(sessionStartTime)
}
/**
* End the current session.
*/
fun endSession() {
sessionStartTime = 0
keyManager.putSecureString(SESSION_START_TIME_KEY, "0")
keyManager.putSecureString(SESSION_ACTIVE_KEY, "false")
_sessionState.value = SessionState.Inactive
}
/**
* Check if session is currently active.
*/
fun isSessionActive(): Boolean {
val storedActive = keyManager.getSecureString(SESSION_ACTIVE_KEY) == "true"
val storedStartTime = keyManager.getSecureString(SESSION_START_TIME_KEY)?.toLongOrNull() ?: 0
if (!storedActive || storedStartTime == 0L) {
return false
}
val elapsed = System.currentTimeMillis() - storedStartTime
if (elapsed > sessionTimeoutMs) {
// Session expired
endSession()
return false
}
return true
}
/**
* Check if session has expired.
*/
fun isSessionExpired(): Boolean {
if (sessionStartTime == 0L) return true
val elapsed = System.currentTimeMillis() - sessionStartTime
return elapsed > sessionTimeoutMs
}
/**
* Get remaining session time in milliseconds.
*/
fun getRemainingSessionTime(): Long {
if (sessionStartTime == 0L) return 0
val elapsed = System.currentTimeMillis() - sessionStartTime
return maxOf(0, sessionTimeoutMs - elapsed)
}
/**
* Restore session state from storage.
*/
fun restoreSession(): Boolean {
val storedActive = keyManager.getSecureString(SESSION_ACTIVE_KEY) == "true"
val storedStartTime = keyManager.getSecureString(SESSION_START_TIME_KEY)?.toLongOrNull() ?: 0
if (storedActive && storedStartTime > 0) {
val elapsed = System.currentTimeMillis() - storedStartTime
if (elapsed <= sessionTimeoutMs) {
sessionStartTime = storedStartTime
_sessionState.value = SessionState.Active(storedStartTime)
return true
} else {
// Session expired, clear it
endSession()
}
}
return false
}
sealed class SessionState {
object Inactive : SessionState()
data class Active(val startTime: Long) : SessionState()
object Expired : SessionState()
}
}

View File

@@ -0,0 +1,65 @@
package com.smoa.core.auth
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* User session manager.
* Tracks current user's role, unit, and session information.
*/
@Singleton
class UserSession @Inject constructor() {
private val _currentUser = MutableStateFlow<UserInfo?>(null)
val currentUser: StateFlow<UserInfo?> = _currentUser.asStateFlow()
/**
* Set current user session.
*/
fun setUser(userInfo: UserInfo) {
_currentUser.value = userInfo
}
/**
* Clear user session.
*/
fun clearUser() {
_currentUser.value = null
}
/**
* Get current user role.
*/
fun getCurrentRole(): RBACFramework.Role {
return _currentUser.value?.role ?: RBACFramework.Role.GUEST
}
/**
* Get current user unit.
*/
fun getCurrentUnit(): String? {
return _currentUser.value?.unit
}
/**
* Get current user ID.
*/
fun getCurrentUserId(): String? {
return _currentUser.value?.userId
}
}
/**
* User information.
*/
data class UserInfo(
val userId: String,
val userName: String,
val role: RBACFramework.Role,
val unit: String?,
val clearanceLevel: String?,
val missionAssignment: String?
)

View File

@@ -0,0 +1,67 @@
package com.smoa.core.auth.di
import android.content.Context
import com.smoa.core.auth.BiometricManager
import com.smoa.core.auth.DualBiometricManager
import com.smoa.core.auth.PinManager
import com.smoa.core.auth.SessionManager
import com.smoa.core.auth.UserSession
import com.smoa.core.auth.RBACFramework
import com.smoa.core.security.KeyManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object AuthModule {
@Provides
@Singleton
fun providePinManager(
keyManager: KeyManager
): PinManager {
return PinManager(keyManager)
}
@Provides
@Singleton
fun provideBiometricManager(
@ApplicationContext context: Context
): BiometricManager {
return BiometricManager(context)
}
@Provides
@Singleton
fun provideDualBiometricManager(
@ApplicationContext context: Context
): DualBiometricManager {
// DualBiometricManager uses androidx.biometric.BiometricManager directly
val biometricManager = androidx.biometric.BiometricManager.from(context)
return DualBiometricManager(context, biometricManager)
}
@Provides
@Singleton
fun provideSessionManager(
keyManager: KeyManager
): SessionManager {
return SessionManager(keyManager)
}
@Provides
@Singleton
fun provideUserSession(): UserSession {
return UserSession()
}
@Provides
@Singleton
fun provideRBACFramework(): RBACFramework {
return RBACFramework()
}
}

View File

@@ -0,0 +1,111 @@
package com.smoa.core.auth
import com.smoa.core.common.TestCoroutineRule
import com.smoa.core.security.KeyManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
/**
* Unit tests for PinManager.
*/
class PinManagerTest {
@get:Rule
val testCoroutineRule = TestCoroutineRule()
private val keyManager = mockk<KeyManager>(relaxed = true)
private val pinManager = PinManager(keyManager)
@Test
fun `setPin should store encrypted PIN`() = runTest {
// Given
val pin = "123456"
every { keyManager.putSecureString(any(), any()) } returns Unit
// When
val result = pinManager.setPin(pin)
// Then
assertTrue(result.isSuccess)
verify { keyManager.putSecureString("user_pin", any()) }
}
@Test
fun `setPin should fail for invalid PIN length`() = runTest {
// Given
val shortPin = "12345" // Too short
val longPin = "1234567890123" // Too long
// When
val shortResult = pinManager.setPin(shortPin)
val longResult = pinManager.setPin(longPin)
// Then
assertTrue(shortResult.isFailure)
assertTrue(longResult.isFailure)
}
@Test
fun `verifyPin should return success for correct PIN`() = runTest {
// Given
val pin = "123456"
val hashedPin = "hashed_pin_value"
every { keyManager.getSecureString("user_pin") } returns hashedPin
every { keyManager.putSecureString(any(), any()) } returns Unit
// Set PIN first
pinManager.setPin(pin)
// When
val result = pinManager.verifyPin(pin)
// Then
assertTrue(result is PinManager.PinVerificationResult.Success)
}
@Test
fun `verifyPin should return failed for incorrect PIN`() = runTest {
// Given
val correctPin = "123456"
val wrongPin = "654321"
every { keyManager.getSecureString("user_pin") } returns "hashed_pin"
every { keyManager.putSecureString(any(), any()) } returns Unit
// Set PIN first
pinManager.setPin(correctPin)
// When
val result = pinManager.verifyPin(wrongPin)
// Then
assertTrue(result is PinManager.PinVerificationResult.Failed)
if (result is PinManager.PinVerificationResult.Failed) {
assertTrue(result.remainingAttempts < 5)
}
}
@Test
fun `verifyPin should lock after max attempts`() = runTest {
// Given
val correctPin = "123456"
val wrongPin = "654321"
every { keyManager.getSecureString("user_pin") } returns "hashed_pin"
every { keyManager.putSecureString(any(), any()) } returns Unit
pinManager.setPin(correctPin)
// When - attempt wrong PIN multiple times
repeat(5) {
pinManager.verifyPin(wrongPin)
}
// Then
val result = pinManager.verifyPin(wrongPin)
assertTrue(result is PinManager.PinVerificationResult.Locked)
}
}

View File

@@ -0,0 +1,59 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
namespace = "com.smoa.core.barcode"
compileSdk = AppConfig.compileSdk
defaultConfig {
minSdk = AppConfig.minSdk
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
}
dependencies {
implementation(project(":core:common"))
implementation(platform(Dependencies.composeBom))
implementation(Dependencies.composeUi)
implementation(Dependencies.composeUiGraphics)
implementation(Dependencies.composeMaterial3)
implementation(Dependencies.androidxCoreKtx)
implementation(Dependencies.androidxLifecycleRuntimeKtx)
// Barcode libraries
implementation(Dependencies.zxingCore)
implementation(Dependencies.zxingAndroid)
// Hilt
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
// Coroutines
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
// Testing
testImplementation(Dependencies.junit)
}

View File

@@ -0,0 +1,75 @@
package com.smoa.core.barcode
import com.smoa.core.barcode.formats.AAMVACredential
import com.smoa.core.barcode.formats.ICAO9303Credential
import com.smoa.core.barcode.formats.MILSTD129Credential
import com.smoa.core.common.Result
import com.google.zxing.common.BitMatrix
import javax.inject.Inject
import javax.inject.Singleton
/**
* Encoder for different credential formats to PDF417 barcode.
*/
@Singleton
class BarcodeEncoder @Inject constructor(
private val pdf417Generator: PDF417Generator
) {
/**
* Encode AAMVA credential to PDF417 barcode.
*/
fun encodeAAMVA(
credential: AAMVACredential,
errorCorrectionLevel: Int = 5,
width: Int = 400,
height: Int = 200
): Result<BitMatrix> {
val encodedData = credential.encodeToAAMVAFormat()
return pdf417Generator.generatePDF417(encodedData, errorCorrectionLevel, width, height)
}
/**
* Encode ICAO 9303 credential to PDF417 barcode.
*/
fun encodeICAO9303(
credential: ICAO9303Credential,
errorCorrectionLevel: Int = 5,
width: Int = 400,
height: Int = 200
): Result<BitMatrix> {
val encodedData = credential.encodeToICAO9303Format()
return pdf417Generator.generatePDF417(encodedData, errorCorrectionLevel, width, height)
}
/**
* Encode MIL-STD-129 credential to PDF417 barcode.
*/
fun encodeMILSTD129(
credential: MILSTD129Credential,
errorCorrectionLevel: Int = 5,
width: Int = 400,
height: Int = 200
): Result<BitMatrix> {
val encodedData = credential.encodeToMILSTD129Format()
return pdf417Generator.generatePDF417(encodedData, errorCorrectionLevel, width, height)
}
/**
* Encode generic data string to PDF417 barcode.
*/
fun encodeGeneric(
data: String,
errorCorrectionLevel: Int = 5,
width: Int = 400,
height: Int = 200,
useCompression: Boolean = false
): Result<BitMatrix> {
return if (useCompression) {
pdf417Generator.generatePDF417WithCompression(data, errorCorrectionLevel, width, height)
} else {
pdf417Generator.generatePDF417(data, errorCorrectionLevel, width, height)
}
}
}

View File

@@ -0,0 +1,85 @@
package com.smoa.core.barcode
import android.content.Context
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.RGBLuminanceSource
import com.google.zxing.Result as ZXingResult
import com.google.zxing.common.HybridBinarizer
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.EnumMap
import javax.inject.Inject
import javax.inject.Singleton
/**
* Barcode scanner for reading PDF417 and other barcode formats.
*/
@Singleton
class BarcodeScanner @Inject constructor(
@ApplicationContext private val context: Context
) {
private val reader = MultiFormatReader()
/**
* Scan barcode from bitmap image.
*
* @param pixels Pixel array (ARGB format)
* @param width Image width
* @param height Image height
* @return Scanned barcode result or error
*/
fun scanFromBitmap(
pixels: IntArray,
width: Int,
height: Int
): Result<BarcodeScanResult> {
return try {
val hints = EnumMap<DecodeHintType, Any>(DecodeHintType::class.java)
hints[DecodeHintType.POSSIBLE_FORMATS] = listOf(BarcodeFormat.PDF_417)
hints[DecodeHintType.TRY_HARDER] = true
val source = RGBLuminanceSource(width, height, pixels)
val bitmap = BinaryBitmap(HybridBinarizer(source))
val zxingResult: ZXingResult = reader.decode(bitmap, hints)
Result.success(
BarcodeScanResult(
text = zxingResult.text,
format = zxingResult.barcodeFormat.toString(),
rawBytes = zxingResult.rawBytes
)
)
} catch (e: NotFoundException) {
Result.failure(BarcodeScanException("Barcode not found in image", e))
} catch (e: Exception) {
Result.failure(BarcodeScanException("Error scanning barcode: ${e.message}", e))
}
}
/**
* Validate scanned barcode data format.
*/
fun validateFormat(data: String, expectedFormat: BarcodeFormat): Boolean {
return when (expectedFormat) {
BarcodeFormat.PDF_417 -> {
// Basic validation - check for common format markers
data.isNotEmpty() && data.length > 10
}
else -> true
}
}
data class BarcodeScanResult(
val text: String,
val format: String,
val rawBytes: ByteArray?
)
class BarcodeScanException(message: String, cause: Throwable? = null) : Exception(message, cause)
}

View File

@@ -0,0 +1,99 @@
package com.smoa.core.barcode
import com.smoa.core.common.Result
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.WriterException
import com.google.zxing.common.BitMatrix
import com.google.zxing.oned.Code128Writer
import com.google.zxing.pdf417.PDF417Writer
import com.google.zxing.qrcode.QRCodeWriter
import java.util.EnumMap
import javax.inject.Inject
import javax.inject.Singleton
/**
* PDF417 barcode generator compliant with ISO/IEC 15438:2015.
* Supports error correction levels 0-8 and text compression mode.
*/
@Singleton
class PDF417Generator @Inject constructor() {
companion object {
private const val DEFAULT_ERROR_CORRECTION_LEVEL = 5
private const val MIN_ERROR_CORRECTION = 0
private const val MAX_ERROR_CORRECTION = 8
private const val MIN_DPI = 200
}
/**
* Generate PDF417 barcode bitmap from data string.
*
* @param data The data to encode
* @param errorCorrectionLevel Error correction level (0-8), default 5
* @param width Desired width in pixels
* @param height Desired height in pixels
* @return BitMatrix representing the barcode
*/
fun generatePDF417(
data: String,
errorCorrectionLevel: Int = DEFAULT_ERROR_CORRECTION_LEVEL,
width: Int = 400,
height: Int = 200
): Result<BitMatrix> {
return try {
// Validate error correction level
val ecLevel = errorCorrectionLevel.coerceIn(MIN_ERROR_CORRECTION, MAX_ERROR_CORRECTION)
val hints = EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
hints[EncodeHintType.ERROR_CORRECTION] = ecLevel
hints[EncodeHintType.PDF417_COMPACT] = false
hints[EncodeHintType.PDF417_AUTO_ECI] = true
val writer = PDF417Writer()
val bitMatrix = writer.encode(data, BarcodeFormat.PDF_417, width, height, hints)
Result.Success(bitMatrix)
} catch (e: WriterException) {
Result.Error(e)
} catch (e: IllegalArgumentException) {
Result.Error(e)
}
}
/**
* Generate PDF417 barcode with text compression mode (Mode 902).
*/
fun generatePDF417WithCompression(
data: String,
errorCorrectionLevel: Int = DEFAULT_ERROR_CORRECTION_LEVEL,
width: Int = 400,
height: Int = 200
): Result<BitMatrix> {
// Apply text compression (Mode 902) - ZXing handles this automatically
// but we can optimize the input data
val compressedData = compressText(data)
return generatePDF417(compressedData, errorCorrectionLevel, width, height)
}
/**
* Basic text compression for PDF417 Mode 902.
* In production, use proper compression algorithms.
*/
private fun compressText(text: String): String {
// Simplified compression - remove redundant whitespace
// Full implementation would use proper compression algorithms
return text.trim().replace(Regex("\\s+"), " ")
}
/**
* Validate barcode dimensions meet minimum DPI requirements.
*/
fun validateDimensions(width: Int, height: Int, dpi: Int = MIN_DPI): Boolean {
val widthInches = width / dpi.toFloat()
val heightInches = height / dpi.toFloat()
// Minimum size: 2.0" x 0.8" (50.8mm x 20.3mm)
return widthInches >= 2.0f && heightInches >= 0.8f
}
}

View File

@@ -0,0 +1,126 @@
package com.smoa.core.barcode.formats
/**
* AAMVA (American Association of Motor Vehicle Administrators)
* Driver License/ID Card data structure for PDF417 encoding.
*
* Format specification: AAMVA DL/ID Card Design Standard
*/
data class AAMVACredential(
val documentDiscriminator: String,
val firstName: String,
val middleName: String? = null,
val lastName: String,
val address: String,
val city: String,
val state: String,
val zipCode: String,
val dateOfBirth: String, // YYYYMMDD
val expirationDate: String, // YYYYMMDD
val issueDate: String, // YYYYMMDD
val licenseNumber: String,
val restrictions: String? = null,
val endorsements: String? = null,
val vehicleClass: String? = null,
val height: String? = null, // Format: FTIN or CM
val weight: String? = null, // Format: LBS or KG
val eyeColor: String? = null,
val hairColor: String? = null,
val sex: String? = null // M, F, or X
) {
/**
* Encode to AAMVA format string for PDF417 barcode.
* Format: @\nANSI [version]\n[data elements]\n
*/
fun encodeToAAMVAFormat(): String {
val builder = StringBuilder()
builder.append("@\n")
builder.append("ANSI 636026") // Standard version header
builder.append(documentDiscriminator)
builder.append("\n")
// Data elements in AAMVA format
builder.append("DAA") // First name
builder.append(firstName)
builder.append("\n")
if (middleName != null) {
builder.append("DAB") // Middle name
builder.append(middleName)
builder.append("\n")
}
builder.append("DAC") // Last name
builder.append(lastName)
builder.append("\n")
builder.append("DAD") // Address
builder.append(address)
builder.append("\n")
builder.append("DAE") // City
builder.append(city)
builder.append("\n")
builder.append("DAF") // State
builder.append(state)
builder.append("\n")
builder.append("DAG") // ZIP code
builder.append(zipCode)
builder.append("\n")
builder.append("DBA") // Date of birth
builder.append(dateOfBirth)
builder.append("\n")
builder.append("DCS") // Last name (alternate)
builder.append(lastName)
builder.append("\n")
builder.append("DDE") // Sex
builder.append(sex ?: "")
builder.append("\n")
builder.append("DDF") // Eye color
builder.append(eyeColor ?: "")
builder.append("\n")
builder.append("DDG") // Height
builder.append(height ?: "")
builder.append("\n")
builder.append("DBB") // Issue date
builder.append(issueDate)
builder.append("\n")
builder.append("DBC") // Expiration date
builder.append(expirationDate)
builder.append("\n")
builder.append("DBD") // License number
builder.append(licenseNumber)
builder.append("\n")
if (restrictions != null) {
builder.append("DBA") // Restrictions
builder.append(restrictions)
builder.append("\n")
}
if (endorsements != null) {
builder.append("DBC") // Endorsements
builder.append(endorsements)
builder.append("\n")
}
if (vehicleClass != null) {
builder.append("DCA") // Vehicle class
builder.append(vehicleClass)
builder.append("\n")
}
return builder.toString()
}
}

View File

@@ -0,0 +1,73 @@
package com.smoa.core.barcode.formats
/**
* ICAO 9303 Machine Readable Travel Document (MRTD) data structure.
*
* Format specification: ICAO Document 9303 - Machine Readable Travel Documents
*/
data class ICAO9303Credential(
val documentType: String, // P = Passport, I = ID card, A = Alien, etc.
val issuingCountry: String, // ISO 3166-1 alpha-3 country code
val surname: String,
val givenNames: String,
val documentNumber: String,
val nationality: String, // ISO 3166-1 alpha-3
val dateOfBirth: String, // YYMMDD
val sex: String, // M, F, or < (unspecified)
val expirationDate: String, // YYMMDD
val personalNumber: String? = null,
val optionalData: String? = null
) {
/**
* Encode to ICAO 9303 format (MRZ - Machine Readable Zone).
* Format: Two-line or three-line MRZ format
*/
fun encodeToICAO9303Format(): String {
val builder = StringBuilder()
// Line 1: Document type, issuing country, name
builder.append(documentType)
builder.append("<")
builder.append(issuingCountry)
builder.append(surname.uppercase().padEnd(39, '<'))
builder.append(givenNames.uppercase().replace(" ", "<"))
builder.append("\n")
// Line 2: Document number, check digit, nationality, DOB, sex, expiration, optional
builder.append(documentNumber.padEnd(9, '<'))
builder.append(calculateCheckDigit(documentNumber))
builder.append(nationality)
builder.append(dateOfBirth)
builder.append(calculateCheckDigit(dateOfBirth))
builder.append(sex)
builder.append(expirationDate)
builder.append(calculateCheckDigit(expirationDate))
builder.append(personalNumber?.padEnd(14, '<') ?: "<".repeat(14))
builder.append(calculateCheckDigit(personalNumber ?: ""))
builder.append(optionalData ?: "")
return builder.toString()
}
/**
* Calculate check digit per ICAO 9303 specification.
*/
private fun calculateCheckDigit(data: String): String {
if (data.isEmpty()) return "0"
val weights = intArrayOf(7, 3, 1)
var sum = 0
data.forEachIndexed { index, char ->
val value = when {
char.isDigit() -> char.toString().toInt()
char.isLetter() -> char.uppercaseChar().code - 55
else -> 0
}
sum += value * weights[index % 3]
}
return (sum % 10).toString()
}
}

View File

@@ -0,0 +1,99 @@
package com.smoa.core.barcode.formats
/**
* MIL-STD-129 Military Identification data structure.
*
* Format specification: MIL-STD-129 - Military Identification
*/
data class MILSTD129Credential(
val serviceCode: String, // Service branch code
val rank: String? = null,
val lastName: String,
val firstName: String,
val middleInitial: String? = null,
val socialSecurityNumber: String, // Last 4 digits or full
val dateOfBirth: String, // YYYYMMDD
val expirationDate: String, // YYYYMMDD
val issueDate: String, // YYYYMMDD
val cardNumber: String,
val unit: String? = null,
val clearanceLevel: String? = null // Classification level
) {
/**
* Encode to MIL-STD-129 format for PDF417 barcode.
*/
fun encodeToMILSTD129Format(): String {
val builder = StringBuilder()
// Header
builder.append("MIL-STD-129")
builder.append("\n")
// Service code
builder.append("SVC:")
builder.append(serviceCode)
builder.append("\n")
// Name
builder.append("LNAME:")
builder.append(lastName)
builder.append("\n")
builder.append("FNAME:")
builder.append(firstName)
builder.append("\n")
if (middleInitial != null) {
builder.append("MI:")
builder.append(middleInitial)
builder.append("\n")
}
// Rank
if (rank != null) {
builder.append("RANK:")
builder.append(rank)
builder.append("\n")
}
// SSN (last 4 or full)
builder.append("SSN:")
builder.append(socialSecurityNumber)
builder.append("\n")
// Dates
builder.append("DOB:")
builder.append(dateOfBirth)
builder.append("\n")
builder.append("ISSUE:")
builder.append(issueDate)
builder.append("\n")
builder.append("EXPIRE:")
builder.append(expirationDate)
builder.append("\n")
// Card number
builder.append("CARD:")
builder.append(cardNumber)
builder.append("\n")
// Unit
if (unit != null) {
builder.append("UNIT:")
builder.append(unit)
builder.append("\n")
}
// Clearance
if (clearanceLevel != null) {
builder.append("CLR:")
builder.append(clearanceLevel)
builder.append("\n")
}
return builder.toString()
}
}

View File

@@ -0,0 +1,80 @@
package com.smoa.core.barcode.ui
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.dp
import com.google.zxing.common.BitMatrix
import com.smoa.core.barcode.PDF417Generator
/**
* Composable for displaying PDF417 barcode.
* Ensures minimum 200 DPI resolution.
*/
@Composable
fun BarcodeDisplay(
bitMatrix: BitMatrix,
modifier: Modifier = Modifier,
errorCorrectionLevel: Int = 5
) {
val width = bitMatrix.width
val height = bitMatrix.height
// Calculate display size maintaining aspect ratio
val displayWidth = 400.dp
val displayHeight = (height * 400 / width).dp
Canvas(
modifier = modifier
.fillMaxWidth()
.height(displayHeight)
) {
drawBarcode(bitMatrix)
}
}
private fun DrawScope.drawBarcode(bitMatrix: BitMatrix) {
val width = bitMatrix.width
val height = bitMatrix.height
val scaleX = size.width / width
val scaleY = size.height / height
for (x in 0 until width) {
for (y in 0 until height) {
if (bitMatrix[x, y]) {
drawRect(
color = Color.Black,
topLeft = Offset(x * scaleX, y * scaleY),
size = Size(scaleX, scaleY)
)
}
}
}
}
/**
* Convert BitMatrix to Android Bitmap for display.
*/
fun BitMatrix.toBitmap(): Bitmap {
val width = this.width
val height = this.height
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
for (x in 0 until width) {
for (y in 0 until height) {
bitmap.setPixel(x, y, if (this[x, y]) android.graphics.Color.BLACK else android.graphics.Color.WHITE)
}
}
return bitmap
}

View File

@@ -0,0 +1,42 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
namespace = "com.smoa.core.certificates"
compileSdk = AppConfig.compileSdk
defaultConfig {
minSdk = AppConfig.minSdk
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(project(":core:common"))
implementation(project(":core:security"))
implementation(Dependencies.androidxCoreKtx)
// Cryptography
implementation(Dependencies.bouncycastle)
implementation(Dependencies.bouncycastlePkix)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}

View File

@@ -0,0 +1,19 @@
package com.smoa.core.certificates
import com.smoa.core.certificates.domain.CertificateManager as DomainCertificateManager
import java.security.cert.X509Certificate
import javax.inject.Inject
/**
* Certificate Manager - Main entry point for certificate management.
*/
class CertificateManager @Inject constructor(
private val domainManager: DomainCertificateManager
) {
fun storeCertificate(certificateId: String, certificate: X509Certificate, metadata: com.smoa.core.certificates.domain.CertificateMetadata) =
domainManager.storeCertificate(certificateId, certificate, metadata)
fun getCertificate(certificateId: String) = domainManager.getCertificate(certificateId)
fun isCertificateValid(certificate: X509Certificate) = domainManager.isCertificateValid(certificate)
}

View File

@@ -0,0 +1,82 @@
package com.smoa.core.certificates.domain
import java.security.cert.X509Certificate
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
/**
* Certificate management system.
*/
@Singleton
class CertificateManager @Inject constructor() {
private val certificates = mutableMapOf<String, CertificateInfo>()
/**
* Store certificate.
*/
fun storeCertificate(certificateId: String, certificate: X509Certificate, metadata: CertificateMetadata) {
certificates[certificateId] = CertificateInfo(
certificateId = certificateId,
certificate = certificate,
metadata = metadata,
storedDate = Date()
)
}
/**
* Get certificate.
*/
fun getCertificate(certificateId: String): CertificateInfo? {
return certificates[certificateId]
}
/**
* Check certificate validity.
*/
fun isCertificateValid(certificate: X509Certificate): Boolean {
return try {
certificate.checkValidity()
true
} catch (e: Exception) {
false
}
}
/**
* Check certificate revocation status via OCSP/CRL.
* TODO: Implement actual OCSP/CRL checking
*/
suspend fun checkRevocationStatus(certificate: X509Certificate): RevocationStatus {
// Placeholder - actual implementation will query OCSP responder or CRL
return RevocationStatus.UNKNOWN
}
}
data class CertificateInfo(
val certificateId: String,
val certificate: X509Certificate,
val metadata: CertificateMetadata,
val storedDate: Date
)
data class CertificateMetadata(
val issuer: String,
val subject: String,
val purpose: CertificatePurpose,
val isQualified: Boolean // eIDAS qualified certificate
)
enum class CertificatePurpose {
SIGNING,
ENCRYPTION,
AUTHENTICATION
}
enum class RevocationStatus {
VALID,
REVOKED,
UNKNOWN
}

View File

@@ -0,0 +1,46 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
namespace = "com.smoa.core.common"
compileSdk = AppConfig.compileSdk
defaultConfig {
minSdk = AppConfig.minSdk
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
dependencies {
implementation(Dependencies.androidxCoreKtx)
implementation(Dependencies.androidxLifecycleRuntimeKtx)
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
// Testing
testImplementation(Dependencies.junit)
testImplementation(Dependencies.mockk)
testImplementation(Dependencies.coroutinesTest)
testImplementation(Dependencies.truth)
}

View File

@@ -0,0 +1,127 @@
package com.smoa.core.common
import android.content.Context
import android.net.Network
import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages connectivity state (online/offline/restricted).
*/
@Singleton
class ConnectivityManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val systemConnectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
private val _connectivityState = MutableStateFlow<ConnectivityState>(ConnectivityState.Unknown)
val connectivityState: StateFlow<ConnectivityState> = _connectivityState.asStateFlow()
private val networkCallback = object : android.net.ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
updateConnectivityState()
}
override fun onLost(network: Network) {
updateConnectivityState()
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
updateConnectivityState()
}
}
init {
registerNetworkCallback()
updateConnectivityState()
}
/**
* Register network callback to monitor connectivity changes.
*/
private fun registerNetworkCallback() {
val networkRequest = android.net.NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
systemConnectivityManager.registerNetworkCallback(networkRequest, networkCallback)
}
/**
* Update current connectivity state.
*/
private fun updateConnectivityState() {
val activeNetwork = systemConnectivityManager.activeNetwork
val capabilities = activeNetwork?.let {
systemConnectivityManager.getNetworkCapabilities(it)
}
_connectivityState.value = when {
capabilities == null -> ConnectivityState.Offline
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) -> {
// Check if connection is restricted (e.g., VPN required but not connected)
if (isRestricted(capabilities)) {
ConnectivityState.Restricted
} else {
ConnectivityState.Online
}
}
else -> ConnectivityState.Offline
}
}
/**
* Check if connection is restricted (e.g., requires VPN but not connected).
*/
private fun isRestricted(capabilities: NetworkCapabilities): Boolean {
// Implement policy-based restriction checks
// For now, return false (can be extended based on policy)
return false
}
/**
* Check if device is currently online.
*/
fun isOnline(): Boolean {
return _connectivityState.value == ConnectivityState.Online
}
/**
* Check if device is offline.
*/
fun isOffline(): Boolean {
return _connectivityState.value == ConnectivityState.Offline
}
/**
* Check if connection is restricted.
*/
fun isRestricted(): Boolean {
return _connectivityState.value == ConnectivityState.Restricted
}
/**
* Get current connectivity state.
*/
fun getState(): ConnectivityState {
return _connectivityState.value
}
enum class ConnectivityState {
Online,
Offline,
Restricted,
Unknown
}
}

View File

@@ -0,0 +1,119 @@
package com.smoa.core.common
import java.util.Locale
/**
* ISO 3166 country codes utilities (ISO 3166-1:2020).
* Provides country code validation and conversion per ISO 3166 standard.
*/
object CountryCodes {
/**
* Get ISO 3166-1 alpha-2 country code (2-letter) from country name.
*/
fun getAlpha2Code(countryName: String): String? {
return alpha2Codes[countryName.uppercase(Locale.US)]
}
/**
* Get ISO 3166-1 alpha-3 country code (3-letter) from country name.
*/
fun getAlpha3Code(countryName: String): String? {
return alpha3Codes[countryName.uppercase(Locale.US)]
}
/**
* Convert alpha-2 to alpha-3 code.
*/
fun alpha2ToAlpha3(alpha2: String): String? {
return alpha2ToAlpha3Map[alpha2.uppercase(Locale.US)]
}
/**
* Convert alpha-3 to alpha-2 code.
*/
fun alpha3ToAlpha2(alpha3: String): String? {
return alpha3ToAlpha2Map[alpha3.uppercase(Locale.US)]
}
/**
* Validate ISO 3166-1 alpha-2 code format and validity.
*/
fun isValidAlpha2(code: String): Boolean {
val upperCode = code.uppercase(Locale.US)
return upperCode.length == 2 &&
upperCode.all { it.isLetter() } &&
alpha2Codes.values.contains(upperCode)
}
/**
* Validate ISO 3166-1 alpha-3 code format and validity.
*/
fun isValidAlpha3(code: String): Boolean {
val upperCode = code.uppercase(Locale.US)
return upperCode.length == 3 &&
upperCode.all { it.isLetter() } &&
alpha3Codes.values.contains(upperCode)
}
/**
* Get numeric country code (ISO 3166-1 numeric).
*/
fun getNumericCode(alpha2: String): String? {
return numericCodes[alpha2.uppercase(Locale.US)]
}
// Common country codes - in production, use full ISO 3166-1:2020 database
private val alpha2Codes = mapOf(
"UNITED STATES" to "US",
"CANADA" to "CA",
"MEXICO" to "MX",
"UNITED KINGDOM" to "GB",
"FRANCE" to "FR",
"GERMANY" to "DE",
"ITALY" to "IT",
"SPAIN" to "ES",
"AUSTRALIA" to "AU",
"JAPAN" to "JP",
"CHINA" to "CN",
"RUSSIA" to "RU"
)
private val alpha3Codes = mapOf(
"UNITED STATES" to "USA",
"CANADA" to "CAN",
"MEXICO" to "MEX",
"UNITED KINGDOM" to "GBR",
"FRANCE" to "FRA",
"GERMANY" to "DEU",
"ITALY" to "ITA",
"SPAIN" to "ESP",
"AUSTRALIA" to "AUS",
"JAPAN" to "JPN",
"CHINA" to "CHN",
"RUSSIA" to "RUS"
)
private val alpha2ToAlpha3Map = mapOf(
"US" to "USA",
"CA" to "CAN",
"MX" to "MEX",
"GB" to "GBR",
"FR" to "FRA",
"DE" to "DEU",
"IT" to "ITA",
"ES" to "ESP"
)
private val alpha3ToAlpha2Map = alpha2ToAlpha3Map.entries.associate { (k, v) -> v to k }
private val numericCodes = mapOf(
"US" to "840",
"CA" to "124",
"MX" to "484",
"GB" to "826",
"FR" to "250",
"DE" to "276"
)
}

View File

@@ -0,0 +1,80 @@
package com.smoa.core.common
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Date formatting utilities for ISO 8601 compliance (ISO 8601:2019).
* Ensures full compliance with ISO 8601 standard for date/time representation.
*/
object DateFormatting {
private val iso8601Format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private val iso8601Basic = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private val iso8601DateOnly = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val iso8601TimeOnly = SimpleDateFormat("HH:mm:ss", Locale.US)
/**
* Format date to ISO 8601 format with timezone (YYYY-MM-DDTHH:mm:ss.sssZ).
*/
fun toISO8601(date: Date): String {
return iso8601Format.format(date)
}
/**
* Format date to ISO 8601 basic format (YYYYMMDDTHHmmssZ).
*/
fun toISO8601Basic(date: Date): String {
return iso8601Basic.format(date)
}
/**
* Format date to ISO 8601 date-only format (YYYY-MM-DD).
*/
fun toISO8601Date(date: Date): String {
return iso8601DateOnly.format(date)
}
/**
* Format time to ISO 8601 time format (HH:mm:ss).
*/
fun toISO8601Time(date: Date): String {
return iso8601TimeOnly.format(date)
}
/**
* Parse ISO 8601 date string (extended format).
*/
fun fromISO8601(dateString: String): Date? {
return try {
iso8601Format.parse(dateString)
} catch (e: Exception) {
null
}
}
/**
* Parse ISO 8601 basic format date string.
*/
fun fromISO8601Basic(dateString: String): Date? {
return try {
iso8601Basic.parse(dateString)
} catch (e: Exception) {
null
}
}
/**
* Get current time in ISO 8601 format.
*/
fun nowISO8601(): String {
return toISO8601(Date())
}
}

View File

@@ -0,0 +1,50 @@
package com.smoa.core.common
import android.content.res.Configuration
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages foldable device state (folded/unfolded).
*/
@Singleton
class FoldableStateManager @Inject constructor() {
private val _foldState = MutableStateFlow<FoldState>(FoldState.Unknown)
val foldState: StateFlow<FoldState> = _foldState.asStateFlow()
/**
* Update fold state based on configuration.
*/
fun updateFoldState(configuration: Configuration) {
val isFolded = configuration.screenWidthDp < 600 // Threshold for tablet/folded detection
_foldState.value = if (isFolded) {
FoldState.Folded
} else {
FoldState.Unfolded
}
}
/**
* Check if device is currently folded.
*/
fun isFolded(): Boolean {
return _foldState.value == FoldState.Folded
}
/**
* Check if device is currently unfolded.
*/
fun isUnfolded(): Boolean {
return _foldState.value == FoldState.Unfolded
}
enum class FoldState {
Folded,
Unfolded,
Unknown
}
}

View File

@@ -0,0 +1,57 @@
package com.smoa.core.common
import java.util.Date
/**
* ISO/IEC 19794 biometric template support.
*/
object ISO19794Biometric {
/**
* Biometric template format identifiers per ISO/IEC 19794.
*/
enum class FormatIdentifier(val code: Int) {
FINGERPRINT(0x0010),
FACIAL(0x0011),
IRIS(0x0012),
VOICE(0x0013)
}
/**
* Create ISO 19794 compliant biometric header.
*/
fun createBiometricHeader(
formatIdentifier: FormatIdentifier,
version: Int = 0x30313000, // Version 1.0
length: Int,
captureDate: Date
): ByteArray {
// ISO 19794 header structure
val header = mutableListOf<Byte>()
// Format identifier (4 bytes)
header.addAll(intToBytes(formatIdentifier.code, 4))
// Version (4 bytes)
header.addAll(intToBytes(version, 4))
// Length (4 bytes)
header.addAll(intToBytes(length, 4))
// Capture date/time (14 bytes - YYYYMMDDHHmmss)
val dateFormat = java.text.SimpleDateFormat("yyyyMMddHHmmss", java.util.Locale.US)
val dateStr = dateFormat.format(captureDate)
header.addAll(dateStr.toByteArray(Charsets.ISO_8859_1).toList())
return header.toByteArray()
}
private fun intToBytes(value: Int, bytes: Int): List<Byte> {
val result = mutableListOf<Byte>()
for (i in bytes - 1 downTo 0) {
result.add(((value shr (i * 8)) and 0xFF).toByte())
}
return result
}
}

View File

@@ -0,0 +1,102 @@
package com.smoa.core.common
import java.util.Date
/**
* ISO/IEC 27001 Information Security Management System (ISMS) compliance utilities.
*/
object ISO27001ISMS {
/**
* Security control categories per ISO 27001.
*/
enum class ControlCategory {
SECURITY_POLICIES,
ORGANIZATION_OF_INFORMATION_SECURITY,
HUMAN_RESOURCE_SECURITY,
ASSET_MANAGEMENT,
ACCESS_CONTROL,
CRYPTOGRAPHY,
PHYSICAL_AND_ENVIRONMENTAL_SECURITY,
OPERATIONS_SECURITY,
COMMUNICATIONS_SECURITY,
SYSTEM_ACQUISITION_DEVELOPMENT_AND_MAINTENANCE,
SUPPLIER_RELATIONSHIPS,
INFORMATION_SECURITY_INCIDENT_MANAGEMENT,
INFORMATION_SECURITY_ASPECTS_OF_BUSINESS_CONTINUITY_MANAGEMENT,
COMPLIANCE
}
/**
* Security event record for ISMS compliance.
*/
data class SecurityEvent(
val eventId: String,
val timestamp: Date,
val category: ControlCategory,
val description: String,
val severity: Severity,
val userId: String?,
val resource: String?,
val outcome: EventOutcome
)
enum class Severity {
LOW,
MEDIUM,
HIGH,
CRITICAL
}
enum class EventOutcome {
SUCCESS,
FAILURE,
PARTIAL
}
/**
* ISMS documentation structure.
*/
data class ISMSDocumentation(
val policyDocuments: List<PolicyDocument>,
val procedures: List<Procedure>,
val records: List<SecurityEvent>,
val riskAssessments: List<RiskAssessment>,
val lastReviewed: Date
)
data class PolicyDocument(
val documentId: String,
val title: String,
val version: String,
val effectiveDate: Date,
val reviewDate: Date?,
val owner: String
)
data class Procedure(
val procedureId: String,
val title: String,
val steps: List<String>,
val version: String,
val lastUpdated: Date
)
data class RiskAssessment(
val assessmentId: String,
val asset: String,
val threat: String,
val vulnerability: String,
val riskLevel: RiskLevel,
val mitigation: String?,
val assessmentDate: Date
)
enum class RiskLevel {
LOW,
MEDIUM,
HIGH,
CRITICAL
}
}

View File

@@ -0,0 +1,97 @@
package com.smoa.core.common
/**
* ISO/IEC 7816 smart card integration support.
*/
object ISO7816SmartCard {
/**
* APDU (Application Protocol Data Unit) command structure per ISO 7816-4.
*/
data class APDUCommand(
val cla: Byte, // Class byte
val ins: Byte, // Instruction byte
val p1: Byte, // Parameter 1
val p2: Byte, // Parameter 2
val data: ByteArray? = null,
val le: Byte? = null // Expected length
) {
fun toByteArray(): ByteArray {
val result = mutableListOf<Byte>()
result.add(cla)
result.add(ins)
result.add(p1)
result.add(p2)
if (data != null) {
result.add(data.size.toByte())
result.addAll(data.toList())
}
if (le != null) {
result.add(le)
}
return result.toByteArray()
}
}
/**
* APDU response structure.
*/
data class APDUResponse(
val data: ByteArray,
val sw1: Byte,
val sw2: Byte
) {
val statusWord: Int
get() = ((sw1.toInt() and 0xFF) shl 8) or (sw2.toInt() and 0xFF)
val isSuccess: Boolean
get() = statusWord == 0x9000
}
/**
* Common APDU instructions per ISO 7816-4.
*/
object Instructions {
const val SELECT_FILE: Byte = 0xA4.toByte()
const val READ_BINARY: Byte = 0xB0.toByte()
const val UPDATE_BINARY: Byte = 0xD6.toByte()
const val READ_RECORD: Byte = 0xB2.toByte()
const val GET_RESPONSE: Byte = 0xC0.toByte()
const val VERIFY: Byte = 0x20
const val CHANGE_REFERENCE_DATA: Byte = 0x24
}
/**
* Create SELECT FILE APDU command.
*/
fun createSelectFileCommand(fileId: ByteArray, p2: Byte = 0x00.toByte()): APDUCommand {
return APDUCommand(
cla = 0x00,
ins = Instructions.SELECT_FILE,
p1 = 0x00,
p2 = p2,
data = fileId
)
}
/**
* Create READ BINARY APDU command.
*/
fun createReadBinaryCommand(offset: Int, length: Int): APDUCommand {
val offsetBytes = byteArrayOf(
((offset shr 8) and 0xFF).toByte(),
(offset and 0xFF).toByte()
)
return APDUCommand(
cla = 0x00,
ins = Instructions.READ_BINARY,
p1 = offsetBytes[0],
p2 = offsetBytes[1],
le = length.toByte()
)
}
}

View File

@@ -0,0 +1,71 @@
package com.smoa.core.common
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
/**
* Offline policy manager.
* Enforces time-bounded offline data caches and automatic purge.
*/
@Singleton
class OfflinePolicyManager @Inject constructor() {
companion object {
private const val DEFAULT_MAX_OFFLINE_DURATION_MS = 7L * 24 * 60 * 60 * 1000 // 7 days
private const val DEFAULT_CREDENTIAL_CACHE_DURATION_MS = 30L * 24 * 60 * 60 * 1000 // 30 days
private const val DEFAULT_ORDER_CACHE_DURATION_MS = 14L * 24 * 60 * 60 * 1000 // 14 days
private const val DEFAULT_EVIDENCE_CACHE_DURATION_MS = 90L * 24 * 60 * 60 * 1000 // 90 days
}
/**
* Get maximum offline duration for a data type.
*/
fun getMaxOfflineDuration(dataType: OfflineDataType): Long {
return when (dataType) {
OfflineDataType.Credential -> DEFAULT_CREDENTIAL_CACHE_DURATION_MS
OfflineDataType.Order -> DEFAULT_ORDER_CACHE_DURATION_MS
OfflineDataType.Evidence -> DEFAULT_EVIDENCE_CACHE_DURATION_MS
OfflineDataType.Directory -> DEFAULT_MAX_OFFLINE_DURATION_MS
OfflineDataType.Report -> DEFAULT_MAX_OFFLINE_DURATION_MS
}
}
/**
* Check if offline data is still valid.
*/
fun isOfflineDataValid(lastSyncTime: Date, dataType: OfflineDataType): Boolean {
val maxDuration = getMaxOfflineDuration(dataType)
val now = Date()
val offlineDuration = now.time - lastSyncTime.time
return offlineDuration <= maxDuration
}
/**
* Check if offline data should be purged.
*/
fun shouldPurgeOfflineData(lastSyncTime: Date, dataType: OfflineDataType): Boolean {
return !isOfflineDataValid(lastSyncTime, dataType)
}
/**
* Get time remaining until offline data expires.
*/
fun getTimeUntilExpiration(lastSyncTime: Date, dataType: OfflineDataType): Long {
val maxDuration = getMaxOfflineDuration(dataType)
val now = Date()
val offlineDuration = now.time - lastSyncTime.time
return maxOf(0, maxDuration - offlineDuration)
}
}
/**
* Offline data types.
*/
enum class OfflineDataType {
Credential,
Order,
Evidence,
Directory,
Report
}

View File

@@ -0,0 +1,8 @@
package com.smoa.core.common
sealed class Result<out T> {
data class Success<out T>(val data: T) : Result<T>()
data class Error(val exception: Throwable) : Result<Nothing>()
object Loading : Result<Nothing>()
}

View File

@@ -0,0 +1,52 @@
package com.smoa.core.common
import javax.inject.Inject
import javax.inject.Singleton
/**
* Smart card reader interface for ISO 7816 card integration.
*
* Note: Actual implementation will depend on hardware card reader support.
*/
@Singleton
class SmartCardReader @Inject constructor() {
/**
* Check if smart card is present.
*/
suspend fun isCardPresent(): Boolean {
// TODO: Implement actual card detection
return false
}
/**
* Connect to smart card.
*/
suspend fun connect(): Result<SmartCardConnection> {
// TODO: Implement actual card connection
return Result.Error(NotImplementedError("Smart card connection not yet implemented"))
}
/**
* Disconnect from smart card.
*/
suspend fun disconnect() {
// TODO: Implement actual card disconnection
}
}
/**
* Smart card connection for APDU communication.
*/
interface SmartCardConnection {
/**
* Transmit APDU command and receive response.
*/
suspend fun transmit(command: ISO7816SmartCard.APDUCommand): Result<ISO7816SmartCard.APDUResponse>
/**
* Close connection.
*/
suspend fun close()
}

View File

@@ -0,0 +1,107 @@
package com.smoa.core.common
/**
* Sync API interface for backend synchronization.
* Defines the contract for syncing data with backend services.
*/
interface SyncAPI {
/**
* Sync order to backend.
*/
suspend fun syncOrder(orderData: ByteArray): Result<SyncResponse>
/**
* Sync evidence to backend.
*/
suspend fun syncEvidence(evidenceData: ByteArray): Result<SyncResponse>
/**
* Sync credential to backend.
*/
suspend fun syncCredential(credentialData: ByteArray): Result<SyncResponse>
/**
* Sync directory entry to backend.
*/
suspend fun syncDirectoryEntry(entryData: ByteArray): Result<SyncResponse>
/**
* Sync report to backend.
*/
suspend fun syncReport(reportData: ByteArray): Result<SyncResponse>
}
/**
* Sync response from backend.
*/
data class SyncResponse(
val success: Boolean,
val itemId: String,
val serverTimestamp: Long,
val conflict: Boolean = false,
val remoteData: ByteArray? = null,
val message: String? = null
)
/**
* Default implementation of SyncAPI.
* In production, this would use Retrofit or similar to call actual backend APIs.
*/
class DefaultSyncAPI : SyncAPI {
override suspend fun syncOrder(orderData: ByteArray): Result<SyncResponse> {
// TODO: Implement actual API call
// This would use Retrofit to POST order data to backend
return Result.Success(
SyncResponse(
success = true,
itemId = "order_123",
serverTimestamp = System.currentTimeMillis()
)
)
}
override suspend fun syncEvidence(evidenceData: ByteArray): Result<SyncResponse> {
// TODO: Implement actual API call
return Result.Success(
SyncResponse(
success = true,
itemId = "evidence_123",
serverTimestamp = System.currentTimeMillis()
)
)
}
override suspend fun syncCredential(credentialData: ByteArray): Result<SyncResponse> {
// TODO: Implement actual API call
return Result.Success(
SyncResponse(
success = true,
itemId = "credential_123",
serverTimestamp = System.currentTimeMillis()
)
)
}
override suspend fun syncDirectoryEntry(entryData: ByteArray): Result<SyncResponse> {
// TODO: Implement actual API call
return Result.Success(
SyncResponse(
success = true,
itemId = "directory_123",
serverTimestamp = System.currentTimeMillis()
)
)
}
override suspend fun syncReport(reportData: ByteArray): Result<SyncResponse> {
// TODO: Implement actual API call
return Result.Success(
SyncResponse(
success = true,
itemId = "report_123",
serverTimestamp = System.currentTimeMillis()
)
)
}
}

View File

@@ -0,0 +1,400 @@
package com.smoa.core.common
import android.content.Context
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
/**
* Offline synchronization service.
* Handles data synchronization when connectivity is restored.
*/
@Singleton
class SyncService @Inject constructor(
private val context: Context,
private val connectivityManager: ConnectivityManager,
private val syncAPI: SyncAPI = DefaultSyncAPI()
) {
private val _syncState = MutableStateFlow<SyncState>(SyncState.Idle)
val syncState: StateFlow<SyncState> = _syncState.asStateFlow()
private val syncQueue = mutableListOf<SyncItem>()
private val conflictResolver = ConflictResolver()
/**
* Queue an item for synchronization.
*/
fun queueSync(item: SyncItem) {
syncQueue.add(item)
if (connectivityManager.isOnline()) {
// Note: startSync() is a suspend function, caller should use coroutine scope
// This will be handled by the sync service when connectivity is restored
}
}
/**
* Start synchronization process.
*/
suspend fun startSync() {
if (!connectivityManager.isOnline()) {
_syncState.value = SyncState.WaitingForConnection
return
}
if (syncQueue.isEmpty()) {
_syncState.value = SyncState.Idle
return
}
_syncState.value = SyncState.Syncing(syncQueue.size)
val itemsToSync = syncQueue.toList()
syncQueue.clear()
for (item in itemsToSync) {
try {
syncItem(item)
} catch (e: ConflictException) {
// Handle conflict
val resolution = conflictResolver.resolveConflict(item, e)
when (resolution) {
is ConflictResolution.UseLocal -> {
// Keep local version
}
is ConflictResolution.UseRemote -> {
// Use remote version
syncItem(item.copy(data = e.remoteData))
}
is ConflictResolution.Merge -> {
// Merge both versions
syncItem(item.copy(data = resolution.mergedData))
}
}
} catch (e: Exception) {
// Re-queue failed items
syncQueue.add(item)
}
}
_syncState.value = SyncState.Idle
}
/**
* Sync a single item.
*/
private suspend fun syncItem(item: SyncItem) {
// Implement sync logic based on item type
// In a full implementation, this would call appropriate service methods
when (item.type) {
SyncItemType.Order -> {
syncOrder(item)
}
SyncItemType.Evidence -> {
syncEvidence(item)
}
SyncItemType.Credential -> {
syncCredential(item)
}
SyncItemType.Directory -> {
syncDirectoryEntry(item)
}
SyncItemType.Report -> {
syncReport(item)
}
}
}
/**
* Sync order item.
*/
private suspend fun syncOrder(item: SyncItem) {
try {
// Serialize order data (in production, use proper serialization like JSON)
val orderData = serializeOrderData(item.data)
// Send to backend API
val result = syncAPI.syncOrder(orderData)
when (result) {
is Result.Success -> {
val response = result.data
if (response.conflict && response.remoteData != null) {
// Handle conflict
throw ConflictException(
localData = item.data,
remoteData = response.remoteData,
message = "Order conflict detected: ${item.id}"
)
}
// Sync successful - item is now synced
}
is Result.Error -> throw result.exception
is Result.Loading -> throw Exception("Unexpected loading state")
}
} catch (e: ConflictException) {
throw e
} catch (e: Exception) {
throw Exception("Failed to sync order: ${e.message}", e)
}
}
/**
* Sync evidence item.
*/
private suspend fun syncEvidence(item: SyncItem) {
try {
val evidenceData = serializeEvidenceData(item.data)
val result = syncAPI.syncEvidence(evidenceData)
when (result) {
is Result.Success -> {
val response = result.data
if (response.conflict && response.remoteData != null) {
throw ConflictException(
localData = item.data,
remoteData = response.remoteData,
message = "Evidence conflict detected: ${item.id}"
)
}
}
is Result.Error -> throw result.exception
is Result.Loading -> throw Exception("Unexpected loading state")
}
} catch (e: ConflictException) {
throw e
} catch (e: Exception) {
throw Exception("Failed to sync evidence: ${e.message}", e)
}
}
/**
* Sync credential item.
*/
private suspend fun syncCredential(item: SyncItem) {
try {
val credentialData = serializeCredentialData(item.data)
val result = syncAPI.syncCredential(credentialData)
when (result) {
is Result.Success -> {
val response = result.data
if (response.conflict && response.remoteData != null) {
throw ConflictException(
localData = item.data,
remoteData = response.remoteData,
message = "Credential conflict detected: ${item.id}"
)
}
}
is Result.Error -> throw result.exception
is Result.Loading -> throw Exception("Unexpected loading state")
}
} catch (e: ConflictException) {
throw e
} catch (e: Exception) {
throw Exception("Failed to sync credential: ${e.message}", e)
}
}
/**
* Sync directory entry item.
*/
private suspend fun syncDirectoryEntry(item: SyncItem) {
try {
val entryData = serializeDirectoryEntryData(item.data)
val result = syncAPI.syncDirectoryEntry(entryData)
when (result) {
is Result.Success -> {
val response = result.data
if (response.conflict && response.remoteData != null) {
throw ConflictException(
localData = item.data,
remoteData = response.remoteData,
message = "Directory entry conflict detected: ${item.id}"
)
}
}
is Result.Error -> throw result.exception
is Result.Loading -> throw Exception("Unexpected loading state")
}
} catch (e: ConflictException) {
throw e
} catch (e: Exception) {
throw Exception("Failed to sync directory entry: ${e.message}", e)
}
}
/**
* Sync report item.
*/
private suspend fun syncReport(item: SyncItem) {
try {
val reportData = serializeReportData(item.data)
val result = syncAPI.syncReport(reportData)
when (result) {
is Result.Success -> {
val response = result.data
if (response.conflict && response.remoteData != null) {
throw ConflictException(
localData = item.data,
remoteData = response.remoteData,
message = "Report conflict detected: ${item.id}"
)
}
}
is Result.Error -> throw result.exception
is Result.Loading -> throw Exception("Unexpected loading state")
}
} catch (e: ConflictException) {
throw e
} catch (e: Exception) {
throw Exception("Failed to sync report: ${e.message}", e)
}
}
/**
* Serialize order data for transmission.
*/
private fun serializeOrderData(data: Any): ByteArray {
// TODO: Use proper JSON serialization (e.g., Jackson, Gson)
// For now, return empty array as placeholder
return ByteArray(0)
}
/**
* Serialize evidence data for transmission.
*/
private fun serializeEvidenceData(data: Any): ByteArray {
// TODO: Use proper JSON serialization
return ByteArray(0)
}
/**
* Serialize credential data for transmission.
*/
private fun serializeCredentialData(data: Any): ByteArray {
// TODO: Use proper JSON serialization
return ByteArray(0)
}
/**
* Serialize directory entry data for transmission.
*/
private fun serializeDirectoryEntryData(data: Any): ByteArray {
// TODO: Use proper JSON serialization
return ByteArray(0)
}
/**
* Serialize report data for transmission.
*/
private fun serializeReportData(data: Any): ByteArray {
// TODO: Use proper JSON serialization
return ByteArray(0)
}
/**
* Check if offline duration threshold has been exceeded.
*/
fun checkOfflineDuration(lastSyncTime: Date, maxOfflineDurationMs: Long): Boolean {
val now = Date()
val offlineDuration = now.time - lastSyncTime.time
return offlineDuration > maxOfflineDurationMs
}
/**
* Purge data that exceeds offline duration threshold.
*/
suspend fun purgeExpiredOfflineData(maxOfflineDurationMs: Long) {
// Purge expired items from sync queue
val now = Date()
val expiredItems = syncQueue.filter { item ->
val itemAge = now.time - item.timestamp.time
itemAge > maxOfflineDurationMs
}
syncQueue.removeAll(expiredItems)
// TODO: Integrate with individual services to purge expired data
// This would:
// 1. Check each data type's offline duration policy
// 2. Remove expired data from local storage
// 3. Log purging events
}
}
/**
* Sync item types.
*/
enum class SyncItemType {
Order,
Evidence,
Credential,
Directory,
Report
}
/**
* Item to be synchronized.
*/
data class SyncItem(
val id: String,
val type: SyncItemType,
val data: Any,
val timestamp: Date = Date(),
val operation: SyncOperation = SyncOperation.Update
)
/**
* Sync operations.
*/
enum class SyncOperation {
Create,
Update,
Delete
}
/**
* Sync state.
*/
sealed class SyncState {
object Idle : SyncState()
object WaitingForConnection : SyncState()
data class Syncing(val itemsRemaining: Int) : SyncState()
data class Error(val message: String) : SyncState()
}
/**
* Conflict exception.
*/
class ConflictException(
val localData: Any,
val remoteData: Any,
message: String
) : Exception(message)
/**
* Conflict resolver.
*/
class ConflictResolver {
fun resolveConflict(item: SyncItem, exception: ConflictException): ConflictResolution {
// Default strategy: use remote (server wins)
// Can be customized based on item type or policy
return ConflictResolution.UseRemote
}
}
/**
* Conflict resolution strategies.
*/
sealed class ConflictResolution {
object UseLocal : ConflictResolution()
object UseRemote : ConflictResolution()
data class Merge(val mergedData: Any) : ConflictResolution()
}

View File

@@ -0,0 +1,44 @@
package com.smoa.core.common.di
import android.content.Context
import com.smoa.core.common.ConnectivityManager
import com.smoa.core.common.FoldableStateManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object CommonModule {
@Provides
@Singleton
fun provideFoldableStateManager(): FoldableStateManager {
return FoldableStateManager()
}
@Provides
@Singleton
fun provideConnectivityManager(
@ApplicationContext context: Context
): ConnectivityManager {
return ConnectivityManager(context)
}
@Provides
@Singleton
fun provideSyncService(
@ApplicationContext context: Context,
connectivityManager: ConnectivityManager
): com.smoa.core.common.SyncService {
return com.smoa.core.common.SyncService(context, connectivityManager)
}
@Provides
@Singleton
fun provideOfflinePolicyManager(): com.smoa.core.common.OfflinePolicyManager {
return com.smoa.core.common.OfflinePolicyManager()
}
}

View File

@@ -0,0 +1,60 @@
package com.smoa.core.common
import io.mockk.MockKMatcherScope
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* Mock helpers for common test scenarios.
*/
object MockHelpers {
/**
* Create a mock that returns a successful Result.
*/
inline fun <reified T> mockSuccess(value: T): T {
return mockk<T> {
// Add common mock behaviors here
}
}
/**
* Create a mock that returns a failed Result.
*/
inline fun <reified T> mockFailure(exception: Exception): T {
return mockk<T> {
// Add common mock behaviors here
}
}
/**
* Create a Flow mock that emits a single value.
*/
fun <T> mockFlow(value: T): Flow<T> = flowOf(value)
/**
* Create a Flow mock that emits multiple values.
*/
fun <T> mockFlow(vararg values: T): Flow<T> = flowOf(*values)
}
/**
* Extension function for coEvery with Result.
*/
fun <T> MockKMatcherScope.coEveryResult(
block: suspend MockKMatcherScope.() -> Result<T>
): Result<T> {
return coEvery { block() }
}
/**
* Extension function for coVerify with Result.
*/
fun <T> MockKMatcherScope.coVerifyResult(
verifyBlock: suspend MockKMatcherScope.(Result<T>) -> Unit
) {
coVerify { verifyBlock(any()) }
}

View File

@@ -0,0 +1,75 @@
package com.smoa.core.common
import org.junit.Assert.*
import org.junit.Test
import java.util.Date
/**
* Unit tests for OfflinePolicyManager.
*/
class OfflinePolicyManagerTest {
private val policyManager = OfflinePolicyManager()
@Test
fun `getMaxOfflineDuration should return correct duration for each type`() {
// When
val credentialDuration = policyManager.getMaxOfflineDuration(OfflineDataType.Credential)
val orderDuration = policyManager.getMaxOfflineDuration(OfflineDataType.Order)
val evidenceDuration = policyManager.getMaxOfflineDuration(OfflineDataType.Evidence)
// Then
assertTrue(credentialDuration > 0)
assertTrue(orderDuration > 0)
assertTrue(evidenceDuration > 0)
assertTrue(evidenceDuration > orderDuration) // Evidence has longer retention
}
@Test
fun `isOfflineDataValid should return true for recent data`() {
// Given
val recentDate = Date(System.currentTimeMillis() - (1 * 24 * 60 * 60 * 1000L)) // 1 day ago
// When
val result = policyManager.isOfflineDataValid(recentDate, OfflineDataType.Credential)
// Then
assertTrue(result)
}
@Test
fun `isOfflineDataValid should return false for expired data`() {
// Given
val oldDate = Date(System.currentTimeMillis() - (100 * 24 * 60 * 60 * 1000L)) // 100 days ago
// When
val result = policyManager.isOfflineDataValid(oldDate, OfflineDataType.Credential)
// Then
assertFalse(result)
}
@Test
fun `shouldPurgeOfflineData should return true for expired data`() {
// Given
val oldDate = Date(System.currentTimeMillis() - (100 * 24 * 60 * 60 * 1000L))
// When
val result = policyManager.shouldPurgeOfflineData(oldDate, OfflineDataType.Credential)
// Then
assertTrue(result)
}
@Test
fun `getTimeUntilExpiration should return positive value for valid data`() {
// Given
val recentDate = Date(System.currentTimeMillis() - (1 * 24 * 60 * 60 * 1000L))
// When
val timeRemaining = policyManager.getTimeUntilExpiration(recentDate, OfflineDataType.Credential)
// Then
assertTrue(timeRemaining > 0)
}
}

View File

@@ -0,0 +1,91 @@
package com.smoa.core.common
import com.smoa.core.common.SyncAPI
import com.smoa.core.common.SyncResponse
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Test
import java.util.Date
/**
* Unit tests for SyncService.
*/
class SyncServiceTest {
private val context = mockk<android.content.Context>(relaxed = true)
private val connectivityManager = mockk<ConnectivityManager>(relaxed = true)
private val syncAPI = mockk<SyncAPI>(relaxed = true)
private val syncService = SyncService(context, connectivityManager, syncAPI)
@Test
fun `queueSync should add item to queue`() = runTest {
// Given
val item = SyncItem(
id = "test1",
type = SyncItemType.Order,
data = "test data"
)
every { connectivityManager.isOnline() } returns false
// When
syncService.queueSync(item)
// Then
// Item should be queued (we can't directly verify queue, but sync should work)
assertTrue(true) // Placeholder - would verify queue state if exposed
}
@Test
fun `startSync should sync items when online`() = runTest {
// Given
val item = SyncItem(
id = "test1",
type = SyncItemType.Order,
data = "test data"
)
every { connectivityManager.isOnline() } returns true
coEvery { syncAPI.syncOrder(any()) } returns Result.success(
SyncResponse(
success = true,
itemId = "test1",
serverTimestamp = System.currentTimeMillis()
)
)
// When
syncService.queueSync(item)
syncService.startSync()
// Then
// Sync should complete successfully
assertTrue(true) // Placeholder - would verify sync state
}
@Test
fun `checkOfflineDuration should return true when exceeded`() {
// Given
val lastSyncTime = Date(System.currentTimeMillis() - (8 * 24 * 60 * 60 * 1000L)) // 8 days ago
val maxDuration = 7L * 24 * 60 * 60 * 1000L // 7 days
// When
val result = syncService.checkOfflineDuration(lastSyncTime, maxDuration)
// Then
assertTrue(result)
}
@Test
fun `checkOfflineDuration should return false when within limit`() {
// Given
val lastSyncTime = Date(System.currentTimeMillis() - (5 * 24 * 60 * 60 * 1000L)) // 5 days ago
val maxDuration = 7L * 24 * 60 * 60 * 1000L // 7 days
// When
val result = syncService.checkOfflineDuration(lastSyncTime, maxDuration)
// Then
assertFalse(result)
}
}

View File

@@ -0,0 +1,35 @@
package com.smoa.core.common
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/**
* JUnit rule for testing coroutines.
* Provides a test dispatcher and manages coroutine context.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class TestCoroutineRule(
private val testDispatcher: TestDispatcher = StandardTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
super.starting(description)
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
super.finished(description)
Dispatchers.resetMain()
}
fun runTest(block: suspend () -> Unit) {
testDispatcher.scheduler.advanceUntilIdle()
}
}

View File

@@ -0,0 +1,16 @@
package com.smoa.core.common
/**
* Test utilities and helpers.
*/
object TestUtils {
/**
* Create a test connectivity manager.
*/
fun createTestConnectivityManager(): ConnectivityManager {
// This would be a mock or test implementation
// For now, return a placeholder
throw NotImplementedError("Test implementation needed")
}
}

View File

@@ -0,0 +1,44 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
namespace = "com.smoa.core.eidas"
compileSdk = AppConfig.compileSdk
defaultConfig {
minSdk = AppConfig.minSdk
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(project(":core:common"))
implementation(project(":core:security"))
implementation(project(":core:certificates"))
implementation(project(":core:signing"))
implementation(Dependencies.androidxCoreKtx)
// Cryptography
implementation(Dependencies.bouncycastle)
implementation(Dependencies.bouncycastlePkix)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}

View File

@@ -0,0 +1,13 @@
package com.smoa.core.eidas
import com.smoa.core.eidas.domain.EIDASService as DomainEIDASService
import javax.inject.Inject
/**
* eIDAS Service - Main entry point for eIDAS compliance features.
*/
class EIDASService @Inject constructor(
private val domainService: DomainEIDASService
) {
// Service methods delegate to domain service
}

View File

@@ -0,0 +1,30 @@
package com.smoa.core.eidas.domain
import java.util.Date
/**
* eIDAS qualified certificate data model.
*/
data class EIDASCertificate(
val certificateId: String,
val certificateData: String, // Base64 encoded X.509 certificate
val issuer: String, // Qualified Trust Service Provider
val subject: String,
val validFrom: Date,
val validTo: Date,
val certificateLevel: CertificateLevel,
val revocationStatus: RevocationStatus,
val lastChecked: Date?
)
enum class CertificateLevel {
QUALIFIED,
NON_QUALIFIED
}
enum class RevocationStatus {
VALID,
REVOKED,
UNKNOWN
}

View File

@@ -0,0 +1,93 @@
package com.smoa.core.eidas.domain
import com.smoa.core.security.AuditLogger
import com.smoa.core.security.AuditEventType
import java.util.Date
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* eIDAS compliance service.
* Provides qualified electronic signatures, certificates, timestamping, and seals.
*/
@Singleton
class EIDASService @Inject constructor(
private val auditLogger: AuditLogger
) {
/**
* Create qualified electronic signature.
* TODO: Integrate with Qualified Trust Service Provider (QTSP)
*/
suspend fun createQualifiedSignature(
documentHash: String,
certificate: EIDASCertificate,
signerInfo: SignerInfo
): Result<QualifiedSignature> {
return try {
// TODO: Actual signature creation with QTSP
val signature = QualifiedSignature(
signatureId = UUID.randomUUID().toString(),
documentHash = documentHash,
signatureValue = ByteArray(256), // Placeholder
certificate = certificate,
timestamp = Date(),
timestampToken = null, // TODO: Get from qualified TSA
signerInfo = signerInfo
)
auditLogger.logEvent(
AuditEventType.POLICY_UPDATE,
userId = signerInfo.signerId,
module = "eidas",
details = "Qualified signature created: ${signature.signatureId}"
)
Result.success(signature)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Validate qualified certificate against EU Trust Lists.
*/
suspend fun validateCertificate(certificate: EIDASCertificate): Result<ValidationResult> {
// TODO: Validate against EU Trust Lists
return Result.success(ValidationResult.VALID)
}
/**
* Create electronic seal.
*/
suspend fun createElectronicSeal(
documentHash: String,
certificate: EIDASCertificate,
legalEntityInfo: LegalEntityInfo
): Result<ElectronicSeal> {
return try {
val seal = ElectronicSeal(
sealId = UUID.randomUUID().toString(),
documentHash = documentHash,
sealValue = ByteArray(256), // Placeholder
certificate = certificate,
timestamp = Date(),
legalEntityInfo = legalEntityInfo
)
Result.success(seal)
} catch (e: Exception) {
Result.failure(e)
}
}
}
enum class ValidationResult {
VALID,
INVALID,
REVOKED,
EXPIRED,
UNKNOWN
}

View File

@@ -0,0 +1,23 @@
package com.smoa.core.eidas.domain
import java.util.Date
/**
* Electronic seal per eIDAS Article 36.
*/
data class ElectronicSeal(
val sealId: String,
val documentHash: String,
val sealValue: ByteArray,
val certificate: EIDASCertificate,
val timestamp: Date,
val legalEntityInfo: LegalEntityInfo
)
data class LegalEntityInfo(
val entityName: String,
val registrationNumber: String,
val jurisdiction: String,
val address: String
)

View File

@@ -0,0 +1,29 @@
package com.smoa.core.eidas.domain
import java.util.Date
/**
* Qualified Electronic Signature (QES) per eIDAS Article 3(12).
*/
data class QualifiedSignature(
val signatureId: String,
val documentHash: String,
val signatureValue: ByteArray,
val certificate: EIDASCertificate,
val timestamp: Date,
val timestampToken: TimestampToken?,
val signerInfo: SignerInfo
)
data class TimestampToken(
val tokenValue: String,
val timestamp: Date,
val tsaCertificate: String // Timestamping Authority certificate
)
data class SignerInfo(
val signerId: String,
val signerName: String,
val signerAttributes: Map<String, String>
)

View File

@@ -0,0 +1,62 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
namespace = "com.smoa.core.security"
compileSdk = AppConfig.compileSdk
defaultConfig {
minSdk = AppConfig.minSdk
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
}
dependencies {
implementation(project(":core:common"))
implementation(platform(Dependencies.composeBom))
implementation(Dependencies.composeUi)
implementation(Dependencies.androidxCoreKtx)
implementation(Dependencies.securityCrypto)
implementation(Dependencies.okHttp)
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
implementation(Dependencies.roomRuntime)
implementation(Dependencies.roomKtx)
kapt(Dependencies.roomCompiler)
// SQLite support for SQLCipher
implementation("androidx.sqlite:sqlite:2.4.0")
// Database Encryption
implementation(Dependencies.sqlcipher)
// Testing
testImplementation(Dependencies.junit)
testImplementation(Dependencies.mockk)
testImplementation(Dependencies.coroutinesTest)
testImplementation(Dependencies.truth)
}

View File

@@ -0,0 +1,214 @@
package com.smoa.core.security
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
/**
* Audit event types for security logging.
*/
enum class AuditEventType {
AUTHENTICATION_SUCCESS,
AUTHENTICATION_FAILURE,
AUTHENTICATION_LOCKOUT,
SESSION_START,
SESSION_END,
SESSION_TIMEOUT,
CREDENTIAL_ACCESS,
CREDENTIAL_DISPLAY,
COMMUNICATION_SESSION_START,
COMMUNICATION_SESSION_END,
MEETING_JOIN,
MEETING_JOINED,
MEETING_LEFT,
MEETING_CREATED,
MEETING_HOST,
POLICY_UPDATE,
STEP_UP_AUTH_REQUIRED,
STEP_UP_AUTH_SUCCESS,
STEP_UP_AUTH_FAILURE,
CHANNEL_JOINED,
CHANNEL_LEFT,
PTT_STARTED,
PTT_STOPPED
}
/**
* Audit log entry entity.
*/
@Entity(tableName = "audit_logs")
data class AuditLogEntry(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val timestamp: Date,
val eventType: AuditEventType,
val userId: String?,
val module: String?,
val details: String?,
val ipAddress: String?,
val deviceId: String?
)
@Dao
interface AuditLogDao {
@Query("SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT :limit")
fun getRecentLogs(limit: Int): Flow<List<AuditLogEntry>>
@Insert
suspend fun insertLog(entry: AuditLogEntry)
@Query("SELECT * FROM audit_logs WHERE timestamp >= :since ORDER BY timestamp DESC")
suspend fun getLogsSince(since: Date): List<AuditLogEntry>
@Query("DELETE FROM audit_logs WHERE timestamp < :before")
suspend fun deleteLogsBefore(before: Date)
}
@Database(entities = [AuditLogEntry::class], version = 1, exportSchema = false)
@TypeConverters(DateConverter::class)
abstract class AuditLogDatabase : RoomDatabase() {
abstract fun auditLogDao(): AuditLogDao
}
/**
* Date converter for Room database.
*/
class DateConverter {
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
fun dateToTimestamp(date: Date?): Long? {
return date?.time
}
}
/**
* Audit Logger for security event logging.
*/
@Singleton
class AuditLogger @Inject constructor(
@ApplicationContext private val context: Context,
private val encryptionManager: EncryptionManager
) {
private val database: AuditLogDatabase = Room.databaseBuilder(
context,
AuditLogDatabase::class.java,
"audit_logs"
)
.enableMultiInstanceInvalidation()
.build()
private val dao = database.auditLogDao()
/**
* Log a security-relevant event.
*/
suspend fun logEvent(
eventType: AuditEventType,
userId: String? = null,
module: String? = null,
details: String? = null
) {
logEvent(eventType, emptyMap(), userId, module, details)
}
/**
* Log a security-relevant event with additional details map.
*/
suspend fun logEvent(
eventType: AuditEventType,
detailsMap: Map<String, String>,
userId: String? = null,
module: String? = null,
details: String? = null
) {
val detailsString = if (detailsMap.isNotEmpty()) {
val mapString = detailsMap.entries.joinToString(", ") { "${it.key}=${it.value}" }
if (details != null) "$details | $mapString" else mapString
} else {
details
}
val entry = AuditLogEntry(
timestamp = Date(),
eventType = eventType,
userId = userId,
module = module,
details = detailsString,
ipAddress = null, // Can be populated if network info available
deviceId = android.provider.Settings.Secure.getString(
context.contentResolver,
android.provider.Settings.Secure.ANDROID_ID
)
)
dao.insertLog(entry)
}
/**
* Get recent audit logs.
*/
fun getRecentLogs(limit: Int = 100): Flow<List<AuditLogEntry>> {
return dao.getRecentLogs(limit)
}
/**
* Get logs since a specific date (for sync).
*/
suspend fun getLogsSince(since: Date): List<AuditLogEntry> {
return dao.getLogsSince(since)
}
/**
* Clean up old logs (retention policy).
*/
suspend fun cleanupOldLogs(retentionDays: Int = 90) {
val cutoffDate = Date(System.currentTimeMillis() - (retentionDays * 24 * 60 * 60 * 1000L))
dao.deleteLogsBefore(cutoffDate)
}
/**
* Export logs for transmission (encrypted).
*/
suspend fun exportLogsForSync(since: Date): ByteArray {
val logs = getLogsSince(since)
// Serialize and encrypt logs before transmission
// This is a placeholder - implement proper serialization and encryption
return logs.toString().toByteArray()
}
/**
* Enhance audit trail with immutable record support.
* Creates cryptographically bound records that cannot be modified.
*/
suspend fun createImmutableRecord(entry: AuditLogEntry): AuditLogEntry {
// In production, add cryptographic binding (hash chain, Merkle tree, etc.)
// For now, return as-is - will be enhanced in Phase 1
return entry
}
/**
* Bind timestamp to audit record per eIDAS requirements.
*/
suspend fun bindTimestamp(entry: AuditLogEntry): AuditLogEntry {
// Timestamp binding will be implemented with qualified timestamping service
// Placeholder for Phase 3 eIDAS implementation
return entry
}
}

View File

@@ -0,0 +1,43 @@
package com.smoa.core.security
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CertificatePinningManager @Inject constructor() {
/**
* Create an OkHttpClient with certificate pinning enabled.
* This ensures all network requests verify the server's certificate chain.
*/
fun createPinnedClient(
hostname: String,
pins: List<String>
): OkHttpClient.Builder {
val certificatePinner = CertificatePinner.Builder()
.apply {
pins.forEach { pin ->
add(hostname, pin)
}
}
.build()
return OkHttpClient.Builder()
.certificatePinner(certificatePinner)
}
/**
* Create a default pinned client for enterprise endpoints.
* Pins should be configured per deployment.
*/
fun createEnterpriseClient(): OkHttpClient.Builder {
// Placeholder - actual pins must be configured per deployment
return OkHttpClient.Builder()
.apply {
// Certificate pinning will be configured via policy
}
}
}

View File

@@ -0,0 +1,86 @@
package com.smoa.core.security
import android.content.Context
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
import javax.crypto.SecretKey
import javax.inject.Inject
import javax.inject.Singleton
/**
* Helper for creating encrypted Room databases using SQLCipher.
* Binds encryption keys to user authentication state.
*/
@Singleton
class EncryptedDatabaseHelper @Inject constructor(
private val encryptionManager: EncryptionManager,
private val keyManager: KeyManager
) {
companion object {
private const val KEY_ALIAS_PREFIX = "db_encryption_key_"
}
/**
* Get or create encryption key for a database.
* Keys are bound to device and user authentication state.
*/
fun getDatabaseKey(alias: String): ByteArray {
// Get key from secure storage or generate new one
val keyString = keyManager.getSecureString("$KEY_ALIAS_PREFIX$alias")
return if (keyString != null) {
// Key exists, decode from base64
android.util.Base64.decode(keyString, android.util.Base64.DEFAULT)
} else {
// Generate new key
val key = encryptionManager.getOrCreateEncryptionKey(alias)
val keyBytes = key.encoded
// Store key in secure storage (base64 encoded)
val encodedKey = android.util.Base64.encodeToString(keyBytes, android.util.Base64.DEFAULT)
keyManager.putSecureString("$KEY_ALIAS_PREFIX$alias", encodedKey)
keyBytes
}
}
/**
* Create SQLCipher open helper factory for Room database.
*/
fun createOpenHelperFactory(databaseName: String): SupportSQLiteOpenHelper.Factory {
val key = getDatabaseKey(databaseName)
val passphrase = SupportOpenHelperFactory(key)
return passphrase
}
/**
* Get database passphrase as String (SQLCipher requires String).
*/
fun getDatabasePassphrase(databaseName: String): String {
val key = getDatabaseKey(databaseName)
// Convert key bytes to String (SQLCipher requirement)
// In production, consider using a more secure conversion
return android.util.Base64.encodeToString(key, android.util.Base64.NO_WRAP)
}
/**
* Rotate database encryption key.
* This should be called periodically or on security events.
*/
fun rotateDatabaseKey(databaseName: String): Result<Unit> {
return try {
// Remove old key
keyManager.removeSecureString("$KEY_ALIAS_PREFIX$databaseName")
// Generate new key
getDatabaseKey(databaseName)
kotlin.Result.success(Unit)
} catch (e: Exception) {
kotlin.Result.failure(e)
}
}
}

View File

@@ -0,0 +1,99 @@
package com.smoa.core.security
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import androidx.security.crypto.EncryptedFile
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.security.KeyStore
import javax.crypto.KeyGenerator
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class EncryptionManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply {
load(null)
}
private val masterKey: MasterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
/**
* Get or create a hardware-backed encryption key for data at rest.
* Keys are non-exportable and bound to the device.
*/
fun getOrCreateEncryptionKey(alias: String): javax.crypto.SecretKey {
if (!keyStore.containsAlias(alias)) {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setUserAuthenticationRequired(false) // Can be enabled for additional security
.build()
keyGenerator.init(keyGenParameterSpec)
keyGenerator.generateKey()
}
return keyStore.getKey(alias, null) as javax.crypto.SecretKey
}
/**
* Create an encrypted file for storing sensitive data.
*/
fun createEncryptedFile(fileName: String): EncryptedFile {
val file = File(context.filesDir, fileName)
return EncryptedFile.Builder(
context,
file,
masterKey,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()
}
/**
* Check if hardware-backed keystore is available.
*/
fun isHardwareBacked(): Boolean {
return try {
val key = getOrCreateEncryptionKey("test_key_check")
val factory = KeyStore.getInstance("AndroidKeyStore")
factory.load(null)
val entry = factory.getEntry("test_key_check", null) as? KeyStore.SecretKeyEntry
entry?.let {
val secretKey = it.secretKey
// Note: AndroidKeyStoreSecretKey is not directly accessible in all API levels
// This is a simplified check - in production, use KeyInfo for detailed key characteristics
try {
// Attempt to get key characteristics (API 23+)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
factory.getKey("test_key_check", null)?.let {
// Key exists and is accessible - assume hardware-backed for AndroidKeyStore
true
} ?: false
} else {
false
}
} catch (e: Exception) {
false
}
} ?: false
} catch (e: Exception) {
false
}
}
}

View File

@@ -0,0 +1,56 @@
package com.smoa.core.security
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class KeyManager @Inject constructor(
@ApplicationContext private val context: Context,
private val encryptionManager: EncryptionManager
) {
private val masterKey: MasterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val encryptedPrefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
/**
* Store a secure key/value pair. Keys are bound to device and user auth state.
*/
fun putSecureString(key: String, value: String) {
encryptedPrefs.edit().putString(key, value).apply()
}
/**
* Retrieve a secure key/value pair.
*/
fun getSecureString(key: String, defaultValue: String? = null): String? {
return encryptedPrefs.getString(key, defaultValue)
}
/**
* Remove a secure key/value pair.
*/
fun removeSecureString(key: String) {
encryptedPrefs.edit().remove(key).apply()
}
/**
* Clear all secure preferences.
*/
fun clearAll() {
encryptedPrefs.edit().clear().apply()
}
}

View File

@@ -0,0 +1,78 @@
package com.smoa.core.security
import android.app.Activity
import android.content.Context
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.view.WindowManager
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import javax.inject.Inject
import javax.inject.Singleton
/**
* Screen protection utility to prevent screenshots and screen recording.
* Implements FLAG_SECURE and media projection detection.
*/
@Singleton
class ScreenProtection @Inject constructor(
private val context: Context
) {
private val mediaProjectionManager: MediaProjectionManager? by lazy {
context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as? MediaProjectionManager
}
/**
* Enable screen protection for an activity.
* This prevents screenshots and screen recording (where supported by OS).
*/
fun enableScreenProtection(activity: Activity) {
activity.window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
// Additional protection for Android 11+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
WindowCompat.setDecorFitsSystemWindows(activity.window, false)
}
}
/**
* Disable screen protection for an activity.
* Use with caution - only disable when absolutely necessary.
*/
fun disableScreenProtection(activity: Activity) {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
/**
* Check if media projection (screen recording) is active.
* Note: This is a best-effort check and may not detect all cases.
*/
fun isScreenRecordingActive(): Boolean {
return try {
// Check if media projection service is available and active
// This is a simplified check - full implementation would require
// monitoring media projection callbacks
mediaProjectionManager != null
} catch (e: Exception) {
false
}
}
/**
* Composable helper to enable screen protection for Compose screens.
*/
@Composable
fun EnableScreenProtection() {
val view = LocalView.current
val activity = view.context as? Activity
activity?.let {
enableScreenProtection(it)
}
}
}

View File

@@ -0,0 +1,65 @@
package com.smoa.core.security
import java.util.Date
import javax.inject.Inject
import javax.inject.Singleton
/**
* Advanced threat detection system.
*/
@Singleton
class ThreatDetection @Inject constructor(
private val auditLogger: AuditLogger
) {
/**
* Detect anomalies in user behavior.
*/
suspend fun detectAnomalies(userId: String, activity: UserActivity): Result<ThreatAssessment> {
// TODO: Implement machine learning-based anomaly detection
return Result.success(ThreatAssessment.NORMAL)
}
/**
* Analyze security events for threats.
*/
suspend fun analyzeSecurityEvents(events: List<SecurityEvent>): Result<ThreatReport> {
// TODO: Implement threat analysis
return Result.success(ThreatReport(emptyList(), ThreatLevel.LOW))
}
}
data class UserActivity(
val userId: String,
val timestamp: Date,
val action: String,
val resource: String?,
val location: String?
)
data class SecurityEvent(
val eventId: String,
val timestamp: Date,
val type: String,
val severity: Int
)
enum class ThreatAssessment {
NORMAL,
SUSPICIOUS,
HIGH_RISK,
CRITICAL
}
data class ThreatReport(
val threats: List<String>,
val overallLevel: ThreatLevel
)
enum class ThreatLevel {
LOW,
MEDIUM,
HIGH,
CRITICAL
}

View File

@@ -0,0 +1,138 @@
package com.smoa.core.security
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.VpnService
import android.os.Build
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* VPN Manager for monitoring and enforcing VPN connections.
* Required for browser module and other sensitive operations.
*/
@Singleton
class VPNManager @Inject constructor(
private val context: Context
) {
private val connectivityManager: ConnectivityManager by lazy {
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
private val _vpnState = MutableStateFlow<VPNState>(VPNState.Unknown)
val vpnState: StateFlow<VPNState> = _vpnState.asStateFlow()
/**
* Check if VPN is currently connected.
*/
fun isVPNConnected(): Boolean {
return try {
val activeNetwork = connectivityManager.activeNetwork
val capabilities = activeNetwork?.let {
connectivityManager.getNetworkCapabilities(it)
}
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
} catch (e: Exception) {
false
}
}
/**
* Check if VPN is required for the current operation.
*/
fun isVPNRequired(): Boolean {
// VPN is required for browser module and other sensitive operations
// This can be made configurable via policy
return true
}
/**
* Request VPN permission from user.
* Returns true if permission is granted or already available.
*/
suspend fun requestVPNPermission(activity: android.app.Activity): Boolean {
return try {
val intent = VpnService.prepare(context)
if (intent != null) {
// VPN permission not granted - need to request
_vpnState.value = VPNState.PermissionRequired
false
} else {
// VPN permission already granted
_vpnState.value = VPNState.PermissionGranted
true
}
} catch (e: Exception) {
_vpnState.value = VPNState.Error
false
}
}
/**
* Monitor VPN connection state.
*/
fun startVPNMonitoring() {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
updateVPNState()
}
override fun onLost(network: Network) {
updateVPNState()
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
updateVPNState()
}
}
connectivityManager.registerDefaultNetworkCallback(callback)
updateVPNState()
}
/**
* Update VPN state based on current connection.
*/
private fun updateVPNState() {
_vpnState.value = when {
isVPNConnected() -> VPNState.Connected
else -> VPNState.Disconnected
}
}
/**
* Enforce VPN requirement - throws exception if VPN not connected.
*/
fun enforceVPNRequirement() {
if (isVPNRequired() && !isVPNConnected()) {
throw VPNRequiredException("VPN connection required for this operation")
}
}
}
/**
* VPN connection states.
*/
enum class VPNState {
Unknown,
Connected,
Disconnected,
PermissionRequired,
PermissionGranted,
Error
}
/**
* Exception thrown when VPN is required but not connected.
*/
class VPNRequiredException(message: String) : SecurityException(message)

View File

@@ -0,0 +1,49 @@
package com.smoa.core.security
import javax.inject.Inject
import javax.inject.Singleton
/**
* Zero-trust architecture framework.
* Implements "never trust, always verify" principle.
*/
@Singleton
class ZeroTrustFramework @Inject constructor(
private val auditLogger: AuditLogger
) {
/**
* Verify trust for resource access request.
*/
suspend fun verifyTrust(
userId: String,
resource: String,
action: String
): Result<TrustVerification> {
// Zero-trust: verify every access attempt
// TODO: Implement comprehensive trust verification
return Result.success(TrustVerification(trusted = true, verificationLevel = VerificationLevel.MULTI_FACTOR))
}
/**
* Check if continuous verification is required.
*/
suspend fun requiresContinuousVerification(userId: String, sessionId: String): Boolean {
// Zero-trust: continuous verification for sensitive operations
return true
}
}
data class TrustVerification(
val trusted: Boolean,
val verificationLevel: VerificationLevel,
val reason: String? = null
)
enum class VerificationLevel {
SINGLE_FACTOR,
MULTI_FACTOR,
MULTI_FACTOR_BIOMETRIC,
HARDWARE_BACKED
}

View File

@@ -0,0 +1,61 @@
package com.smoa.core.security.di
import android.content.Context
import com.smoa.core.security.EncryptedDatabaseHelper
import com.smoa.core.security.EncryptionManager
import com.smoa.core.security.KeyManager
import com.smoa.core.security.ScreenProtection
import com.smoa.core.security.VPNManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SecurityModule {
@Provides
@Singleton
fun provideEncryptionManager(
@ApplicationContext context: Context
): EncryptionManager {
return EncryptionManager(context)
}
@Provides
@Singleton
fun provideKeyManager(
@ApplicationContext context: Context,
encryptionManager: EncryptionManager
): KeyManager {
return KeyManager(context, encryptionManager)
}
@Provides
@Singleton
fun provideEncryptedDatabaseHelper(
encryptionManager: EncryptionManager,
keyManager: KeyManager
): EncryptedDatabaseHelper {
return EncryptedDatabaseHelper(encryptionManager, keyManager)
}
@Provides
@Singleton
fun provideScreenProtection(
@ApplicationContext context: Context
): ScreenProtection {
return ScreenProtection(context)
}
@Provides
@Singleton
fun provideVPNManager(
@ApplicationContext context: Context
): VPNManager {
return VPNManager(context)
}
}

View File

@@ -0,0 +1,56 @@
package com.smoa.core.security
import android.content.Context
import io.mockk.mockk
import org.junit.Assert.*
import org.junit.Test
/**
* Unit tests for EncryptionManager.
*/
class EncryptionManagerTest {
private val context = mockk<Context>(relaxed = true)
private val encryptionManager = EncryptionManager(context)
@Test
fun `getOrCreateEncryptionKey should create key if not exists`() {
// Given
val alias = "test_key"
// When
val key = encryptionManager.getOrCreateEncryptionKey(alias)
// Then
assertNotNull(key)
assertEquals("AES", key.algorithm)
}
@Test
fun `getOrCreateEncryptionKey should return same key for same alias`() {
// Given
val alias = "test_key"
// When
val key1 = encryptionManager.getOrCreateEncryptionKey(alias)
val key2 = encryptionManager.getOrCreateEncryptionKey(alias)
// Then
assertNotNull(key1)
assertNotNull(key2)
// Keys should be the same for the same alias
assertArrayEquals(key1.encoded, key2.encoded)
}
@Test
fun `createEncryptedFile should create encrypted file`() {
// Given
val fileName = "test_file.txt"
// When
val encryptedFile = encryptionManager.createEncryptedFile(fileName)
// Then
assertNotNull(encryptedFile)
}
}

View File

@@ -0,0 +1,90 @@
package com.smoa.core.security
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.*
import org.junit.Test
/**
* Unit tests for VPNManager.
*/
class VPNManagerTest {
private val context = mockk<Context>(relaxed = true)
private val connectivityManager = mockk<ConnectivityManager>(relaxed = true)
init {
every { context.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager
}
@Test
fun `isVPNConnected should return true when VPN transport is active`() {
// Given
val vpnManager = VPNManager(context)
val capabilities = mockk<NetworkCapabilities>(relaxed = true)
val network = mockk<android.net.Network>(relaxed = true)
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns true
// When
val result = vpnManager.isVPNConnected()
// Then
assertTrue(result)
}
@Test
fun `isVPNConnected should return false when VPN transport is not active`() {
// Given
val vpnManager = VPNManager(context)
val capabilities = mockk<NetworkCapabilities>(relaxed = true)
val network = mockk<android.net.Network>(relaxed = true)
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false
// When
val result = vpnManager.isVPNConnected()
// Then
assertFalse(result)
}
@Test
fun `isVPNRequired should return true by default`() {
// Given
val vpnManager = VPNManager(context)
// When
val result = vpnManager.isVPNRequired()
// Then
assertTrue(result)
}
@Test
fun `enforceVPNRequirement should throw exception when VPN not connected`() {
// Given
val vpnManager = VPNManager(context)
val capabilities = mockk<NetworkCapabilities>(relaxed = true)
val network = mockk<android.net.Network>(relaxed = true)
every { connectivityManager.activeNetwork } returns network
every { connectivityManager.getNetworkCapabilities(network) } returns capabilities
every { capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) } returns false
// When/Then
try {
vpnManager.enforceVPNRequirement()
fail("Should have thrown VPNRequiredException")
} catch (e: VPNRequiredException) {
assertTrue(e.message?.contains("VPN connection required") == true)
}
}
}

View File

@@ -0,0 +1,43 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
android {
namespace = "com.smoa.core.signing"
compileSdk = AppConfig.compileSdk
defaultConfig {
minSdk = AppConfig.minSdk
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(project(":core:common"))
implementation(project(":core:security"))
implementation(project(":core:certificates"))
implementation(Dependencies.androidxCoreKtx)
// Cryptography
implementation(Dependencies.bouncycastle)
implementation(Dependencies.bouncycastlePkix)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
}

View File

@@ -0,0 +1,21 @@
package com.smoa.core.signing
import com.smoa.core.signing.domain.DigitalSignatureService
import com.smoa.core.signing.domain.ElectronicSealService
import java.security.PrivateKey
import java.security.cert.X509Certificate
import javax.inject.Inject
/**
* Signing Service - Main entry point for digital signatures and seals.
*/
class SigningService @Inject constructor(
private val signatureService: DigitalSignatureService,
private val sealService: ElectronicSealService
) {
suspend fun signData(data: ByteArray, privateKey: PrivateKey, certificate: X509Certificate) =
signatureService.signData(data, privateKey, certificate)
suspend fun createSeal(data: ByteArray, certificate: X509Certificate, legalEntityInfo: com.smoa.core.signing.domain.LegalEntityInfo) =
sealService.createSeal(data, certificate, legalEntityInfo)
}

View File

@@ -0,0 +1,61 @@
package com.smoa.core.signing.domain
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.Date
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Digital signature service for X.509 certificate-based signing.
*/
@Singleton
class DigitalSignatureService @Inject constructor() {
/**
* Sign data with X.509 certificate.
*/
suspend fun signData(
data: ByteArray,
privateKey: PrivateKey,
certificate: X509Certificate
): Result<DigitalSignature> {
return try {
// TODO: Actual signature generation using BouncyCastle or similar
val signature = DigitalSignature(
signatureId = UUID.randomUUID().toString(),
data = data,
signatureValue = ByteArray(256), // Placeholder
certificate = certificate,
algorithm = "SHA256withRSA",
timestamp = Date()
)
Result.success(signature)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Verify digital signature.
*/
suspend fun verifySignature(
data: ByteArray,
signature: DigitalSignature
): Result<Boolean> {
// TODO: Actual signature verification
return Result.success(true) // Placeholder
}
}
data class DigitalSignature(
val signatureId: String,
val data: ByteArray,
val signatureValue: ByteArray,
val certificate: X509Certificate,
val algorithm: String,
val timestamp: Date
)

View File

@@ -0,0 +1,65 @@
package com.smoa.core.signing.domain
import java.security.cert.X509Certificate
import java.util.Date
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/**
* Electronic seal service for legal entities.
*/
@Singleton
class ElectronicSealService @Inject constructor() {
/**
* Create electronic seal for legal entity.
*/
suspend fun createSeal(
data: ByteArray,
certificate: X509Certificate,
legalEntityInfo: LegalEntityInfo
): Result<ElectronicSeal> {
return try {
val seal = ElectronicSeal(
sealId = UUID.randomUUID().toString(),
data = data,
sealValue = ByteArray(256), // Placeholder - actual seal generation
certificate = certificate,
algorithm = "SHA256withRSA",
timestamp = Date(),
legalEntityInfo = legalEntityInfo
)
Result.success(seal)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Verify electronic seal.
*/
suspend fun verifySeal(seal: ElectronicSeal): Result<Boolean> {
// TODO: Actual seal verification
return Result.success(true) // Placeholder
}
}
data class ElectronicSeal(
val sealId: String,
val data: ByteArray,
val sealValue: ByteArray,
val certificate: X509Certificate,
val algorithm: String,
val timestamp: Date,
val legalEntityInfo: LegalEntityInfo
)
data class LegalEntityInfo(
val entityName: String,
val registrationNumber: String,
val jurisdiction: String,
val address: String
)