From a2dc194a49d43112a16f387bc0c2b2084d95e447 Mon Sep 17 00:00:00 2001 From: defiQUG Date: Mon, 23 Mar 2026 20:19:24 -0700 Subject: [PATCH] Monorepo: Gitea CI, docs, auth/sync, backend APIs, gitignore - Add Gitea Actions workflow; point README to gitea.d-bis.org/Sankofa_Phoenix/SMOA - Expand .gitignore for Spring H2 data, secrets, Kotlin .kotlin/, tooling - Track docs/api/generated ReDoc bundle; refresh api docs README - Android: network/auth/sync, UI shell, tests; backend credentials/integrity APIs - Docs, scripts (generate-api-docs), modules and core updates Made-with: Cursor --- .gitea/workflows/ci.yml | 38 ++ .gitignore | 122 ++-- .vscode/settings.json | 3 +- README.md | 14 +- TASKS.md | 59 ++ TODO.md | 199 +++---- app/build.gradle.kts | 54 +- app/proguard-rules.pro | 30 + app/src/androidTest/AndroidManifest.xml | 8 + .../java/com/smoa/HiltTestRunner.kt | 14 + .../java/com/smoa/SMOAHiltTestApplication.kt | 10 + ...ntialConflictMockServerInstrumentedTest.kt | 74 +++ ...flictSnapshotRepositoryInstrumentedTest.kt | 75 +++ app/src/main/AndroidManifest.xml | 21 + app/src/main/java/com/smoa/MainActivity.kt | 95 +++- app/src/main/java/com/smoa/SMOAApplication.kt | 15 + .../main/java/com/smoa/api/BackendPullAPI.kt | 39 +- .../com/smoa/api/BackendPullApiService.kt | 7 + .../main/java/com/smoa/api/BackendSyncAPI.kt | 132 ++--- .../java/com/smoa/api/SyncRetrofitHolder.kt | 77 +++ .../com/smoa/auth/AuthPreferencesStore.kt | 41 ++ .../com/smoa/auth/BiometricAuthenticator.kt | 133 +++++ .../com/smoa/config/ClientConfigFetcher.kt | 29 + .../config/ClientConfigRefreshCoordinator.kt | 49 ++ .../com/smoa/config/RemoteEndpointStore.kt | 72 +++ .../com/smoa/config/SmoaClientConfigJson.kt | 22 + app/src/main/java/com/smoa/di/AppModule.kt | 43 +- .../main/java/com/smoa/di/NetworkModule.kt | 57 ++ .../com/smoa/di/SmoaRemoteConfigEntryPoint.kt | 12 + .../java/com/smoa/di/SyncRemoteDataModule.kt | 48 ++ .../com/smoa/network/AuthTokenInterceptor.kt | 24 + .../smoa/network/CertificatePinnerFactory.kt | 53 ++ .../com/smoa/network/NetworkPinningConfig.kt | 31 ++ .../smoa/network/TokenRefreshAuthenticator.kt | 99 ++++ .../smoa/security/BiometricSecretsVault.kt | 66 +++ .../com/smoa/security/KnoxEnterpriseProbe.kt | 25 + .../com/smoa/security/OidcAuthStateHolder.kt | 29 + .../java/com/smoa/security/OidcBootstrap.kt | 24 + .../com/smoa/security/OidcLoginCoordinator.kt | 186 +++++++ .../smoa/security/PlayIntegrityVerifier.kt | 49 ++ .../com/smoa/security/SecureTokenStore.kt | 73 +++ .../smoa/security/SecurityStatusViewModel.kt | 66 +++ .../smoa/security/SessionLockController.kt | 76 +++ .../com/smoa/security/SessionLockLogic.kt | 17 + .../smoa/sync/CompositeSyncRemoteDataCache.kt | 34 ++ .../com/smoa/sync/SyncConflictSnapshotDao.kt | 33 ++ .../smoa/sync/SyncConflictSnapshotDatabase.kt | 13 + .../smoa/sync/SyncConflictSnapshotEntity.kt | 14 + .../sync/SyncConflictSnapshotRepository.kt | 39 ++ .../smoa/sync/SyncConflictSnapshotStore.kt | 25 + .../java/com/smoa/ui/auth/AuthFlowHost.kt | 410 ++++++++++++++ .../com/smoa/ui/auth/SessionLockOverlay.kt | 67 +++ .../ui/components/ModulePlaceholderImage.kt | 28 + .../java/com/smoa/ui/components/SmoaChrome.kt | 119 ++++ .../main/java/com/smoa/ui/main/HomeScreen.kt | 185 +++++++ .../main/java/com/smoa/ui/main/MainScreen.kt | 162 ++++-- .../smoa/ui/navigation/NavigationDrawer.kt | 86 ++- .../smoa/ui/navigation/NavigationModule.kt | 65 ++- .../java/com/smoa/ui/navigation/SmoaNav.kt | 22 + app/src/main/java/com/smoa/ui/root/AppRoot.kt | 136 +++++ .../smoa/ui/settings/UserSettingsScreen.kt | 267 +++++++++ .../main/java/com/smoa/ui/theme/SMOATheme.kt | 80 +++ .../java/com/smoa/ui/theme/SmoaSpatial.kt | 38 ++ .../res/drawable/ic_launcher_background.xml | 5 + .../res/drawable/ic_launcher_foreground.xml | 17 + .../res/drawable/ic_module_placeholder.xml | 20 + app/src/main/res/drawable/ic_smo_mark.xml | 16 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 4 +- .../mipmap-anydpi-v26/ic_launcher_round.xml | 4 +- app/src/main/res/values/strings.xml | 82 +++ .../java/com/smoa/api/BackendPullAPITest.kt | 68 +++ .../java/com/smoa/api/BackendSyncAPITest.kt | 98 ++++ .../network/CertificatePinnerFactoryTest.kt | 37 ++ .../com/smoa/security/SessionLockLogicTest.kt | 41 ++ backend/README.md | 17 +- backend/build.gradle.kts | 17 +- backend/docs/BACKEND-GAPS-AND-ROADMAP.md | 17 +- backend/docs/LXC-PROXMOX-CONTAINERS.md | 168 ++++++ .../examples/smoa-client-config.example.json | 14 + .../smoa/backend/api/CredentialController.kt | 41 ++ .../api/IntegrityAttestationController.kt | 39 ++ .../backend/api/dto/CredentialDetailItem.kt | 28 + .../backend/api/dto/IntegrityVerifyRequest.kt | 13 + .../com/smoa/backend/api/dto/SyncRequest.kt | 15 +- .../com/smoa/backend/config/TenantFilter.kt | 2 +- .../com/smoa/backend/config/WebConfig.kt | 2 +- .../smoa/backend/domain/SmoaCredentialType.kt | 44 ++ .../backend/service/CredentialSyncService.kt | 3 + .../smoa/backend/service/ReportSyncService.kt | 30 +- .../src/main/resources/application-prod.yml | 10 +- backend/src/main/resources/application.yml | 6 +- .../api/IntegrityAttestationControllerTest.kt | 48 ++ .../api/SyncControllerIntegrationTest.kt | 62 +++ .../service/DirectorySyncServiceTest.kt | 7 +- .../backend/service/ReportSyncServiceTest.kt | 55 ++ .../src/test/resources/application-test.yml | 17 + build.gradle.kts | 6 + buildSrc/src/main/kotlin/Dependencies.kt | 11 +- core/as4/README.md | 10 + core/as4/build.gradle.kts | 15 +- .../main/java/com/smoa/core/as4/AS4Gateway.kt | 12 +- .../com/smoa/core/as4/domain/AS4Service.kt | 324 ++++++++++- .../smoa/core/as4/domain/AS4ServiceTest.kt | 62 +++ .../smoa/core/as4/domain/TestCertificates.kt | 53 ++ core/auth/build.gradle.kts | 10 +- core/barcode/build.gradle.kts | 11 +- .../core/barcode/formats/AAMVACredential.kt | 11 +- .../barcode/formats/ICAO9303Credential.kt | 7 +- core/certificates/build.gradle.kts | 14 +- .../core/certificates/CertificateManager.kt | 3 + .../certificates/domain/CertificateManager.kt | 59 +- .../domain/CertificateManagerTest.kt | 95 ++++ .../certificates/domain/TestCertificates.kt | 43 ++ core/common/build.gradle.kts | 10 +- .../smoa/core/common/ConnectivityManager.kt | 11 +- .../main/java/com/smoa/core/common/PullAPI.kt | 4 + .../com/smoa/core/common/SmartCardReader.kt | 17 +- .../main/java/com/smoa/core/common/SyncAPI.kt | 10 +- .../java/com/smoa/core/common/SyncService.kt | 130 ++++- .../com/smoa/core/common/di/CommonModule.kt | 11 - .../common/sync/CredentialCacheSyncPort.kt | 34 ++ .../sync/CredentialConflictRemoteJson.kt | 87 +++ .../common/sync/CredentialSyncRequestDto.kt | 19 + .../common/sync/SyncRemoteDataCachePort.kt | 34 ++ .../java/com/smoa/core/common/MockHelpers.kt | 39 +- .../sync/CredentialConflictRemoteJsonTest.kt | 41 ++ core/eidas/build.gradle.kts | 15 +- .../smoa/core/eidas/domain/EIDASService.kt | 75 ++- .../core/eidas/domain/EIDASServiceTest.kt | 130 +++++ .../core/eidas/domain/TestCertificates.kt | 53 ++ core/security/build.gradle.kts | 14 +- .../EncryptionManagerInstrumentedTest.kt | 24 + .../core/security/EncryptedDatabaseHelper.kt | 14 +- .../smoa/core/security/ScreenProtection.kt | 9 +- .../java/com/smoa/core/security/VPNManager.kt | 15 + .../smoa/core/security/di/SecurityModule.kt | 10 - .../core/security/EncryptionManagerTest.kt | 28 +- .../com/smoa/core/security/VPNManagerTest.kt | 10 + core/signing/build.gradle.kts | 14 +- .../com/smoa/core/signing/SigningService.kt | 8 +- .../signing/domain/DigitalSignatureService.kt | 43 +- .../signing/domain/ElectronicSealService.kt | 48 +- .../signing/DigitalSignatureServiceTest.kt | 65 +++ .../core/signing/ElectronicSealServiceTest.kt | 53 ++ .../com/smoa/core/signing/TestCertificates.kt | 47 ++ docs/README.md | 5 +- docs/api/README.md | 10 +- docs/api/api-specification.yaml | 59 +- docs/api/generated/api-specification.yaml | 524 ++++++++++++++++++ docs/api/generated/index.html | 13 + docs/architecture/ARCHITECTURE.md | 14 +- docs/architecture/MENU-AND-ENDPOINTS.md | 134 +++++ .../phases/phase-1-completion-report.md | 4 +- .../phases/phase-2-completion-report.md | 10 +- docs/database/DATABASE_SCHEMA.md | 483 +++++++--------- docs/development/BUILD.md | 28 + docs/development/OPENAPI-SYNCHRONIZATION.md | 22 + docs/development/SECURITY-ENTERPRISE.md | 41 ++ docs/infrastructure/TURN-SIGNALING.md | 29 + docs/ios/README.md | 14 +- docs/ios/SAMPLES.md | 47 ++ docs/reference/GAPS-AND-INCONSISTENCIES.md | 36 ++ docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md | 126 +++++ docs/reference/KNOX-INTEGRATION.md | 16 + docs/schemas/credential-authority.schema.json | 129 +++++ .../credential-payload-aamva_dlid.schema.json | 87 +++ ...redential-payload-agency_badge.schema.json | 27 + .../credential-payload-document.schema.json | 16 + ...edential-payload-icao9303_mrtd.schema.json | 76 +++ ...credential-payload-mil_std_129.schema.json | 77 +++ .../credential-payload-mobile.schema.json | 25 + .../credential-payload-nfc_access.schema.json | 27 + .../credential-payload-payment.schema.json | 25 + .../credential-payload-piv_pki.schema.json | 26 + docs/security/TENANT-THREAT-MODEL.md | 22 + docs/status/IMPLEMENTATION_STATUS.md | 2 +- docs/testing/E2E-PLAN.md | 11 + docs/web-scaffold/DEPLOY.md | 35 ++ docs/web-scaffold/index.html | 6 + docs/web-scaffold/manifest.webmanifest | 10 + docs/web-scaffold/offline-queue.js | 48 ++ docs/web-scaffold/sw.js | 27 + gradle.properties | 9 + modules/atf/build.gradle.kts | 11 +- .../com/smoa/modules/atf/data/ATFFormDao.kt | 21 + .../smoa/modules/atf/data/ATFFormDatabase.kt | 15 +- .../modules/atf/data/ATFFormDraftEntity.kt | 16 + .../com/smoa/modules/atf/domain/ATFService.kt | 2 +- modules/browser/build.gradle.kts | 11 +- .../modules/browser/domain/BrowserService.kt | 11 +- .../smoa/modules/browser/domain/URLFilter.kt | 8 +- .../smoa/modules/browser/ui/BrowserScreen.kt | 169 ++++-- .../browser/domain/BrowserServiceTest.kt | 8 +- modules/communications/build.gradle.kts | 13 +- .../communications/di/CommunicationsModule.kt | 6 +- .../communications/domain/ChannelManager.kt | 27 +- ...etworkEstimatesConnectionQualityMonitor.kt | 79 +++ .../communications/domain/WebRTCManager.kt | 56 +- modules/credentials/build.gradle.kts | 21 +- .../modules/credentials/CredentialsModule.kt | 337 ++++++++++- .../credentials/data/CredentialCacheDao.kt | 35 ++ .../data/CredentialCacheDatabase.kt | 13 + .../data/CredentialCacheDatabaseModule.kt | 36 ++ .../credentials/data/CredentialCacheEntity.kt | 19 + .../credentials/data/CredentialCacheMerger.kt | 123 ++++ .../data/CredentialCachePortModule.kt | 16 + .../data/CredentialPullDetailDto.kt | 15 + .../data/CredentialPullListItemDto.kt | 25 + .../domain/SmoaCredentialTemplateIds.kt | 39 ++ .../credentials/ui/CredentialsViewModel.kt | 86 +++ .../data/CredentialCacheMergerTest.kt | 150 +++++ modules/directory/build.gradle.kts | 11 +- .../directory/data/DirectoryDatabaseModule.kt | 1 + .../data/DirectoryDemoSeedCallback.kt | 30 + .../directory/ui/DirectoryListScreen.kt | 13 +- modules/evidence/build.gradle.kts | 11 +- modules/intelligence/build.gradle.kts | 11 +- modules/judicial/build.gradle.kts | 11 +- modules/meetings/build.gradle.kts | 13 +- .../meetings/domain/MeetingRoomManager.kt | 14 + .../modules/meetings/domain/VideoTransport.kt | 10 +- modules/military/build.gradle.kts | 11 +- modules/ncic/build.gradle.kts | 15 +- .../smoa/modules/ncic/data/NCICQueryDao.kt | 18 + .../modules/ncic/data/NCICQueryDatabase.kt | 24 +- .../modules/ncic/data/NCICQueryLogEntity.kt | 21 + .../smoa/modules/ncic/domain/NCICService.kt | 3 +- modules/orders/build.gradle.kts | 11 +- modules/reports/build.gradle.kts | 11 +- scripts/build-all.sh | 6 + scripts/export-openapi-local.sh | 8 + scripts/generate-api-docs.sh | 26 + scripts/wsl-install-debug.sh | 53 ++ settings.gradle.kts | 9 + 234 files changed, 10008 insertions(+), 1149 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 TASKS.md create mode 100644 app/src/androidTest/AndroidManifest.xml create mode 100644 app/src/androidTest/java/com/smoa/HiltTestRunner.kt create mode 100644 app/src/androidTest/java/com/smoa/SMOAHiltTestApplication.kt create mode 100644 app/src/androidTest/java/com/smoa/api/BackendSyncCredentialConflictMockServerInstrumentedTest.kt create mode 100644 app/src/androidTest/java/com/smoa/sync/SyncConflictSnapshotRepositoryInstrumentedTest.kt create mode 100644 app/src/main/java/com/smoa/api/SyncRetrofitHolder.kt create mode 100644 app/src/main/java/com/smoa/auth/AuthPreferencesStore.kt create mode 100644 app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt create mode 100644 app/src/main/java/com/smoa/config/ClientConfigFetcher.kt create mode 100644 app/src/main/java/com/smoa/config/ClientConfigRefreshCoordinator.kt create mode 100644 app/src/main/java/com/smoa/config/RemoteEndpointStore.kt create mode 100644 app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt create mode 100644 app/src/main/java/com/smoa/di/NetworkModule.kt create mode 100644 app/src/main/java/com/smoa/di/SmoaRemoteConfigEntryPoint.kt create mode 100644 app/src/main/java/com/smoa/di/SyncRemoteDataModule.kt create mode 100644 app/src/main/java/com/smoa/network/AuthTokenInterceptor.kt create mode 100644 app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt create mode 100644 app/src/main/java/com/smoa/network/NetworkPinningConfig.kt create mode 100644 app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt create mode 100644 app/src/main/java/com/smoa/security/BiometricSecretsVault.kt create mode 100644 app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt create mode 100644 app/src/main/java/com/smoa/security/OidcAuthStateHolder.kt create mode 100644 app/src/main/java/com/smoa/security/OidcBootstrap.kt create mode 100644 app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt create mode 100644 app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt create mode 100644 app/src/main/java/com/smoa/security/SecureTokenStore.kt create mode 100644 app/src/main/java/com/smoa/security/SecurityStatusViewModel.kt create mode 100644 app/src/main/java/com/smoa/security/SessionLockController.kt create mode 100644 app/src/main/java/com/smoa/security/SessionLockLogic.kt create mode 100644 app/src/main/java/com/smoa/sync/CompositeSyncRemoteDataCache.kt create mode 100644 app/src/main/java/com/smoa/sync/SyncConflictSnapshotDao.kt create mode 100644 app/src/main/java/com/smoa/sync/SyncConflictSnapshotDatabase.kt create mode 100644 app/src/main/java/com/smoa/sync/SyncConflictSnapshotEntity.kt create mode 100644 app/src/main/java/com/smoa/sync/SyncConflictSnapshotRepository.kt create mode 100644 app/src/main/java/com/smoa/sync/SyncConflictSnapshotStore.kt create mode 100644 app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt create mode 100644 app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt create mode 100644 app/src/main/java/com/smoa/ui/components/ModulePlaceholderImage.kt create mode 100644 app/src/main/java/com/smoa/ui/components/SmoaChrome.kt create mode 100644 app/src/main/java/com/smoa/ui/main/HomeScreen.kt create mode 100644 app/src/main/java/com/smoa/ui/navigation/SmoaNav.kt create mode 100644 app/src/main/java/com/smoa/ui/root/AppRoot.kt create mode 100644 app/src/main/java/com/smoa/ui/settings/UserSettingsScreen.kt create mode 100644 app/src/main/java/com/smoa/ui/theme/SMOATheme.kt create mode 100644 app/src/main/java/com/smoa/ui/theme/SmoaSpatial.kt create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_module_placeholder.xml create mode 100644 app/src/main/res/drawable/ic_smo_mark.xml create mode 100644 app/src/test/java/com/smoa/api/BackendPullAPITest.kt create mode 100644 app/src/test/java/com/smoa/api/BackendSyncAPITest.kt create mode 100644 app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt create mode 100644 app/src/test/java/com/smoa/security/SessionLockLogicTest.kt create mode 100644 backend/docs/LXC-PROXMOX-CONTAINERS.md create mode 100644 backend/docs/examples/smoa-client-config.example.json create mode 100644 backend/src/main/kotlin/com/smoa/backend/api/CredentialController.kt create mode 100644 backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt create mode 100644 backend/src/main/kotlin/com/smoa/backend/api/dto/CredentialDetailItem.kt create mode 100644 backend/src/main/kotlin/com/smoa/backend/api/dto/IntegrityVerifyRequest.kt create mode 100644 backend/src/main/kotlin/com/smoa/backend/domain/SmoaCredentialType.kt create mode 100644 backend/src/test/kotlin/com/smoa/backend/api/IntegrityAttestationControllerTest.kt create mode 100644 backend/src/test/kotlin/com/smoa/backend/service/ReportSyncServiceTest.kt create mode 100644 backend/src/test/resources/application-test.yml create mode 100644 core/as4/README.md create mode 100644 core/as4/src/test/java/com/smoa/core/as4/domain/AS4ServiceTest.kt create mode 100644 core/as4/src/test/java/com/smoa/core/as4/domain/TestCertificates.kt create mode 100644 core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt create mode 100644 core/certificates/src/test/java/com/smoa/core/certificates/domain/TestCertificates.kt create mode 100644 core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt create mode 100644 core/common/src/main/java/com/smoa/core/common/sync/CredentialConflictRemoteJson.kt create mode 100644 core/common/src/main/java/com/smoa/core/common/sync/CredentialSyncRequestDto.kt create mode 100644 core/common/src/main/java/com/smoa/core/common/sync/SyncRemoteDataCachePort.kt create mode 100644 core/common/src/test/java/com/smoa/core/common/sync/CredentialConflictRemoteJsonTest.kt create mode 100644 core/eidas/src/test/java/com/smoa/core/eidas/domain/EIDASServiceTest.kt create mode 100644 core/eidas/src/test/java/com/smoa/core/eidas/domain/TestCertificates.kt create mode 100644 core/security/src/androidTest/java/com/smoa/core/security/EncryptionManagerInstrumentedTest.kt create mode 100644 core/signing/src/test/java/com/smoa/core/signing/DigitalSignatureServiceTest.kt create mode 100644 core/signing/src/test/java/com/smoa/core/signing/ElectronicSealServiceTest.kt create mode 100644 core/signing/src/test/java/com/smoa/core/signing/TestCertificates.kt create mode 100644 docs/api/generated/api-specification.yaml create mode 100644 docs/api/generated/index.html create mode 100644 docs/architecture/MENU-AND-ENDPOINTS.md create mode 100644 docs/development/BUILD.md create mode 100644 docs/development/OPENAPI-SYNCHRONIZATION.md create mode 100644 docs/development/SECURITY-ENTERPRISE.md create mode 100644 docs/infrastructure/TURN-SIGNALING.md create mode 100644 docs/ios/SAMPLES.md create mode 100644 docs/reference/GAPS-AND-INCONSISTENCIES.md create mode 100644 docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md create mode 100644 docs/reference/KNOX-INTEGRATION.md create mode 100644 docs/schemas/credential-authority.schema.json create mode 100644 docs/schemas/credential-payload-aamva_dlid.schema.json create mode 100644 docs/schemas/credential-payload-agency_badge.schema.json create mode 100644 docs/schemas/credential-payload-document.schema.json create mode 100644 docs/schemas/credential-payload-icao9303_mrtd.schema.json create mode 100644 docs/schemas/credential-payload-mil_std_129.schema.json create mode 100644 docs/schemas/credential-payload-mobile.schema.json create mode 100644 docs/schemas/credential-payload-nfc_access.schema.json create mode 100644 docs/schemas/credential-payload-payment.schema.json create mode 100644 docs/schemas/credential-payload-piv_pki.schema.json create mode 100644 docs/security/TENANT-THREAT-MODEL.md create mode 100644 docs/testing/E2E-PLAN.md create mode 100644 docs/web-scaffold/DEPLOY.md create mode 100644 docs/web-scaffold/manifest.webmanifest create mode 100644 docs/web-scaffold/offline-queue.js create mode 100644 docs/web-scaffold/sw.js create mode 100644 modules/atf/src/main/java/com/smoa/modules/atf/data/ATFFormDao.kt create mode 100644 modules/atf/src/main/java/com/smoa/modules/atf/data/ATFFormDraftEntity.kt create mode 100644 modules/communications/src/main/java/com/smoa/modules/communications/domain/NetworkEstimatesConnectionQualityMonitor.kt create mode 100644 modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheDao.kt create mode 100644 modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheDatabase.kt create mode 100644 modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheDatabaseModule.kt create mode 100644 modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheEntity.kt create mode 100644 modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheMerger.kt create mode 100644 modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCachePortModule.kt create mode 100644 modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialPullDetailDto.kt create mode 100644 modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialPullListItemDto.kt create mode 100644 modules/credentials/src/main/java/com/smoa/modules/credentials/domain/SmoaCredentialTemplateIds.kt create mode 100644 modules/credentials/src/main/java/com/smoa/modules/credentials/ui/CredentialsViewModel.kt create mode 100644 modules/credentials/src/test/java/com/smoa/modules/credentials/data/CredentialCacheMergerTest.kt create mode 100644 modules/directory/src/main/java/com/smoa/modules/directory/data/DirectoryDemoSeedCallback.kt create mode 100644 modules/ncic/src/main/java/com/smoa/modules/ncic/data/NCICQueryDao.kt create mode 100644 modules/ncic/src/main/java/com/smoa/modules/ncic/data/NCICQueryLogEntity.kt create mode 100755 scripts/build-all.sh create mode 100755 scripts/export-openapi-local.sh create mode 100755 scripts/generate-api-docs.sh create mode 100755 scripts/wsl-install-debug.sh diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..78608b7 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,38 @@ +# Gitea Actions — runner must advertise label `ubuntu-latest` (act_runner). +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android Platform 34 + run: | + sdkmanager "platforms;android-34" "build-tools;34.0.0" + + - name: Accept Android licenses + run: yes | sdkmanager --licenses >/dev/null + + - name: Run smoaVerify + run: ./gradlew smoaVerify --no-daemon + + - name: Generate API docs (static ReDoc) + run: bash scripts/generate-api-docs.sh diff --git a/.gitignore b/.gitignore index 76a092e..aefda6c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,87 +1,101 @@ -# Built application files +# ============================================================================= +# SMOA monorepo — Android app + Gradle modules + Spring Boot backend +# ============================================================================= + +# --- Android pack / bytecode --- *.apk +*.aab *.aar *.ap_ -*.aab - -# Files for the ART/Dalvik VM *.dex -# Java class files +# --- JVM --- *.class +*.hprof +hs_err_pid* +replay_pid* -# Generated files +# --- Build outputs --- bin/ gen/ out/ release/ - -# Gradle files -.gradle/ build/ +.gradle/ -# Local configuration file (sdk path, etc) +# Kotlin incremental / metadata (Gradle Kotlin plugin; root + subprojects) +.kotlin/ + +# --- Local machine / SDK (never commit) --- local.properties +# Optional per-developer overrides (JAVA_HOME path, etc.) +gradle.properties.local -# Proguard folder generated by Eclipse -proguard/ +# --- Spring Boot: file-based H2 when running from backend/ --- +backend/data/ -# Log Files -*.log - -# Android Studio Navigation editor temp files -.navigation/ - -# Android Studio captures folder -captures/ - -# IntelliJ -*.iml -.idea/workspace.xml -.idea/tasks.xml -.idea/gradle.xml -.idea/assetWizardSettings.xml -.idea/dictionaries -.idea/libraries -.idea/caches -.idea/modules.xml - -# Keystore files +# --- Secrets & environment (Docker Compose, local runs) --- +.env +.env.* +!.env.example +keystore.properties *.jks *.keystore -# External native build folder generated in Android Studio 2.2 and later -.externalNativeBuild -.cxx/ +# Local Spring profiles (often contain secrets or machine-specific URLs) +application-local.yml +application-local.properties +application-secrets.yml +application-secret.yml -# Google Services (e.g. APIs or Firebase) +# Local Compose overrides (ports, bind mounts, secrets) +docker-compose.override.yml + +# --- Google Services / Firebase (use a template in-repo if the team shares a non-secret stub) --- google-services.json -# Freeline -freeline.py -freeline/ -freeline_project_description.json +# --- Native / CMake --- +.externalNativeBuild/ +.cxx/ -# fastlane -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots -fastlane/test_output -fastlane/readme.md +# --- Logs & temp --- +*.log +*.tmp +*.temp +*~ +*.swp +*.swo +*.orig -# Version control -vcs.xml +# --- Android Studio / legacy --- +.navigation/ +captures/ +proguard/ -# lint +# --- IntelliJ / Android Studio --- +*.iml +.idea/ + +# --- Lint / tooling caches (AGP outputs live under build/; these cover edge cases) --- lint/intermediates/ lint/generated/ lint/outputs/ lint/tmp/ -# lint/reports/ -# Android Profiling -*.hprof +# --- fastlane --- +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/ +fastlane/test_output/ +fastlane/readme.md -# macOS +# --- OS --- .DS_Store +Thumbs.db +Desktop.ini +# --- Local tooling archives --- +commandlinetools-linux-*_latest.zip + +# --- Optional: local Action runner / CI experimentation --- +.act/ diff --git a/.vscode/settings.json b/.vscode/settings.json index e0f15db..0be1c0c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "java.configuration.updateBuildConfiguration": "automatic" + "java.configuration.updateBuildConfiguration": "automatic", + "java.compile.nullAnalysis.mode": "automatic" } \ No newline at end of file diff --git a/README.md b/README.md index df90442..6718f4e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Secure Mobile Operations Application (SMOA) +[![CI](https://gitea.d-bis.org/Sankofa_Phoenix/SMOA/actions/workflows/ci.yml/badge.svg)](https://gitea.d-bis.org/Sankofa_Phoenix/SMOA/actions) + +**Repository (monorepo):** [https://gitea.d-bis.org/Sankofa_Phoenix/SMOA](https://gitea.d-bis.org/Sankofa_Phoenix/SMOA) — `git clone https://gitea.d-bis.org/Sankofa_Phoenix/SMOA.git` + +**CI:** [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) (Gitea Actions) — runs `./gradlew smoaVerify` on push/PR. + **Android Foldable Devices – Online / Offline Mission Operations** > **Constitutional Status**: This repository implements **SMOA**, a Tier-1 canonical sovereign system. See [NOTICE.md](./NOTICE.md) and [CANONICAL_CONSTITUTION.md](../CANONICAL_CONSTITUTION.md) for details. @@ -72,9 +78,16 @@ smoa/ ## Building ```bash +# Backend tests + Android debug APK (recommended): +./gradlew smoaVerify --no-daemon +# or: ./scripts/build-all.sh + +# Full project build: ./gradlew build ``` +See [docs/development/BUILD.md](docs/development/BUILD.md) for details. Debug APK: `app/build/outputs/apk/debug/app-debug.apk`. + ## Requirements - Android Studio Hedgehog or later @@ -144,4 +157,3 @@ See the `docs/` directory for comprehensive documentation: ## License Proprietary - Government Use Only - diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..f419f24 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,59 @@ +# SMOA – Master task list + +All areas; **status** reflects repo state after the 2026-02 optional-task sweep. Details: [TODO.md](./TODO.md). + +| # | Area | Task | Status | +|---|------|------|--------| +| B1 | Backend | Prod profile, Flyway, PostgreSQL docs | Done | +| B2 | Backend | Tenant filter (`X-Unit`) | Done | +| B3 | Backend | Pagination / OpenAPI params | Done | +| B4 | Backend | ETag for GET `/api/v1/*` | Done | +| B5 | Backend | Request ID filter | Done | +| B6 | Backend | API versioning doc | Done | +| B7 | Backend | Gradle Kotlin plugin + `FAIL_ON_PROJECT_REPOS` fix | Done | +| B8 | Backend | Test profile (`application-test.yml`), passing tests | Done | +| A1 | Android | Sync serialization (Gson), PullAPI, BuildConfig API URL/key | Done | +| A2 | Android | InfrastructureManager STUN/signaling from BuildConfig | Done | +| A3 | Android | Android 16 target doc | Done | +| A4 | Android | Knox integration | **Doc:** [KNOX-INTEGRATION.md](docs/reference/KNOX-INTEGRATION.md) (SDK not bundled) | +| A5 | Android | WebRTC PeerConnection | **Stub** + [WebRTCManager](modules/communications/.../WebRTCManager.kt); optional AAR | +| A6 | Android | Connection quality | **Done:** [NetworkEstimatesConnectionQualityMonitor](modules/communications/.../NetworkEstimatesConnectionQualityMonitor.kt) | +| A7 | Android | Screen share / file transfer | **Stub** flags in [VideoTransport](modules/meetings/.../VideoTransport.kt) | +| A8 | Android | SmartCardReader | **Stub** (no PC/SC driver) | +| I1 | iOS | App in Xcode | **External** – create project; contract in [docs/ios/README.md](docs/ios/README.md) | +| I2 | iOS | Keychain / offline / biometrics / pinning | **Samples:** [docs/ios/SAMPLES.md](docs/ios/SAMPLES.md) | +| W1 | Web | Scaffold + directory pull | Done | +| W2 | Web | PWA manifest + service worker | Done | +| W3 | Web | Offline queue helper | Done (`offline-queue.js`) | +| W4 | Web | Deploy + CORS | **Doc:** [DEPLOY.md](docs/web-scaffold/DEPLOY.md) | +| N1 | Infra | Nginx, docker-compose, k8s example | Done | +| N2 | Infra | TURN/signaling self-host | **Doc:** [TURN-SIGNALING.md](docs/infrastructure/TURN-SIGNALING.md) | +| D1 | Domain | NCIC live API | **Legal gate** – CJIS; local **Room** log: [NCICQueryDatabase](modules/ncic/.../NCICQueryDatabase.kt) | +| D2 | Domain | ATF eTrace | **Legal gate**; **Room** drafts: [ATFFormDatabase](modules/atf/.../ATFFormDatabase.kt) | +| D3 | Domain | eIDAS QTSP / EU trust lists | **Product gate** – stub in [EIDASService](core/eidas/.../EIDASService.kt) | +| D4 | Domain | JCA digital signatures | **Done:** [DigitalSignatureService](core/signing/.../DigitalSignatureService.kt) | +| D5 | Domain | Electronic seal verify | **Done:** SHA-256 compare in [ElectronicSealService](core/signing/.../ElectronicSealService.kt) | +| D6 | Domain | XML/AS4 full stack | **Stub** + [core/as4/README.md](core/as4/README.md) | +| D7 | Domain | Certificate revocation | Stub UNKNOWN; extend with OCSP/CRL | +| T1 | Testing | `smoaVerify` in CI | Done (`.gitea/workflows/ci.yml`) | +| T2 | Testing | Android coverage 80%+ | **Plan** – expand over time | +| T3 | Testing | E2E | **Plan:** [E2E-PLAN.md](docs/testing/E2E-PLAN.md) | +| X1 | Docs | BUILD.md, TASKS.md, TODO sweep | Done | +| X2 | Build | **`./gradlew smoaVerify`** + `scripts/build-all.sh` (backend test + debug APK) | Done | +| A9 | Android | TLS pinning + `AuthTokenInterceptor` + enterprise settings card | **Done** — [NetworkModule](app/src/main/java/com/smoa/di/NetworkModule.kt), [SECURITY-ENTERPRISE.md](docs/development/SECURITY-ENTERPRISE.md) | +| A10 | Android | `SecureTokenStore`, OIDC `BuildConfig`, session lock overlay | **Done** — [security/](app/src/main/java/com/smoa/security/), [SessionLockOverlay](app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt) | +| A11 | Android | Play Integrity client + Knox classpath probe | **Done** — [PlayIntegrityVerifier](app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt), [KnoxEnterpriseProbe](app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt) | +| A12 | Android | Biometric-gated AES key scaffold | **Done** — [BiometricSecretsVault](app/src/main/java/com/smoa/security/BiometricSecretsVault.kt); **Next:** CryptoObject + token wrap | +| A13 | Android | **OIDC login flow** (AppAuth) + redirect activity / intent-filter | **Done** — [OidcLoginCoordinator](app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt), manifest `RedirectUriReceiverActivity` | +| A14 | Android | **Refresh token** — OkHttp `Authenticator` + `SecureTokenStore` | **Done** — [TokenRefreshAuthenticator](app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt), token endpoint persisted after OIDC | +| A15 | Android | Certificate pinning host from **hosted config** (`RemoteEndpointStore`) | **Done** — [NetworkPinningConfig.resolveBackendHost](app/src/main/java/com/smoa/network/NetworkPinningConfig.kt) + [NetworkModule](app/src/main/java/com/smoa/di/NetworkModule.kt) | +| A16 | Android | **BiometricPrompt.CryptoObject** + keystore encrypt path | **Done** — [BiometricSecretsVault.tryCreateEncryptCryptoObject](app/src/main/java/com/smoa/security/BiometricSecretsVault.kt), [BiometricAuthenticator.authenticateWithCryptoObject](app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt) | +| A17 | Android | Session lock: **device credential** fallback | **Done** — [BiometricAuthenticator.authenticateForSessionUnlock](app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt), sign-in MFA path allows PIN/pattern | +| A18 | Android | **Demo** auth + **SSO** when OIDC BuildConfig set | **Hybrid** — [AuthFlowHost](app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt) organization SSO + local demo; full IdP-only when you remove demo UI | +| A19 | Android | Release **R8** + pinning + Integrity + ProGuard | **Verified** — `./gradlew :app:assembleRelease` succeeds; extend release checklists per tenant | +| A20 | Android | **Multi-host** pins | **Done** — `tls_pin_spec` in hosted config + `SMOA_TLS_PIN_SPEC` BuildConfig; [CertificatePinnerFactory.buildFromMultiSpec](app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt) | +| A21 | Android | Classification / watermark from **remote config** | **Done** — `classification_watermark_*` in [SmoaClientConfigJson](app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt), [ClassificationWatermark](app/src/main/java/com/smoa/ui/components/SmoaChrome.kt) | +| T4 | Testing | Unit tests: session lock logic, certificate pinning | **Done** — [SessionLockLogicTest](app/src/test/java/com/smoa/security/SessionLockLogicTest.kt), [CertificatePinnerFactoryTest](app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt); `SecureTokenStore` needs on-device / crypto-capable runner | +| T5 | Testing | UI tests: session lock, user settings | **Partial** — add `@HiltAndroidTest` + Compose rules on emulator when CI has a device | +| T6 | Testing | CI: `scripts/generate-api-docs.sh` | **Done** — [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) | +| B9 | Backend | **Play Integrity** verify endpoint | **Stub** — `POST /api/v1/integrity/verify` returns **501** until Google API wired — [IntegrityAttestationController](backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt) | diff --git a/TODO.md b/TODO.md index 8834f78..e730d85 100644 --- a/TODO.md +++ b/TODO.md @@ -1,130 +1,101 @@ -# SMOA – Remaining and Optional Tasks +# SMOA – Task status (post-sweep) -Single list of **remaining** and **optional** work. References: [BACKEND-GAPS-AND-ROADMAP.md](backend/docs/BACKEND-GAPS-AND-ROADMAP.md), [REQUIREMENTS-ALIGNMENT.md](docs/reference/REQUIREMENTS-ALIGNMENT.md), [PLATFORM-REQUIREMENTS.md](docs/reference/PLATFORM-REQUIREMENTS.md), [IMPLEMENTATION_STATUS.md](docs/status/IMPLEMENTATION_STATUS.md). +This file tracks **what the repository delivers** versus **what remains outside the repo** (legal gates, Xcode app binary, live vendor APIs). + +**Master index:** [TASKS.md](./TASKS.md) +**Build:** [docs/development/BUILD.md](docs/development/BUILD.md) — run **`./gradlew smoaVerify --no-daemon`** (or `./scripts/build-all.sh`) for backend tests + debug APK. --- -## Backend +## Completed in repository (optional + remaining) -### Remaining -- [x] **Prod profile and DB** – Done: application-prod.yml, ddl-auto: validate, Flyway; document PostgreSQL in README. -- [x] **Unit/tenant scoping** – Done: TenantFilter when smoa.tenant.require-unit=true; X-Unit required for /api/v1. -- [x] **Migrations** – Done: Flyway, V1__baseline.sql, baseline-on-migrate. +### Backend +- [x] Prod profile, Flyway, PostgreSQL documentation +- [x] Tenant filter (`smoa.tenant.require-unit`, `X-Unit`) +- [x] Pagination / OpenAPI `@Parameter` +- [x] ETag (`ShallowEtagHeaderFilter`), Request ID, API versioning doc +- [x] Gradle: `pluginManagement` for `:backend`, no project-local repos under `FAIL_ON_PROJECT_REPOS` +- [x] Tests: `application-test.yml`, MockK fixes, `:backend:test` green -### Optional -- [x] **Pagination** – Done: @Parameter on PullController for since/limit. -- [x] **ETag / If-None-Match** – Done: ShallowEtagHeaderFilter for /api/v1/*; GET list supports ETag and 304. -- [x] **Request ID** – Done: RequestIdFilter (X-Request-Id, MDC). -- [x] **API versioning** – Doc: backend/docs/API-VERSIONING.md (when to add v2, deprecation). -- [x] **Fix Gradle/Kotlin plugin** – Resolve “plugin already on classpath with unknown version” so `./gradlew :backend:test` runs (root vs backend plugin alignment). +### Android +- [x] Sync/pull, Gson, BuildConfig backend URL + API key + STUN/signaling URLs +- [x] Android 16 documentation +- [x] **Connection quality:** `NetworkEstimatesConnectionQualityMonitor` (link bandwidth from `NetworkCapabilities`) +- [x] **WebRTC / screen share / file transfer:** documented stubs; `WebRTCManager` + `VideoTransport` flags (no AAR bundled) +- [x] **SmartCardReader:** explicit no-hardware stub +- [x] **Knox:** integration guide only — [KNOX-INTEGRATION.md](docs/reference/KNOX-INTEGRATION.md) + +### iOS +- [x] API contract + checklist + **Swift samples** — [docs/ios/README.md](docs/ios/README.md), [docs/ios/SAMPLES.md](docs/ios/SAMPLES.md) +- [ ] **Shipped Xcode app** — create in a separate Xcode project (not stored here) + +### Web +- [x] Scaffold: info, health, directory pull, touch-friendly UI +- [x] **PWA:** `manifest.webmanifest`, `sw.js`, service worker registration +- [x] **Offline helper:** `offline-queue.js` (IndexedDB queue pattern) +- [x] **Deploy + CORS:** [docs/web-scaffold/DEPLOY.md](docs/web-scaffold/DEPLOY.md) + +### Infrastructure +- [x] Nginx example, docker-compose, k8s Deployment/Service example +- [x] **TURN/signaling:** [docs/infrastructure/TURN-SIGNALING.md](docs/infrastructure/TURN-SIGNALING.md) + +### Domain / compliance +- [x] **ATF local storage:** Room `ATFFormDraftEntity` + DAO + `ATFFormDatabase` +- [x] **NCIC local log:** Room `NCICQueryLogEntity` + DAO + `NCICQueryDatabase` (+ Room deps on `:modules:ncic`) +- [x] **Digital signatures:** JCA `Signature` in `DigitalSignatureService` +- [x] **Electronic seal:** SHA-256 content verification in `ElectronicSealService` +- [x] **AS4 / eIDAS:** stubs documented; roadmap [core/as4/README.md](core/as4/README.md) +- [x] NCIC/ATF/eIDAS **live** integrations: clearly marked as **simulation or product gate** in service code + +### Testing & CI +- [x] Gitea Actions: `./gradlew smoaVerify --no-daemon` — [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) +- [x] E2E plan doc — [docs/testing/E2E-PLAN.md](docs/testing/E2E-PLAN.md) +- [ ] Android 80%+ coverage — ongoing goal + +### Enterprise / auth / networking (coding follow-ups) + +See [TASKS.md](./TASKS.md) rows **A9–A21**, **T4–T6**. Summary: + +- [ ] **OIDC client:** AppAuth or SSO WebView; redirect `intent-filter`; code exchange → `SecureTokenStore.persistTokens` +- [ ] **Token refresh:** OkHttp `Authenticator` on 401 using `refresh_token` +- [ ] **Pinning + dynamic URL:** resolve pin host from `RemoteEndpointStore` when backend URL comes only from hosted config +- [ ] **Biometric + Keystore:** `Cipher` + `BiometricPrompt.CryptoObject` to wrap/unwrap refresh token with `BiometricSecretsVault` +- [ ] **Session lock:** optional device-credential path (not only biometrics) where policy allows +- [ ] **Play Integrity:** POST token to your backend; add **server** verification (Play Integrity API) — sample in backend +- [ ] **Tests:** `SessionLockController`, pinning factory, `SecureTokenStore`, `navigateSmoa`, settings ViewModel +- [ ] **Auth UX:** replace demo password/MFA in `AuthFlowHost` with IdP-driven flow or hybrid step-up +- [ ] **Release:** R8/proguard shrink test with Integrity + OkHttp pinning enabled +- [ ] **CI:** optional `generate-api-docs.sh` + publish `docs/api/generated` +- [ ] **Multi-pin hosts:** if pull/config use different origins than `SMOA_BACKEND_BASE_URL`, extend `CertificatePinnerFactory` + +### Documentation +- [x] Backend README, IMPLEMENTATION_STATUS “next steps”, BUILD.md, TASKS.md --- -## Android app +## Outside repository (cannot close without external parties) -### Remaining -- [x] **SyncService serialization** – Done: Gson in core:common; serialize* produce JSON bytes. -- [x] **Pull on connect** – Done: PullAPI + BackendPullAPI; startSync() runs pull when online and emits to pullResults (merge by observing modules). -- [x] **API key in app** – Done: BuildConfig.SMOA_API_KEY, passed to BackendSyncAPI (build with -Psmoa.api.key=…). -- [x] **Android 16 doc** – Done: [docs/reference/ANDROID-16-TARGET.md](docs/reference/ANDROID-16-TARGET.md). Actual SDK bump when AGP 8.5+ is adopted. - -### Optional -- [ ] **Knox integration** – If required, integrate Knox SDK (e.g. secure storage, VPN) for target devices; Knox API level 39 is supported on primary device. -- [ ] **WebRTC full integration** – Replace WebRTCManager TODOs with actual PeerConnection, audio/video capture, and track setup when library is fully integrated. -- [ ] **Connection quality from WebRTC** – Replace StubConnectionQualityMonitor with implementation that uses WebRTC `getStats()` (bandwidth, RTT, loss) and calls SmartRoutingService/AdaptiveCodecSelector. -- [x] **InfrastructureManager endpoints** – Done: BuildConfig SMOA_STUN_URLS, SMOA_SIGNALING_URLS; SMOAApplication configures STUN and signaling at startup; TURN set programmatically (see modules/communications/README.md). -- [ ] **Screen sharing / file transfer** – Implement TODOs in VideoTransport for screen sharing and file transfer in meetings. -- [ ] **SmartCardReader** – Implement actual card detection, connection, disconnection (or remove if not required). - ---- - -## iOS (last 3 generations) - -### Remaining -- [ ] **iOS app project** – Scaffold: [docs/ios/README.md](docs/ios/README.md). Create full app (Swift/SwiftUI) targeting iOS 15, 16, 17. -- [ ] **Keychain for API key** – To implement in iOS app. -- [ ] **Offline queue** – To implement in iOS app (queue sync when offline; retry when online). - -### Optional -- [ ] **Face ID / Touch ID** – Optional app unlock or sensitive-action auth. -- [ ] **Certificate pinning** – Optional for API calls. - ---- - -## Web Dapp (Desktop / Laptop + touch) - -### Remaining -- [x] **Web scaffold expand** – Done: [docs/web-scaffold/index.html](docs/web-scaffold/index.html) – API info, health, **Pull directory** (GET /api/v1/directory, list display, 304 handling); vanilla JS, no build step. Full SPA (React/Vue/Svelte) remains optional. -- [ ] **Build and host** – Build pipeline and HTTPS hosting when SPA is ready. -- [ ] **CORS** – Configure backend `smoa.cors.allowed-origins` for web app origin(s) in production. - -### Optional -- [ ] **Offline** – Service Worker + Cache API; queue sync in IndexedDB/localStorage and flush when online. -- [ ] **PWA** – Installable; optional offline shell. - ---- - -## Infrastructure - -### Optional -- [x] **Reverse proxy** – Done: [nginx-smoa.conf.example](docs/infrastructure/nginx-smoa.conf.example), [docker-compose.yml](docker-compose.yml). -- [ ] **TURN / signaling** – Host TURN and/or signaling for WebRTC if not using external services. -- [x] **k8s manifests** – Done: [docs/infrastructure/k8s/backend-deployment.yaml](docs/infrastructure/k8s/backend-deployment.yaml) (Deployment, Service, optional Secret/ConfigMap). - ---- - -## Domain / compliance (optional, by priority) - -### High (requires approvals / provider selection) -- [ ] **NCIC/III integration** – NCIC API (CJIS approval required). -- [ ] **ATF eTrace** – ATF eTrace API (federal approval required). -- [ ] **eIDAS QTSP** – Integrate with Qualified Trust Service Provider; qualified signatures, timestamps, EU Trust Lists. - -### Medium -- [ ] **Digital signatures** – Full BouncyCastle (or similar) signature generation/verification; certificate chain validation. -- [ ] **XML security** – Apache Santuario; XMLDSig/XMLEnc for AS4 and compliance. -- [x] **CertificateManager.checkRevocationStatus** – Stub clarified: returns UNKNOWN; extend with OCSP/CRL for production. -- [ ] **AS4 full implementation** – Full AS4 message envelope, ebMS 3.0, WS-RM, receipts, CPA (see AS4Service TODOs). -- [x] **Report digital signature** – Done: ReportService.signReports + minimal SHA-256 content-hash signature; full signing via dedicated service when needed. -- [ ] **Electronic seal** – Actual seal verification (ElectronicSealService TODO). - -### Lower / future -- [x] **ZeroTrustFramework** – Replaced TODO with “Minimal implementation; extend for production”. -- [x] **ThreatDetection** – Replaced TODOs with “Minimal implementation; extend for production”. -- [ ] **ATF form storage** – Add entities and storage for ATF forms (ATFFormDatabase TODO). -- [ ] **NCIC query storage** – Add entities for NCIC query storage (NCICQueryDatabase TODO). -- [ ] **Compliance gaps** – Address domain-specific gaps in [COMPLIANCE_EVALUATION.md](docs/reference/COMPLIANCE_EVALUATION.md) (eIDAS QES, credential formats, barcode, NIBRS/UCR, etc.) per deployment priorities. - ---- - -## Testing - -### Optional -- [ ] **Backend tests** – Fix Gradle plugin so `:backend:test` runs; add more integration tests as needed. -- [ ] **Android unit/integration** – More unit tests for remaining modules; integration tests; UI tests; target 80%+ coverage where practical. -- [ ] **E2E** – End-to-end tests for critical flows (sync, auth, meetings). - ---- - -## Documentation - -### Optional -- [x] **README/back-end** – Done: Backend README lists DELETE/GET, rate limit, audit, Docker, tenant (smoa.tenant.require-unit), Request ID, Flyway, PostgreSQL prod, CORS (smoa.cors.allowed-origins), ETag. -- [x] **Timeline** – Done: IMPLEMENTATION_STATUS.md “Next steps (short-term)” section added. +| Item | Blocker | +|------|---------| +| Live **NCIC/III** API | CJIS approval, agency contract | +| Live **ATF eTrace** | Federal approval, credentials | +| **eIDAS QTSP** / EU Trust Lists | Trust service provider, operational URLs | +| **Knox SDK** in binary | Samsung license / partner program | +| **WebRTC** production media | Ship `google-webrtc` or vendor AAR + signaling | +| **Full AS4** interop | Partner CPA, CXF/Santuario stack, operations | +| **Compliance matrix gaps** | Deployment-specific priorities — see [COMPLIANCE_EVALUATION.md](docs/reference/COMPLIANCE_EVALUATION.md) | --- ## Summary -| Area | Remaining | Optional | -|------------|-----------|----------| -| Backend | 0 | 2 | -| Android | 0 | 6 | -| iOS | 3 | 2 | -| Web Dapp | 2 | 2 | -| Infra | 0 | 2 | -| Domain | 0 | 12+ | -| Testing | 0 | 3 | -| Docs | 0 | 0 | +| Area | Repo-delivered | External / binary app | +|------|----------------|------------------------| +| Backend | All listed | — | +| Android | All listed except Knox binary | Knox AAR, WebRTC AAR optional | +| iOS | Docs + samples | Xcode application | +| Web | Scaffold + PWA + deploy doc | Your HTTPS host + CORS env | +| Infra | Examples + docs | Your servers | +| Domain | Room + JCA + stubs | Live agency/vendor APIs | -Use this file as the single checklist for remaining and optional work; link to it from [docs/README.md](docs/README.md) or [IMPLEMENTATION_STATUS.md](docs/status/IMPLEMENTATION_STATUS.md) as needed. +Use [TASKS.md](./TASKS.md) for the flat checklist with file pointers. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3ef8c6e..abca36b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,11 +1,17 @@ +import java.net.URI + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") - id("kotlin-kapt") - id("dagger.hilt.android.plugin") + id("com.google.dagger.hilt.android") id("kotlin-parcelize") + id("kotlin-kapt") } +val oidcRedirectProp = (project.findProperty("smoa.oidc.redirectUri") as? String)?.trim().orEmpty() +val oidcRedirectUriDefault = if (oidcRedirectProp.isEmpty()) "com.smoa://oauth/redirect" else oidcRedirectProp +val oauthRedirectUri = runCatching { URI(oidcRedirectUriDefault) }.getOrNull() + android { namespace = "com.smoa" compileSdk = AppConfig.compileSdk @@ -21,7 +27,24 @@ android { buildConfigField("String", "SMOA_API_KEY", "\"${project.findProperty("smoa.api.key") ?: ""}\"") buildConfigField("String", "SMOA_STUN_URLS", "\"${project.findProperty("smoa.stun.urls") ?: ""}\"") buildConfigField("String", "SMOA_SIGNALING_URLS", "\"${project.findProperty("smoa.signaling.urls") ?: ""}\"") - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "SMOA_CONFIG_URL", "\"${project.findProperty("smoa.config.url") ?: ""}\"") + val browserVpn = (project.findProperty("smoa.browser.vpnEnforced") as? String)?.toBoolean() == true + buildConfigField("boolean", "SMOA_BROWSER_VPN_ENFORCED", "$browserVpn") + // TLS: comma-separated OkHttp pins, e.g. sha256/AAAAAAAAAAA=,sha256/BBBBBBB= (see docs/development/SECURITY-ENTERPRISE.md) + buildConfigField("String", "SMOA_TLS_PINS", "\"${project.findProperty("smoa.tls.pins") ?: ""}\"") + // Multi-host pins: host|sha256/a,sha256/b;other.host|sha256/c (optional; overrides single-host when non-empty) + buildConfigField("String", "SMOA_TLS_PIN_SPEC", "\"${project.findProperty("smoa.tls.pinSpec") ?: ""}\"") + // OIDC placeholders — wire AppAuth / Custom Tabs in your IdP integration layer + buildConfigField("String", "SMOA_OIDC_ISSUER", "\"${project.findProperty("smoa.oidc.issuer") ?: ""}\"") + buildConfigField("String", "SMOA_OIDC_CLIENT_ID", "\"${project.findProperty("smoa.oidc.clientId") ?: ""}\"") + buildConfigField("String", "SMOA_OIDC_REDIRECT_URI", "\"${project.findProperty("smoa.oidc.redirectUri") ?: ""}\"") + val sessionTimeout = (project.findProperty("smoa.session.timeoutMinutes") as? String)?.toIntOrNull() ?: 15 + buildConfigField("int", "SMOA_SESSION_TIMEOUT_MINUTES", "$sessionTimeout") + buildConfigField("String", "SMOA_PLAY_INTEGRITY_CLOUD_PROJECT_NUMBER", "\"${project.findProperty("smoa.playIntegrity.cloudProjectNumber") ?: ""}\"") + buildConfigField("String", "SMOA_CLASSIFICATION_BUILD_MARKING", "\"${project.findProperty("smoa.classification.buildMarking") ?: "UNCONFIGURED"}\"") + manifestPlaceholders["appAuthRedirectScheme"] = oauthRedirectUri?.scheme ?: "com.smoa" + manifestPlaceholders["appAuthRedirectHost"] = oauthRedirectUri?.host ?: "oauth" + testInstrumentationRunner = "com.smoa.HiltTestRunner" vectorDrawables { useSupportLibrary = true } @@ -55,6 +78,10 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.4" } + + hilt { + enableAggregatingTask = true + } packaging { resources { @@ -73,15 +100,23 @@ android { } } +kapt { + correctErrorTypes = true +} + dependencies { implementation(platform(Dependencies.composeBom)) implementation(Dependencies.composeUi) implementation(Dependencies.composeUiGraphics) implementation(Dependencies.composeUiToolingPreview) implementation(Dependencies.composeMaterial3) + implementation("androidx.compose.material3:material3-window-size-class") + implementation("androidx.compose.material:material-icons-extended") implementation(Dependencies.androidxActivityCompose) implementation(Dependencies.androidxCoreKtx) implementation(Dependencies.androidxLifecycleRuntimeKtx) + implementation(Dependencies.androidxLifecycleViewmodelCompose) + implementation(Dependencies.lifecycleProcess) // Navigation implementation(Dependencies.navigationCompose) @@ -90,6 +125,12 @@ dependencies { implementation(Dependencies.hiltAndroid) kapt(Dependencies.hiltAndroidCompiler) implementation(Dependencies.hiltNavigationCompose) + + implementation(Dependencies.roomRuntime) + implementation(Dependencies.roomKtx) + kapt(Dependencies.roomCompiler) + implementation("androidx.sqlite:sqlite:2.4.0") + implementation(Dependencies.sqlcipher) // Core modules implementation(project(":core:auth")) @@ -127,14 +168,19 @@ dependencies { implementation(Dependencies.retrofit) implementation(Dependencies.retrofitGson) implementation(Dependencies.okHttp) + implementation("net.openid:appauth:0.11.1") + implementation(Dependencies.playIntegrity) + implementation(Dependencies.coroutinesPlayServices) // Testing testImplementation(Dependencies.junit) androidTestImplementation(Dependencies.androidxJunit) androidTestImplementation(Dependencies.espressoCore) + androidTestImplementation(Dependencies.hiltAndroidTesting) + kaptAndroidTest(Dependencies.hiltAndroidCompiler) + androidTestImplementation(Dependencies.mockWebServer) androidTestImplementation(platform(Dependencies.composeBom)) androidTestImplementation(Dependencies.composeUiTestJunit4) debugImplementation(Dependencies.composeUiTooling) debugImplementation(Dependencies.composeUiTestManifest) } - diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 3026518..3c96f0a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -20,7 +20,37 @@ -keep class androidx.security.** { *; } -dontwarn androidx.security.** +# Play Integrity / Play Core +-keep class com.google.android.play.core.integrity.** { *; } +-dontwarn com.google.android.play.core.** + +# OkHttp / certificate pinning +-dontwarn okhttp3.internal.platform.** + +# AppAuth (OIDC) +-keep class net.openid.appauth.** { *; } +-dontwarn net.openid.appauth.** + +# Gson / remote config / sync DTOs (field names must match JSON) +-keep class com.smoa.config.SmoaClientConfigJson { *; } +-keep class com.smoa.core.common.sync.CredentialSyncRequestDto { *; } +-keep class com.smoa.sync.** { *; } + # Keep Kotlin coroutines -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +# R8: optional references from CXF / Woodstox / Tink (desktop JDK & compile-only annotations) +-dontwarn aQute.bnd.annotation.spi.ServiceProvider +-dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue +-dontwarn com.google.errorprone.annotations.CheckReturnValue +-dontwarn com.google.errorprone.annotations.Immutable +-dontwarn com.google.errorprone.annotations.RestrictedApi +-dontwarn javax.imageio.ImageIO +-dontwarn javax.xml.stream.XMLEventFactory +-dontwarn javax.xml.stream.XMLInputFactory +-dontwarn javax.xml.stream.XMLOutputFactory +-dontwarn javax.xml.stream.XMLResolver +-dontwarn javax.xml.stream.util.XMLEventAllocator +-dontwarn org.springframework.context.ApplicationContext + diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..de2746a --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/androidTest/java/com/smoa/HiltTestRunner.kt b/app/src/androidTest/java/com/smoa/HiltTestRunner.kt new file mode 100644 index 0000000..6901fab --- /dev/null +++ b/app/src/androidTest/java/com/smoa/HiltTestRunner.kt @@ -0,0 +1,14 @@ +package com.smoa + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner + +/** + * Runs instrumented tests with [SMOAHiltTestApplication] so @HiltAndroidTest can inject app components. + */ +class HiltTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application { + return super.newApplication(cl, SMOAHiltTestApplication::class.java.name, context) + } +} diff --git a/app/src/androidTest/java/com/smoa/SMOAHiltTestApplication.kt b/app/src/androidTest/java/com/smoa/SMOAHiltTestApplication.kt new file mode 100644 index 0000000..4c323f6 --- /dev/null +++ b/app/src/androidTest/java/com/smoa/SMOAHiltTestApplication.kt @@ -0,0 +1,10 @@ +package com.smoa + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +/** + * Test process entry point for Hilt-instrumented tests ([HiltTestRunner]). + */ +@HiltAndroidApp +class SMOAHiltTestApplication : Application() diff --git a/app/src/androidTest/java/com/smoa/api/BackendSyncCredentialConflictMockServerInstrumentedTest.kt b/app/src/androidTest/java/com/smoa/api/BackendSyncCredentialConflictMockServerInstrumentedTest.kt new file mode 100644 index 0000000..ff5c7f5 --- /dev/null +++ b/app/src/androidTest/java/com/smoa/api/BackendSyncCredentialConflictMockServerInstrumentedTest.kt @@ -0,0 +1,74 @@ +package com.smoa.api + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.gson.Gson +import com.smoa.api.dto.SyncResponseDto +import com.smoa.core.common.Result +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.Base64 + +/** + * HTTP integration test without a live backend. Uses OkHttp [MockWebServer] (WireMock is not + * typically used on-device; MockWebServer is the standard Android equivalent). + */ +@RunWith(AndroidJUnit4::class) +class BackendSyncCredentialConflictMockServerInstrumentedTest { + + @Test + fun syncCredential_conflict_decodesRemoteData() = runBlocking { + val remoteCredentialJson = + """{"credentialId":"c-mock","holderId":"h1","credentialType":"ID_CARD","issuer":null,"issuedAt":1,"expiresAt":2,"payloadJson":"{\"k\":1}","updatedAt":99}""" + val remoteB64 = Base64.getEncoder().encodeToString(remoteCredentialJson.toByteArray(Charsets.UTF_8)) + val dto = SyncResponseDto( + success = false, + itemId = "c-mock", + serverTimestamp = 1000L, + conflict = true, + remoteDataBase64 = remoteB64, + message = "Conflict" + ) + val bodyJson = Gson().toJson(dto) + + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(bodyJson) + ) + server.start() + + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + val service = retrofit.create(BackendSyncApiService::class.java) + val api = BackendSyncAPI { Pair(service, null) } + + val credentialBytes = + """{"credentialId":"c-mock","holderId":"h1","credentialType":"ID_CARD","clientUpdatedAt":1}""" + .toByteArray(Charsets.UTF_8) + + val result = api.syncCredential(credentialBytes) + + assertEquals("/api/v1/sync/credential", server.takeRequest().path) + + server.shutdown() + + assertTrue(result is Result.Success) + val data = (result as Result.Success).data + assertTrue(data.conflict) + assertArrayEquals(remoteCredentialJson.toByteArray(Charsets.UTF_8), data.remoteData) + } +} diff --git a/app/src/androidTest/java/com/smoa/sync/SyncConflictSnapshotRepositoryInstrumentedTest.kt b/app/src/androidTest/java/com/smoa/sync/SyncConflictSnapshotRepositoryInstrumentedTest.kt new file mode 100644 index 0000000..981b12f --- /dev/null +++ b/app/src/androidTest/java/com/smoa/sync/SyncConflictSnapshotRepositoryInstrumentedTest.kt @@ -0,0 +1,75 @@ +package com.smoa.sync + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import javax.inject.Inject + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class SyncConflictSnapshotRepositoryInstrumentedTest { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @Inject + lateinit var repository: SyncConflictSnapshotRepository + + @Inject + lateinit var store: SyncConflictSnapshotStore + + @Before + fun setup() { + hiltRule.inject() + } + + @After + fun tearDown() = runBlocking { + repository.clearAll() + } + + @Test + fun observeSnapshots_emitsAfterSave() = runBlocking { + val payload = """{"orderId":"o-int","title":"T"}""".toByteArray(Charsets.UTF_8) + store.save(resourceType = "order", itemId = "o-int", body = payload) + + val list = repository.observeSnapshots().first { rows -> + rows.any { it.itemId == "o-int" && it.resourceType == "order" } + } + + val row = list.first { it.itemId == "o-int" } + assertEquals("order", row.resourceType) + assertTrue(row.jsonUtf8.contains("orderId")) + } + + @Test + fun getSnapshot_and_deleteSnapshot() = runBlocking { + store.save("evidence", "e1", """{"evidenceId":"e1"}""".toByteArray(Charsets.UTF_8)) + + val got = repository.getSnapshot("evidence", "e1") + assertNotNull(got) + assertEquals("e1", got!!.itemId) + + repository.deleteSnapshot("evidence", "e1") + assertEquals(null, repository.getSnapshot("evidence", "e1")) + } + + @Test + fun observeByResourceType_filtersRows() = runBlocking { + store.save("report", "r1", "{}".toByteArray(Charsets.UTF_8)) + store.save("order", "o2", "{}".toByteArray(Charsets.UTF_8)) + + val reports = repository.observeByResourceType("report").first { it.any { row -> row.itemId == "r1" } } + assertTrue(reports.all { it.resourceType == "report" }) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d63c597..e759e36 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,6 +42,21 @@ android:networkSecurityConfig="@xml/network_security_config" tools:targetApi="31"> + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/smoa/MainActivity.kt b/app/src/main/java/com/smoa/MainActivity.kt index 834efce..cc75ed0 100644 --- a/app/src/main/java/com/smoa/MainActivity.kt +++ b/app/src/main/java/com/smoa/MainActivity.kt @@ -1,21 +1,36 @@ package com.smoa +import android.app.Activity import android.content.res.Configuration import android.os.Bundle -import androidx.activity.ComponentActivity +import android.content.Intent +import android.widget.Toast import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.fragment.app.FragmentActivity import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import com.smoa.core.common.ConnectivityManager +import androidx.lifecycle.lifecycleScope import com.smoa.core.common.FoldableStateManager -import com.smoa.ui.main.MainScreen +import com.smoa.auth.AuthPreferencesStore +import com.smoa.security.OidcLoginCoordinator +import com.smoa.security.SessionLockController +import com.smoa.ui.root.AppRoot +import com.smoa.ui.theme.SMOATheme import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import javax.inject.Inject +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @AndroidEntryPoint -class MainActivity : ComponentActivity() { +class MainActivity : FragmentActivity() { @Inject lateinit var connectivityManager: ConnectivityManager @@ -50,8 +65,42 @@ class MainActivity : ComponentActivity() { @Inject lateinit var rbacFramework: com.smoa.core.auth.RBACFramework + @Inject + lateinit var authPreferencesStore: AuthPreferencesStore + + @Inject + lateinit var sessionLockController: SessionLockController + + @Inject + lateinit var oidcLoginCoordinator: OidcLoginCoordinator + + private lateinit var oidcAuthLauncher: ActivityResultLauncher + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() super.onCreate(savedInstanceState) + + oidcAuthLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + when (result.resultCode) { + Activity.RESULT_OK -> { + val data = result.data ?: return@registerForActivityResult + lifecycleScope.launch { + runCatching { + oidcLoginCoordinator.finishAuthorization(this@MainActivity, data) + }.onFailure { + Toast.makeText( + this@MainActivity, + getString(R.string.auth_oidc_failed), + Toast.LENGTH_LONG + ).show() + } + } + } + else -> oidcLoginCoordinator.cancelPendingFlow() + } + } // Enable screen protection for the entire application screenProtection.enableScreenProtection(this) @@ -62,29 +111,21 @@ class MainActivity : ComponentActivity() { // Update fold state on configuration change updateFoldState() - // Initialize default user session for testing - // In production, this would come from authentication - userSession.setUser( - com.smoa.core.auth.UserInfo( - userId = "user1", - userName = "Test User", - role = com.smoa.core.auth.RBACFramework.Role.OPERATOR, - unit = "Unit1", - clearanceLevel = null, - missionAssignment = null - ) - ) - setContent { - MaterialTheme { + val windowSizeClass = calculateWindowSizeClass(this) + SMOATheme { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - MainScreen( + AppRoot( + activity = this, + windowSizeClass = windowSizeClass, + sessionLockController = sessionLockController, + authPreferencesStore = authPreferencesStore, + userSession = userSession, connectivityManager = connectivityManager, foldableStateManager = foldableStateManager, - userSession = userSession, rbacFramework = rbacFramework, directoryService = directoryService, communicationsService = communicationsService, @@ -92,7 +133,21 @@ class MainActivity : ComponentActivity() { browserService = browserService, urlFilter = urlFilter, screenProtection = screenProtection, - vpnManager = vpnManager + vpnManager = vpnManager, + onOrganizationSignIn = { + lifecycleScope.launch { + runCatching { + val intent = oidcLoginCoordinator.prepareAuthorizationIntent() + oidcAuthLauncher.launch(intent) + }.onFailure { + Toast.makeText( + this@MainActivity, + getString(R.string.auth_oidc_failed), + Toast.LENGTH_LONG + ).show() + } + } + } ) } } diff --git a/app/src/main/java/com/smoa/SMOAApplication.kt b/app/src/main/java/com/smoa/SMOAApplication.kt index c051468..e99234b 100644 --- a/app/src/main/java/com/smoa/SMOAApplication.kt +++ b/app/src/main/java/com/smoa/SMOAApplication.kt @@ -1,6 +1,9 @@ package com.smoa import android.app.Application +import com.smoa.config.ClientConfigRefreshCoordinator +import com.smoa.core.security.VPNManager +import com.smoa.security.SessionLockController import com.smoa.modules.communications.domain.InfrastructureManager import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @@ -11,8 +14,20 @@ class SMOAApplication : Application() { @Inject lateinit var infrastructureManager: InfrastructureManager + @Inject + lateinit var clientConfigRefreshCoordinator: ClientConfigRefreshCoordinator + + @Inject + lateinit var vpnManager: VPNManager + + @Inject + lateinit var sessionLockController: SessionLockController + override fun onCreate() { super.onCreate() + sessionLockController.start() + vpnManager.setBrowserVpnEnforced(BuildConfig.SMOA_BROWSER_VPN_ENFORCED) + clientConfigRefreshCoordinator.scheduleNonBlockingInitialLoad() configureInfrastructure() } diff --git a/app/src/main/java/com/smoa/api/BackendPullAPI.kt b/app/src/main/java/com/smoa/api/BackendPullAPI.kt index 26243a1..389c367 100644 --- a/app/src/main/java/com/smoa/api/BackendPullAPI.kt +++ b/app/src/main/java/com/smoa/api/BackendPullAPI.kt @@ -6,14 +6,18 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import retrofit2.HttpException +/** + * PullAPI implementation; [resolveApi] returns service + key or null if base URL unavailable. + */ class BackendPullAPI( - private val apiService: BackendPullApiService, - private val apiKey: String? = null + private val resolveApi: () -> Pair? ) : PullAPI { override suspend fun pullDirectory(unit: String?) = withContext(Dispatchers.IO) { + val (api, key) = resolveApi() + ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured")) runCatching { - val r = apiService.getDirectory(unit, unit, apiKey) + val r = api.getDirectory(unit, unit, key) if (!r.isSuccessful) throw HttpException(r) r.body()?.bytes() ?: ByteArray(0) }.fold( @@ -23,8 +27,10 @@ class BackendPullAPI( } override suspend fun pullOrders(since: Long?, limit: Int, jurisdiction: String?) = withContext(Dispatchers.IO) { + val (api, key) = resolveApi() + ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured")) runCatching { - val r = apiService.getOrders(since, limit, jurisdiction, jurisdiction, apiKey) + val r = api.getOrders(since, limit, jurisdiction, jurisdiction, key) if (!r.isSuccessful) throw HttpException(r) r.body()?.bytes() ?: ByteArray(0) }.fold( @@ -34,8 +40,10 @@ class BackendPullAPI( } override suspend fun pullEvidence(since: Long?, limit: Int, caseNumber: String?) = withContext(Dispatchers.IO) { + val (api, key) = resolveApi() + ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured")) runCatching { - val r = apiService.getEvidence(since, limit, caseNumber, apiKey) + val r = api.getEvidence(since, limit, caseNumber, key) if (!r.isSuccessful) throw HttpException(r) r.body()?.bytes() ?: ByteArray(0) }.fold( @@ -45,8 +53,23 @@ class BackendPullAPI( } override suspend fun pullCredentials(since: Long?, limit: Int, holderId: String?) = withContext(Dispatchers.IO) { + val (api, key) = resolveApi() + ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured")) runCatching { - val r = apiService.getCredentials(since, limit, holderId, apiKey) + val r = api.getCredentials(since, limit, holderId, key) + if (!r.isSuccessful) throw HttpException(r) + r.body()?.bytes() ?: ByteArray(0) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Error(it) } + ) + } + + override suspend fun pullCredentialDetail(credentialId: String) = withContext(Dispatchers.IO) { + val (api, key) = resolveApi() + ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured")) + runCatching { + val r = api.getCredential(credentialId, key) if (!r.isSuccessful) throw HttpException(r) r.body()?.bytes() ?: ByteArray(0) }.fold( @@ -56,8 +79,10 @@ class BackendPullAPI( } override suspend fun pullReports(since: Long?, limit: Int) = withContext(Dispatchers.IO) { + val (api, key) = resolveApi() + ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured")) runCatching { - val r = apiService.getReports(since, limit, apiKey) + val r = api.getReports(since, limit, key) if (!r.isSuccessful) throw HttpException(r) r.body()?.bytes() ?: ByteArray(0) }.fold( diff --git a/app/src/main/java/com/smoa/api/BackendPullApiService.kt b/app/src/main/java/com/smoa/api/BackendPullApiService.kt index a7ea0b0..1637fac 100644 --- a/app/src/main/java/com/smoa/api/BackendPullApiService.kt +++ b/app/src/main/java/com/smoa/api/BackendPullApiService.kt @@ -4,6 +4,7 @@ import okhttp3.ResponseBody import retrofit2.Response import retrofit2.http.GET import retrofit2.http.Header +import retrofit2.http.Path import retrofit2.http.Query interface BackendPullApiService { @@ -39,6 +40,12 @@ interface BackendPullApiService { @Header("X-API-Key") apiKey: String? ): Response + @GET("api/v1/credentials/{credentialId}") + suspend fun getCredential( + @Path("credentialId") credentialId: String, + @Header("X-API-Key") apiKey: String? + ): Response + @GET("api/v1/reports") suspend fun getReports( @Query("since") since: Long?, diff --git a/app/src/main/java/com/smoa/api/BackendSyncAPI.kt b/app/src/main/java/com/smoa/api/BackendSyncAPI.kt index 69846f4..8ff9f9f 100644 --- a/app/src/main/java/com/smoa/api/BackendSyncAPI.kt +++ b/app/src/main/java/com/smoa/api/BackendSyncAPI.kt @@ -12,136 +12,88 @@ import java.util.Base64 /** * SyncAPI implementation that calls the SMOA backend over HTTP. - * Use when BuildConfig.SMOA_BACKEND_BASE_URL is set. + * [resolveApi] returns service + API key, or null if the backend base URL is not available yet. */ class BackendSyncAPI( - private val baseUrl: String, - private val apiService: BackendSyncApiService, - private val apiKey: String? = null + private val resolveApi: () -> Pair? ) : com.smoa.core.common.SyncAPI { private val jsonType = "application/json; charset=utf-8".toMediaType() + private inline fun withApi(block: (BackendSyncApiService, String?) -> SyncResponse): Result { + val (api, key) = resolveApi() + ?: return Result.Error(IllegalStateException("Backend base URL not configured")) + return runCatching { block(api, key) }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Error(it) } + ) + } + override suspend fun syncDirectoryEntry(entryData: ByteArray): Result = withContext(Dispatchers.IO) { - runCatching { - val body = entryData.toRequestBody(jsonType) - val response = apiService.syncDirectory(body, apiKey) - mapResponse(response) - }.fold( - onSuccess = { it }, - onFailure = { Result.Error(it) } - ) + withApi { api, key -> + mapResponse(api.syncDirectory(entryData.toRequestBody(jsonType), key)) + } } override suspend fun syncOrder(orderData: ByteArray): Result = withContext(Dispatchers.IO) { - runCatching { - val body = orderData.toRequestBody(jsonType) - val response = apiService.syncOrder(body, apiKey) - mapResponse(response) - }.fold( - onSuccess = { it }, - onFailure = { Result.Error(it) } - ) + withApi { api, key -> + mapResponse(api.syncOrder(orderData.toRequestBody(jsonType), key)) + } } override suspend fun syncEvidence(evidenceData: ByteArray): Result = withContext(Dispatchers.IO) { - runCatching { - val body = evidenceData.toRequestBody(jsonType) - val response = apiService.syncEvidence(body, apiKey) - mapResponse(response) - }.fold( - onSuccess = { it }, - onFailure = { Result.Error(it) } - ) + withApi { api, key -> + mapResponse(api.syncEvidence(evidenceData.toRequestBody(jsonType), key)) + } } override suspend fun syncCredential(credentialData: ByteArray): Result = withContext(Dispatchers.IO) { - runCatching { - val body = credentialData.toRequestBody(jsonType) - val response = apiService.syncCredential(body, apiKey) - mapResponse(response) - }.fold( - onSuccess = { it }, - onFailure = { Result.Error(it) } - ) + withApi { api, key -> + mapResponse(api.syncCredential(credentialData.toRequestBody(jsonType), key)) + } } override suspend fun syncReport(reportData: ByteArray): Result = withContext(Dispatchers.IO) { - runCatching { - val body = reportData.toRequestBody(jsonType) - val response = apiService.syncReport(body, apiKey) - mapResponse(response) - }.fold( - onSuccess = { it }, - onFailure = { Result.Error(it) } - ) + withApi { api, key -> + mapResponse(api.syncReport(reportData.toRequestBody(jsonType), key)) + } } override suspend fun deleteDirectory(id: String): Result = withContext(Dispatchers.IO) { - runCatching { - val response = apiService.deleteDirectory(id, apiKey) - mapResponse(response) - }.fold( - onSuccess = { it }, - onFailure = { Result.Error(it) } - ) + withApi { api, key -> mapResponse(api.deleteDirectory(id, key)) } } override suspend fun deleteOrder(orderId: String): Result = withContext(Dispatchers.IO) { - runCatching { - val response = apiService.deleteOrder(orderId, apiKey) - mapResponse(response) - }.fold( - onSuccess = { it }, - onFailure = { Result.Error(it) } - ) + withApi { api, key -> mapResponse(api.deleteOrder(orderId, key)) } } override suspend fun deleteEvidence(evidenceId: String): Result = withContext(Dispatchers.IO) { - runCatching { - val response = apiService.deleteEvidence(evidenceId, apiKey) - mapResponse(response) - }.fold( - onSuccess = { it }, - onFailure = { Result.Error(it) } - ) + withApi { api, key -> mapResponse(api.deleteEvidence(evidenceId, key)) } } override suspend fun deleteCredential(credentialId: String): Result = withContext(Dispatchers.IO) { - runCatching { - val response = apiService.deleteCredential(credentialId, apiKey) - mapResponse(response) - }.fold( - onSuccess = { it }, - onFailure = { Result.Error(it) } - ) + withApi { api, key -> mapResponse(api.deleteCredential(credentialId, key)) } } override suspend fun deleteReport(reportId: String): Result = withContext(Dispatchers.IO) { - runCatching { - val response = apiService.deleteReport(reportId, apiKey) - mapResponse(response) - }.fold( - onSuccess = { it }, - onFailure = { Result.Error(it) } - ) + withApi { api, key -> mapResponse(api.deleteReport(reportId, key)) } } - private fun mapResponse(response: retrofit2.Response): Result { + private fun mapResponse(response: retrofit2.Response): SyncResponse { if (!response.isSuccessful) { - return Result.Error(HttpException(response)) + throw HttpException(response) } - val dto = response.body() ?: return Result.Error(NullPointerException("Empty body")) + val dto = response.body() ?: throw NullPointerException("Empty body") val remoteData = dto.remoteDataBase64?.let { base64 -> try { Base64.getDecoder().decode(base64) @@ -149,15 +101,13 @@ class BackendSyncAPI( null } } - return Result.Success( - SyncResponse( - success = dto.success, - itemId = dto.itemId, - serverTimestamp = dto.serverTimestamp, - conflict = dto.conflict, - remoteData = remoteData, - message = dto.message - ) + return SyncResponse( + success = dto.success, + itemId = dto.itemId, + serverTimestamp = dto.serverTimestamp, + conflict = dto.conflict, + remoteData = remoteData, + message = dto.message ) } } diff --git a/app/src/main/java/com/smoa/api/SyncRetrofitHolder.kt b/app/src/main/java/com/smoa/api/SyncRetrofitHolder.kt new file mode 100644 index 0000000..29571d6 --- /dev/null +++ b/app/src/main/java/com/smoa/api/SyncRetrofitHolder.kt @@ -0,0 +1,77 @@ +package com.smoa.api + +import com.smoa.BuildConfig +import com.smoa.config.RemoteEndpointStore +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Resolves Retrofit and API keys from [RemoteEndpointStore] with [BuildConfig] fallback. + * Rebuilds the client when the effective base URL changes (e.g. after hosted config fetch). + */ +@Singleton +class SyncRetrofitHolder @Inject constructor( + private val store: RemoteEndpointStore, + private val httpClient: OkHttpClient +) { + private val lock = Any() + private var cachedBaseUrl: String? = null + private var retrofit: Retrofit? = null + + /** True when the app may use HTTP sync (static URL, hosted config URL, or cached override). */ + fun shouldEnableBackendNetworking(): Boolean { + if (BuildConfig.SMOA_BACKEND_BASE_URL.isNotBlank()) return true + if (BuildConfig.SMOA_CONFIG_URL.isNotBlank()) return true + if (store.getBackendBaseUrl()?.isNotBlank() == true) return true + return false + } + + fun getSyncApiOrNull(): Pair? { + val r = retrofitOrNull() ?: return null + return Pair(r.create(BackendSyncApiService::class.java), effectiveApiKey()) + } + + fun getPullApiOrNull(): Pair? { + val r = retrofitOrNull() ?: return null + return Pair(r.create(BackendPullApiService::class.java), effectiveApiKey()) + } + + private fun retrofitOrNull(): Retrofit? { + val base = effectiveBaseUrl() ?: return null + synchronized(lock) { + if (cachedBaseUrl != base || retrofit == null) { + val built = try { + Retrofit.Builder() + .baseUrl(base) + .client(httpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } catch (_: IllegalArgumentException) { + null + } + if (built != null) { + retrofit = built + cachedBaseUrl = base + } else { + retrofit = null + cachedBaseUrl = null + } + } + return retrofit + } + } + + private fun effectiveBaseUrl(): String? { + val fromStore = store.getBackendBaseUrl()?.trim()?.takeIf { it.isNotEmpty() } + val raw = (fromStore ?: BuildConfig.SMOA_BACKEND_BASE_URL.trim()) + if (raw.isEmpty()) return null + return if (raw.endsWith("/")) raw else "$raw/" + } + + private fun effectiveApiKey(): String? = + store.getApiKey()?.trim()?.takeIf { it.isNotEmpty() } + ?: BuildConfig.SMOA_API_KEY.trim().takeIf { it.isNotEmpty() } +} diff --git a/app/src/main/java/com/smoa/auth/AuthPreferencesStore.kt b/app/src/main/java/com/smoa/auth/AuthPreferencesStore.kt new file mode 100644 index 0000000..60b4cd5 --- /dev/null +++ b/app/src/main/java/com/smoa/auth/AuthPreferencesStore.kt @@ -0,0 +1,41 @@ +package com.smoa.auth + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Persists onboarding and lightweight auth bootstrap flags (encrypted at rest). + */ +@Singleton +class AuthPreferencesStore @Inject constructor( + @ApplicationContext context: Context +) { + private val prefs by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + fun isOnboardingComplete(): Boolean = + prefs.getBoolean(KEY_ONBOARDING_DONE, false) + + fun setOnboardingComplete() { + prefs.edit().putBoolean(KEY_ONBOARDING_DONE, true).apply() + } + + companion object { + private const val PREFS_NAME = "smoa_auth_bootstrap" + private const val KEY_ONBOARDING_DONE = "onboarding_complete" + } +} diff --git a/app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt b/app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt new file mode 100644 index 0000000..56f7441 --- /dev/null +++ b/app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt @@ -0,0 +1,133 @@ +package com.smoa.auth + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity + +/** + * Runs [BiometricPrompt] for mandatory biometric verification during sign-in, + * or biometric **or** device credential (PIN/pattern) when [useDeviceCredentialFallback] is true. + */ +class BiometricAuthenticator( + private val activity: FragmentActivity +) { + + fun canAuthenticateWithBiometrics(): Int { + return BiometricManager.from(activity).canAuthenticate( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK + ) + } + + fun canAuthenticateWithDeviceCredential(): Int { + return BiometricManager.from(activity).canAuthenticate( + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + } + + fun authenticate( + onSuccess: () -> Unit, + onError: (message: String) -> Unit, + onCancelled: () -> Unit, + useDeviceCredentialFallback: Boolean = false + ) { + val combined = BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + val biometricOnly = BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK + val allowed = if (useDeviceCredentialFallback) combined else biometricOnly + val executor = ContextCompat.getMainExecutor(activity) + val prompt = BiometricPrompt( + activity, + executor, + 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 -> onCancelled() + else -> onError(errString.toString()) + } + } + + override fun onAuthenticationFailed() { + onError(activity.getString(com.smoa.R.string.auth_biometric_failed)) + } + } + ) + val infoBuilder = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(com.smoa.R.string.auth_biometric_required)) + .setSubtitle(activity.getString(com.smoa.R.string.auth_biometric_subtitle)) + .setAllowedAuthenticators(allowed) + if (!useDeviceCredentialFallback) { + infoBuilder.setNegativeButtonText(activity.getString(com.smoa.R.string.cancel)) + } + prompt.authenticate(infoBuilder.build()) + } + + /** + * Session unlock: PIN/pattern or biometric (no negative button — required by platform when device credential is allowed). + */ + fun authenticateForSessionUnlock( + onSuccess: () -> Unit, + onError: (message: String) -> Unit, + onCancelled: () -> Unit + ) { + authenticate( + onSuccess = onSuccess, + onError = onError, + onCancelled = onCancelled, + useDeviceCredentialFallback = true + ) + } + + fun authenticateWithCryptoObject( + cryptoObject: BiometricPrompt.CryptoObject, + onSuccess: (BiometricPrompt.AuthenticationResult) -> Unit, + onError: (message: String) -> Unit, + onCancelled: () -> Unit, + useDeviceCredentialFallback: Boolean = false + ) { + val combined = BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + val biometricOnly = BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.BIOMETRIC_WEAK + val allowed = if (useDeviceCredentialFallback) combined else biometricOnly + val executor = ContextCompat.getMainExecutor(activity) + val prompt = BiometricPrompt( + activity, + executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onSuccess(result) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + when (errorCode) { + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> onCancelled() + else -> onError(errString.toString()) + } + } + + override fun onAuthenticationFailed() { + onError(activity.getString(com.smoa.R.string.auth_biometric_failed)) + } + } + ) + val infoBuilder = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(com.smoa.R.string.auth_biometric_required)) + .setSubtitle(activity.getString(com.smoa.R.string.auth_biometric_subtitle)) + .setAllowedAuthenticators(allowed) + if (!useDeviceCredentialFallback) { + infoBuilder.setNegativeButtonText(activity.getString(com.smoa.R.string.cancel)) + } + prompt.authenticate(infoBuilder.build(), cryptoObject) + } +} diff --git a/app/src/main/java/com/smoa/config/ClientConfigFetcher.kt b/app/src/main/java/com/smoa/config/ClientConfigFetcher.kt new file mode 100644 index 0000000..0cf2d1f --- /dev/null +++ b/app/src/main/java/com/smoa/config/ClientConfigFetcher.kt @@ -0,0 +1,29 @@ +package com.smoa.config + +import com.google.gson.Gson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ClientConfigFetcher @Inject constructor( + private val httpClient: OkHttpClient +) { + private val gson = Gson() + + suspend fun fetch(configUrl: String): SmoaClientConfigJson = withContext(Dispatchers.IO) { + val req = Request.Builder().url(configUrl).get().build() + httpClient.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) { + throw IOException("Client config HTTP ${resp.code}") + } + val body = resp.body?.string() ?: throw IOException("Client config empty body") + gson.fromJson(body, SmoaClientConfigJson::class.java) + ?: throw IOException("Client config JSON parse failed") + } + } +} diff --git a/app/src/main/java/com/smoa/config/ClientConfigRefreshCoordinator.kt b/app/src/main/java/com/smoa/config/ClientConfigRefreshCoordinator.kt new file mode 100644 index 0000000..aaac2c9 --- /dev/null +++ b/app/src/main/java/com/smoa/config/ClientConfigRefreshCoordinator.kt @@ -0,0 +1,49 @@ +package com.smoa.config + +import com.smoa.BuildConfig +import com.smoa.modules.communications.domain.InfrastructureManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Fetches hosted client config without blocking the main thread. + * [com.smoa.api.SyncRetrofitHolder] picks up new URLs from [RemoteEndpointStore] on the next HTTP call. + */ +@Singleton +class ClientConfigRefreshCoordinator @Inject constructor( + private val fetcher: ClientConfigFetcher, + private val store: RemoteEndpointStore, + private val infrastructureManager: InfrastructureManager +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + fun scheduleNonBlockingInitialLoad(timeoutMs: Long = 5_000L) { + val url = BuildConfig.SMOA_CONFIG_URL.trim() + if (url.isEmpty()) return + scope.launch { + runCatching { + withTimeout(timeoutMs) { + fetcher.fetch(url) + } + }.onSuccess { cfg -> + store.applyRemoteConfig(cfg) + applyInfrastructure(cfg) + } + } + } + + private fun applyInfrastructure(cfg: SmoaClientConfigJson) { + cfg.stunUrls?.splitCommaList()?.takeIf { it.isNotEmpty() } + ?.let { infrastructureManager.setStunEndpoints(it) } + cfg.signalingUrls?.splitCommaList()?.takeIf { it.isNotEmpty() } + ?.let { infrastructureManager.setSignalingEndpoints(it) } + } + + private fun String.splitCommaList(): List = + split(',').map { it.trim() }.filter { it.isNotEmpty() } +} diff --git a/app/src/main/java/com/smoa/config/RemoteEndpointStore.kt b/app/src/main/java/com/smoa/config/RemoteEndpointStore.kt new file mode 100644 index 0000000..7849f42 --- /dev/null +++ b/app/src/main/java/com/smoa/config/RemoteEndpointStore.kt @@ -0,0 +1,72 @@ +package com.smoa.config + +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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Persists optional overrides from hosted [SmoaClientConfigJson]; [com.smoa.di.AppModule] merges with [com.smoa.BuildConfig]. + */ +@Singleton +class RemoteEndpointStore @Inject constructor( + @ApplicationContext private val context: Context +) { + private val _configEpoch = MutableStateFlow(0L) + val configEpoch: StateFlow = _configEpoch.asStateFlow() + + private val prefs: SharedPreferences by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + fun getBackendBaseUrl(): String? = prefs.getString(KEY_BACKEND, null) + + fun getApiKey(): String? = prefs.getString(KEY_API_KEY, null) + + fun getTlsPinSpec(): String? = prefs.getString(KEY_TLS_PIN_SPEC, null) + + fun getClassificationWatermarkPrimary(): String? = + prefs.getString(KEY_CLASS_PRIMARY, null)?.trim()?.takeIf { it.isNotEmpty() } + + fun getClassificationWatermarkSecondary(): String? = + prefs.getString(KEY_CLASS_SECONDARY, null)?.trim()?.takeIf { it.isNotEmpty() } + + fun applyRemoteConfig(cfg: SmoaClientConfigJson) { + val e = prefs.edit() + cfg.apiBaseUrl?.trim()?.takeIf { it.isNotEmpty() }?.let { e.putString(KEY_BACKEND, it) } + cfg.apiKey?.trim()?.takeIf { it.isNotEmpty() }?.let { e.putString(KEY_API_KEY, it) } + cfg.tlsPinSpec?.trim()?.takeIf { it.isNotEmpty() }?.let { e.putString(KEY_TLS_PIN_SPEC, it) } + cfg.classificationWatermarkPrimary?.trim()?.takeIf { it.isNotEmpty() } + ?.let { e.putString(KEY_CLASS_PRIMARY, it) } + cfg.classificationWatermarkSecondary?.trim()?.takeIf { it.isNotEmpty() } + ?.let { e.putString(KEY_CLASS_SECONDARY, it) } + e.putLong(KEY_UPDATED_AT, System.currentTimeMillis()) + e.apply() + _configEpoch.value = System.currentTimeMillis() + } + + companion object { + private const val PREFS_NAME = "smoa_remote_endpoints" + private const val KEY_BACKEND = "backend_base_url" + private const val KEY_API_KEY = "api_key" + private const val KEY_UPDATED_AT = "config_updated_at" + private const val KEY_TLS_PIN_SPEC = "tls_pin_spec" + private const val KEY_CLASS_PRIMARY = "classification_watermark_primary" + private const val KEY_CLASS_SECONDARY = "classification_watermark_secondary" + } +} diff --git a/app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt b/app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt new file mode 100644 index 0000000..464c077 --- /dev/null +++ b/app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt @@ -0,0 +1,22 @@ +package com.smoa.config + +import com.google.gson.annotations.SerializedName + +/** + * Parsed body of hosted client config JSON (see `backend/docs/examples/smoa-client-config.example.json`). + */ +data class SmoaClientConfigJson( + @SerializedName("schema_version") val schemaVersion: String? = null, + @SerializedName("environment") val environment: String? = null, + @SerializedName("api_base_url") val apiBaseUrl: String? = null, + @SerializedName("api_key") val apiKey: String? = null, + @SerializedName("stun_urls") val stunUrls: String? = null, + @SerializedName("signaling_urls") val signalingUrls: String? = null, + @SerializedName("turn_urls") val turnUrls: String? = null, + @SerializedName("well_known_health_url") val wellKnownHealthUrl: String? = null, + @SerializedName("openapi_url") val openapiUrl: String? = null, + /** Optional multi-host pin spec: `host|sha256/a,sha256/b;host2|sha256/c` (see [com.smoa.network.CertificatePinnerFactory]). */ + @SerializedName("tls_pin_spec") val tlsPinSpec: String? = null, + @SerializedName("classification_watermark_primary") val classificationWatermarkPrimary: String? = null, + @SerializedName("classification_watermark_secondary") val classificationWatermarkSecondary: String? = null +) diff --git a/app/src/main/java/com/smoa/di/AppModule.kt b/app/src/main/java/com/smoa/di/AppModule.kt index 903e1bf..f5adf67 100644 --- a/app/src/main/java/com/smoa/di/AppModule.kt +++ b/app/src/main/java/com/smoa/di/AppModule.kt @@ -1,9 +1,8 @@ package com.smoa.di import com.smoa.api.BackendPullAPI -import com.smoa.api.BackendPullApiService import com.smoa.api.BackendSyncAPI -import com.smoa.api.BackendSyncApiService +import com.smoa.api.SyncRetrofitHolder import com.smoa.core.common.DefaultPullAPI import com.smoa.core.common.DefaultSyncAPI import com.smoa.core.common.PullAPI @@ -12,16 +11,14 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import okhttp3.OkHttpClient -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import java.util.concurrent.TimeUnit import javax.inject.Singleton /** * App-level bindings. Provides SyncAPI and PullAPI for SyncService. - * When SMOA_BACKEND_BASE_URL is set, backend implementations are used. + * When backend networking is enabled (see [SyncRetrofitHolder.shouldEnableBackendNetworking]), uses [SyncRetrofitHolder] + * for a shared [okhttp3.OkHttpClient] and Retrofit instances that refresh when the effective base URL changes. * Build with -Psmoa.backend.baseUrl=http://10.0.2.2:8080/ and -Psmoa.api.key=key for emulator. + * Optional: -Psmoa.config.url=https://config.example.com/smoa/client-config.json */ @Module @InstallIn(SingletonComponent::class) @@ -29,35 +26,15 @@ object AppModule { @Provides @Singleton - fun provideSyncAPI(): SyncAPI { - val (baseUrl, apiKey, retrofit) = createRetrofitIfConfigured() ?: return DefaultSyncAPI() - val service = retrofit.create(BackendSyncApiService::class.java) - return BackendSyncAPI(baseUrl, service, apiKey = apiKey) + fun provideSyncAPI(holder: SyncRetrofitHolder): SyncAPI { + if (!holder.shouldEnableBackendNetworking()) return DefaultSyncAPI() + return BackendSyncAPI { holder.getSyncApiOrNull() } } @Provides @Singleton - fun providePullAPI(): PullAPI { - val (_, apiKey, retrofit) = createRetrofitIfConfigured() ?: return DefaultPullAPI() - val service = retrofit.create(BackendPullApiService::class.java) - return BackendPullAPI(service, apiKey = apiKey) - } - - private fun createRetrofitIfConfigured(): Triple? { - val raw = com.smoa.BuildConfig.SMOA_BACKEND_BASE_URL.trim() - val baseUrl = if (raw.isEmpty()) null else (if (raw.endsWith("/")) raw else "$raw/") - val apiKey = com.smoa.BuildConfig.SMOA_API_KEY?.trim()?.takeIf { it.isNotEmpty() } - if (baseUrl == null) return null - val client = OkHttpClient.Builder() - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build() - val retrofit = Retrofit.Builder() - .baseUrl(baseUrl) - .client(client) - .addConverterFactory(GsonConverterFactory.create()) - .build() - return Triple(baseUrl, apiKey, retrofit) + fun providePullAPI(holder: SyncRetrofitHolder): PullAPI { + if (!holder.shouldEnableBackendNetworking()) return DefaultPullAPI() + return BackendPullAPI { holder.getPullApiOrNull() } } } diff --git a/app/src/main/java/com/smoa/di/NetworkModule.kt b/app/src/main/java/com/smoa/di/NetworkModule.kt new file mode 100644 index 0000000..2dc60b8 --- /dev/null +++ b/app/src/main/java/com/smoa/di/NetworkModule.kt @@ -0,0 +1,57 @@ +package com.smoa.di + +import com.smoa.BuildConfig +import com.smoa.config.RemoteEndpointStore +import com.smoa.network.AuthTokenInterceptor +import com.smoa.network.CertificatePinnerFactory +import com.smoa.network.NetworkPinningConfig +import com.smoa.network.TokenRefreshAuthenticator +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + @Named("unauthenticated") + fun provideUnauthenticatedOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + } + + @Provides + @Singleton + fun provideOkHttpClient( + authTokenInterceptor: AuthTokenInterceptor, + tokenRefreshAuthenticator: TokenRefreshAuthenticator, + remoteEndpointStore: RemoteEndpointStore + ): OkHttpClient { + val builder = OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .addInterceptor(authTokenInterceptor) + .authenticator(tokenRefreshAuthenticator) + val pinSpec = remoteEndpointStore.getTlsPinSpec()?.trim()?.takeIf { it.isNotEmpty() } + ?: BuildConfig.SMOA_TLS_PIN_SPEC.trim().takeIf { it.isNotEmpty() } + val pinner = if (!pinSpec.isNullOrEmpty()) { + CertificatePinnerFactory.buildFromMultiSpec(pinSpec) + } else { + val host = NetworkPinningConfig.resolveBackendHost(remoteEndpointStore) + CertificatePinnerFactory.build(host, BuildConfig.SMOA_TLS_PINS) + } + pinner?.let { builder.certificatePinner(it) } + return builder.build() + } +} diff --git a/app/src/main/java/com/smoa/di/SmoaRemoteConfigEntryPoint.kt b/app/src/main/java/com/smoa/di/SmoaRemoteConfigEntryPoint.kt new file mode 100644 index 0000000..e8b19f7 --- /dev/null +++ b/app/src/main/java/com/smoa/di/SmoaRemoteConfigEntryPoint.kt @@ -0,0 +1,12 @@ +package com.smoa.di + +import com.smoa.config.RemoteEndpointStore +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface SmoaRemoteConfigEntryPoint { + fun remoteEndpointStore(): RemoteEndpointStore +} diff --git a/app/src/main/java/com/smoa/di/SyncRemoteDataModule.kt b/app/src/main/java/com/smoa/di/SyncRemoteDataModule.kt new file mode 100644 index 0000000..f3ba7e5 --- /dev/null +++ b/app/src/main/java/com/smoa/di/SyncRemoteDataModule.kt @@ -0,0 +1,48 @@ +package com.smoa.di + +import android.content.Context +import androidx.room.Room +import com.smoa.core.common.sync.SyncRemoteDataCachePort +import com.smoa.core.security.EncryptedDatabaseHelper +import com.smoa.modules.credentials.data.CredentialCacheMerger +import com.smoa.sync.CompositeSyncRemoteDataCache +import com.smoa.sync.SyncConflictSnapshotDao +import com.smoa.sync.SyncConflictSnapshotDatabase +import com.smoa.sync.SyncConflictSnapshotStore +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 SyncRemoteDataModule { + + @Provides + @Singleton + fun provideSyncConflictSnapshotDatabase( + @ApplicationContext context: Context, + encryptedDatabaseHelper: EncryptedDatabaseHelper + ): SyncConflictSnapshotDatabase { + val factory = encryptedDatabaseHelper.createOpenHelperFactory("sync_conflict_snapshot_db") + return Room.databaseBuilder( + context, + SyncConflictSnapshotDatabase::class.java, + "sync_conflict_snapshot_db" + ) + .openHelperFactory(factory) + .build() + } + + @Provides + fun provideSyncConflictSnapshotDao(db: SyncConflictSnapshotDatabase): SyncConflictSnapshotDao = db.dao() + + @Provides + @Singleton + fun provideSyncRemoteDataCachePort( + merger: CredentialCacheMerger, + snapshots: SyncConflictSnapshotStore + ): SyncRemoteDataCachePort = CompositeSyncRemoteDataCache(merger, snapshots) +} diff --git a/app/src/main/java/com/smoa/network/AuthTokenInterceptor.kt b/app/src/main/java/com/smoa/network/AuthTokenInterceptor.kt new file mode 100644 index 0000000..b423b27 --- /dev/null +++ b/app/src/main/java/com/smoa/network/AuthTokenInterceptor.kt @@ -0,0 +1,24 @@ +package com.smoa.network + +import com.smoa.security.SecureTokenStore +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +/** + * Attaches `Authorization: Bearer` when an OIDC/access token is present in [SecureTokenStore]. + * API-key flows (X-API-Key) remain unchanged when no bearer token is stored. + */ +class AuthTokenInterceptor @Inject constructor( + private val secureTokenStore: SecureTokenStore +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val token = secureTokenStore.peekAccessToken()?.trim()?.takeIf { it.isNotEmpty() } + ?: return chain.proceed(chain.request()) + val request = chain.request().newBuilder() + .header("Authorization", "Bearer $token") + .build() + return chain.proceed(request) + } +} diff --git a/app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt b/app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt new file mode 100644 index 0000000..9f8cebd --- /dev/null +++ b/app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt @@ -0,0 +1,53 @@ +package com.smoa.network + +import okhttp3.CertificatePinner + +/** + * Builds OkHttp [CertificatePinner] from comma-separated SHA-256 pins (`sha256/...` base64) + * for a single host, or from a multi-host spec. + * + * Multi-host format (semicolon separates hosts): `hostname|sha256/a,sha256/b;other.host|sha256/c` + */ +object CertificatePinnerFactory { + + fun build(hostname: String?, pinsCsv: String): CertificatePinner? { + val host = hostname?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val pins = parsePinList(pinsCsv) ?: return null + return buildForHostPins(mapOf(host to pins)) + } + + /** + * Parses [spec] as `host|pin1,pin2;host2|pin3` (pins are comma-separated, hosts are semicolon-separated). + */ + fun buildFromMultiSpec(spec: String): CertificatePinner? { + val trimmed = spec.trim() + if (trimmed.isEmpty()) return null + val segments = trimmed.split(';').map { it.trim() }.filter { it.isNotEmpty() } + val map = linkedMapOf>() + for (seg in segments) { + val pipe = seg.indexOf('|') + if (pipe <= 0 || pipe >= seg.lastIndex) return null + val host = seg.substring(0, pipe).trim().lowercase() + val pinPart = seg.substring(pipe + 1) + val pins = parsePinList(pinPart) ?: return null + map[host] = pins + } + if (map.isEmpty()) return null + return buildForHostPins(map) + } + + private fun parsePinList(pinsCsv: String): List? { + val pins = pinsCsv.split(',') + .map { it.trim() } + .filter { it.startsWith("sha256/") && it.length > 10 } + return pins.takeIf { it.isNotEmpty() } + } + + private fun buildForHostPins(hostToPins: Map>): CertificatePinner { + val builder = CertificatePinner.Builder() + hostToPins.forEach { (host, pins) -> + pins.forEach { pin -> builder.add(host, pin) } + } + return builder.build() + } +} diff --git a/app/src/main/java/com/smoa/network/NetworkPinningConfig.kt b/app/src/main/java/com/smoa/network/NetworkPinningConfig.kt new file mode 100644 index 0000000..ff84166 --- /dev/null +++ b/app/src/main/java/com/smoa/network/NetworkPinningConfig.kt @@ -0,0 +1,31 @@ +package com.smoa.network + +import android.net.Uri +import com.smoa.BuildConfig +import com.smoa.config.RemoteEndpointStore + +/** + * Resolves the API host used for [okhttp3.CertificatePinner] from [RemoteEndpointStore] (after hosted config) + * or [BuildConfig.SMOA_BACKEND_BASE_URL]. + */ +object NetworkPinningConfig { + + fun resolveBackendHost(store: RemoteEndpointStore): String? { + store.getBackendBaseUrl()?.trim()?.takeIf { it.isNotEmpty() }?.let { url -> + hostFromUrl(url)?.let { return it } + } + return resolveBackendHostFromBuildConfig() + } + + fun resolveBackendHostFromBuildConfig(): String? { + val raw = BuildConfig.SMOA_BACKEND_BASE_URL.trim() + if (raw.isEmpty()) return null + return hostFromUrl(raw) + } + + private fun hostFromUrl(raw: String): String? = try { + Uri.parse(raw).host?.lowercase() + } catch (_: Exception) { + null + } +} diff --git a/app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt b/app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt new file mode 100644 index 0000000..6ba4604 --- /dev/null +++ b/app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt @@ -0,0 +1,99 @@ +package com.smoa.network + +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.smoa.security.OidcBootstrap +import com.smoa.security.SecureTokenStore +import okhttp3.Authenticator +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +private const val RETRY_HEADER = "X-Smoa-Auth-Retry" + +private data class RefreshTokenJsonResponse( + @SerializedName("access_token") val accessToken: String?, + @SerializedName("refresh_token") val refreshToken: String?, + @SerializedName("expires_in") val expiresIn: Long? +) + +/** + * On HTTP 401, exchanges [SecureTokenStore] refresh token at the OIDC token endpoint (no interceptor loop). + */ +@Singleton +class TokenRefreshAuthenticator @Inject constructor( + private val secureTokenStore: SecureTokenStore, + @Named("unauthenticated") private val rawClient: OkHttpClient +) : Authenticator { + + private val gson = Gson() + private val refreshLock = Any() + + override fun authenticate(route: Route?, response: Response): Request? { + if (response.request.header(RETRY_HEADER) != null) return null + if (responseCount(response) > 2) return null + val path = response.request.url.encodedPath + if (path.contains("/token") || path.endsWith("/oauth/token")) return null + + val refresh = secureTokenStore.peekRefreshToken()?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val tokenEndpoint = secureTokenStore.peekTokenEndpoint()?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val clientId = OidcBootstrap.clientId().ifEmpty { secureTokenStore.peekOidcClientId() ?: "" } + if (clientId.isEmpty()) return null + + synchronized(refreshLock) { + val bearer = response.request.header("Authorization") + val failedAccess = bearer?.removePrefix("Bearer ")?.trim() + val current = secureTokenStore.peekAccessToken() + if (failedAccess != null && current != null && current != failedAccess) { + return response.request.newBuilder() + .removeHeader("Authorization") + .header(RETRY_HEADER, "1") + .build() + } + val newTokens = runCatching { performRefresh(tokenEndpoint, clientId, refresh) }.getOrNull() + ?: return null + val access = newTokens.accessToken ?: return null + val exp = newTokens.expiresIn?.let { System.currentTimeMillis() / 1000 + it } + secureTokenStore.persistTokens( + accessToken = access, + refreshToken = newTokens.refreshToken ?: refresh, + idToken = secureTokenStore.peekIdToken(), + expiresAtEpochSeconds = exp + ) + } + + return response.request.newBuilder() + .header(RETRY_HEADER, "1") + .removeHeader("Authorization") + .build() + } + + private fun performRefresh(tokenEndpoint: String, clientId: String, refreshToken: String): RefreshTokenJsonResponse? { + val body = FormBody.Builder() + .add("grant_type", "refresh_token") + .add("refresh_token", refreshToken) + .add("client_id", clientId) + .build() + val req = Request.Builder().url(tokenEndpoint).post(body).build() + rawClient.newCall(req).execute().use { resp -> + val text = resp.body?.string() ?: return null + if (!resp.isSuccessful) return null + return gson.fromJson(text, RefreshTokenJsonResponse::class.java) + } + } + + private fun responseCount(response: Response): Int { + var n = 1 + var p = response.priorResponse + while (p != null) { + n++ + p = p.priorResponse + } + return n + } +} diff --git a/app/src/main/java/com/smoa/security/BiometricSecretsVault.kt b/app/src/main/java/com/smoa/security/BiometricSecretsVault.kt new file mode 100644 index 0000000..ad39a00 --- /dev/null +++ b/app/src/main/java/com/smoa/security/BiometricSecretsVault.kt @@ -0,0 +1,66 @@ +package com.smoa.security + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import androidx.biometric.BiometricPrompt +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Hardware-backed AES key marked **user authentication required** for wrapping high-value secrets + * (e.g. refresh token) with biometric / device credential gates. + * + * Use [tryCreateEncryptCryptoObject] with [androidx.biometric.BiometricPrompt.authenticate] to gate + * encrypt/decrypt (e.g. wrapping a refresh token) behind the same user authentication required by the key. + */ +@Singleton +class BiometricSecretsVault @Inject constructor() { + + private val keyStore: KeyStore by lazy { + KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + } + + fun hasKey(): Boolean = keyStore.containsAlias(KEY_ALIAS) + + fun getOrCreateSecretKey(): SecretKey { + if (hasKey()) { + return (keyStore.getEntry(KEY_ALIAS, null) as KeyStore.SecretKeyEntry).secretKey + } + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + val spec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setUserAuthenticationRequired(true) + .setInvalidatedByBiometricEnrollment(true) + .build() + generator.init(spec) + return generator.generateKey() + } + + /** + * Prepares AES-GCM encrypt [BiometricPrompt.CryptoObject] for the keystore key (user auth required). + * Returns null if the cipher cannot be initialized (e.g. key needs enrollment). + */ + fun tryCreateEncryptCryptoObject(): BiometricPrompt.CryptoObject? { + return try { + val key = getOrCreateSecretKey() + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + BiometricPrompt.CryptoObject(cipher) + } catch (_: Exception) { + null + } + } + + companion object { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS = "smoa_biometric_wrap_aes" + } +} diff --git a/app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt b/app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt new file mode 100644 index 0000000..460a114 --- /dev/null +++ b/app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt @@ -0,0 +1,25 @@ +package com.smoa.security + +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Best-effort detection of Samsung Knox / enterprise SDK on classpath without bundling proprietary AARs. + * For full MDM policies, integrate Samsung Knox SDK or your UEM vendor agent per deployment guide. + */ +@Singleton +class KnoxEnterpriseProbe @Inject constructor() { + + fun isKnoxSdkOnClasspath(): Boolean = try { + Class.forName("com.samsung.android.knox.EnterpriseDeviceManager") + true + } catch (_: ClassNotFoundException) { + false + } + + fun summary(): String = if (isKnoxSdkOnClasspath()) { + "Knox EnterpriseDeviceManager class found (device/agent may expose Knox APIs)." + } else { + "Knox SDK classes not on classpath (typical for generic AOSP / non-Samsung builds)." + } +} diff --git a/app/src/main/java/com/smoa/security/OidcAuthStateHolder.kt b/app/src/main/java/com/smoa/security/OidcAuthStateHolder.kt new file mode 100644 index 0000000..7c5271c --- /dev/null +++ b/app/src/main/java/com/smoa/security/OidcAuthStateHolder.kt @@ -0,0 +1,29 @@ +package com.smoa.security + +import net.openid.appauth.AuthorizationServiceConfiguration +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OidcAuthStateHolder @Inject constructor() { + + @Volatile + private var pending: AuthorizationServiceConfiguration? = null + + @Synchronized + fun putConfiguration(configuration: AuthorizationServiceConfiguration) { + pending = configuration + } + + @Synchronized + fun takeConfiguration(): AuthorizationServiceConfiguration? { + val c = pending + pending = null + return c + } + + @Synchronized + fun clear() { + pending = null + } +} diff --git a/app/src/main/java/com/smoa/security/OidcBootstrap.kt b/app/src/main/java/com/smoa/security/OidcBootstrap.kt new file mode 100644 index 0000000..9131abc --- /dev/null +++ b/app/src/main/java/com/smoa/security/OidcBootstrap.kt @@ -0,0 +1,24 @@ +package com.smoa.security + +import com.smoa.BuildConfig + +/** + * Build-time OIDC/OAuth hints for IdP integration (AppAuth, Custom Tabs, or enterprise SSO WebView). + */ +object OidcBootstrap { + + fun issuer(): String = BuildConfig.SMOA_OIDC_ISSUER.trim() + + fun clientId(): String = BuildConfig.SMOA_OIDC_CLIENT_ID.trim() + + fun redirectUri(): String = BuildConfig.SMOA_OIDC_REDIRECT_URI.trim() + + fun discoveryUrl(): String? { + val iss = issuer() + if (iss.isEmpty()) return null + val base = iss.trimEnd('/') + return "$base/.well-known/openid-configuration" + } + + fun isConfigured(): Boolean = issuer().isNotEmpty() && clientId().isNotEmpty() && redirectUri().isNotEmpty() +} diff --git a/app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt b/app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt new file mode 100644 index 0000000..86a5eed --- /dev/null +++ b/app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt @@ -0,0 +1,186 @@ +package com.smoa.security + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Base64 +import androidx.fragment.app.FragmentActivity +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.smoa.core.auth.RBACFramework +import com.smoa.core.auth.UserInfo +import com.smoa.core.auth.UserSession +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenResponse +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +@Singleton +class OidcLoginCoordinator @Inject constructor( + @ApplicationContext private val appContext: Context, + private val oidcAuthStateHolder: OidcAuthStateHolder, + private val secureTokenStore: SecureTokenStore, + private val userSession: UserSession +) { + + private val authServiceLock = Any() + private var pendingAuthService: AuthorizationService? = null + + fun cancelPendingFlow() { + synchronized(authServiceLock) { + pendingAuthService?.dispose() + pendingAuthService = null + } + oidcAuthStateHolder.clear() + } + + suspend fun prepareAuthorizationIntent(): Intent { + if (!OidcBootstrap.isConfigured()) { + error("OIDC is not configured (issuer, client id, redirect URI).") + } + val config = withContext(Dispatchers.IO) { fetchDiscovery() } + return withContext(Dispatchers.Main) { + synchronized(authServiceLock) { + pendingAuthService?.dispose() + val service = AuthorizationService(appContext).also { pendingAuthService = it } + oidcAuthStateHolder.putConfiguration(config) + val redirect = Uri.parse(OidcBootstrap.redirectUri()) + val request = AuthorizationRequest.Builder( + config, + OidcBootstrap.clientId(), + ResponseTypeValues.CODE, + redirect + ) + .setScope("openid profile email offline_access") + .build() + service.getAuthorizationRequestIntent(request) + } + } + } + + suspend fun finishAuthorization(@Suppress("UNUSED_PARAMETER") activity: FragmentActivity, data: Intent) = + withContext(Dispatchers.IO) { + val response = AuthorizationResponse.fromIntent(data) + val ex = AuthorizationException.fromIntent(data) + if (ex != null) { + cancelPendingFlow() + throw IllegalStateException(ex.error ?: "authorization_failed") + } + if (response == null) { + cancelPendingFlow() + throw IllegalStateException("missing_authorization_response") + } + val config = oidcAuthStateHolder.takeConfiguration() + ?: run { + cancelPendingFlow() + throw IllegalStateException("missing_oidc_configuration") + } + val service = synchronized(authServiceLock) { + pendingAuthService ?: AuthorizationService(appContext) + } + try { + val tokenResponse = suspendCancellableCoroutine { cont -> + val exchange = response.createTokenExchangeRequest() + service.performTokenRequest(exchange) { resp: TokenResponse?, tex: AuthorizationException? -> + when { + resp != null -> cont.resume(resp) + tex != null -> cont.resumeWithException( + IllegalStateException(tex.error ?: "token_exchange_failed") + ) + else -> cont.resumeWithException(IllegalStateException("token_exchange_failed")) + } + } + } + val expSec = tokenResponse.accessTokenExpirationTime + secureTokenStore.persistTokens( + accessToken = tokenResponse.accessToken, + refreshToken = tokenResponse.refreshToken, + idToken = tokenResponse.idToken, + expiresAtEpochSeconds = expSec + ) + secureTokenStore.persistOidcEndpoints( + tokenEndpoint = config.tokenEndpoint.toString(), + clientId = OidcBootstrap.clientId().ifEmpty { null } + ) + val claims = parseIdTokenClaims(tokenResponse.idToken) + val sub = claims["sub"] ?: "oidc_user" + val display = claims["name"] ?: claims["preferred_username"] ?: sub + userSession.setUser( + UserInfo( + userId = sub, + userName = display, + role = RBACFramework.Role.OPERATOR, + unit = "OIDC", + clearanceLevel = null, + missionAssignment = null + ) + ) + } finally { + synchronized(authServiceLock) { + service.dispose() + pendingAuthService = null + } + } + } + + private suspend fun fetchDiscovery(): AuthorizationServiceConfiguration = + suspendCancellableCoroutine { cont -> + val issuer = Uri.parse(OidcBootstrap.issuer()) + AuthorizationServiceConfiguration.fetchFromIssuer( + issuer, + object : AuthorizationServiceConfiguration.RetrieveConfigurationCallback { + override fun onFetchConfigurationCompleted( + configuration: AuthorizationServiceConfiguration?, + authorizationException: AuthorizationException? + ) { + when { + configuration != null -> cont.resume(configuration) + authorizationException != null -> + cont.resumeWithException( + IllegalStateException( + authorizationException.error ?: "discovery_failed" + ) + ) + else -> cont.resumeWithException(IllegalStateException("discovery_failed")) + } + } + } + ) + } + + private fun parseIdTokenClaims(idToken: String?): Map { + if (idToken.isNullOrEmpty()) return emptyMap() + val parts = idToken.split('.') + if (parts.size < 2) return emptyMap() + return try { + val json = String( + Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP), + Charsets.UTF_8 + ) + val obj: JsonObject = JsonParser.parseString(json).asJsonObject + obj.keySet().associateWith { key -> + val el = obj.get(key) ?: return@associateWith "" + when { + el.isJsonPrimitive -> { + val p = el.asJsonPrimitive + if (p.isString) p.asString else p.toString() + } + else -> el.toString().trim('"') + } + } + } catch (_: Exception) { + emptyMap() + } + } +} diff --git a/app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt b/app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt new file mode 100644 index 0000000..2c636a2 --- /dev/null +++ b/app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt @@ -0,0 +1,49 @@ +package com.smoa.security + +import android.content.Context +import com.google.android.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.IntegrityTokenRequest +import com.smoa.BuildConfig +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.tasks.await +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Requests a Play Integrity token when [BuildConfig.SMOA_PLAY_INTEGRITY_CLOUD_PROJECT_NUMBER] is set. + * Send the full token to your backend for verification (Google Play Console API). + */ +@Singleton +class PlayIntegrityVerifier @Inject constructor( + @ApplicationContext private val context: Context +) { + + suspend fun requestIntegrityTokenOrNull(): String? { + val raw = BuildConfig.SMOA_PLAY_INTEGRITY_CLOUD_PROJECT_NUMBER.trim() + if (raw.isEmpty()) return null + val projectNumber = raw.toLongOrNull() ?: return null + return try { + val integrityManager = IntegrityManagerFactory.create(context) + val nonce = UUID.randomUUID().toString() + val request = IntegrityTokenRequest.builder() + .setCloudProjectNumber(projectNumber) + .setNonce(nonce) + .build() + val response = integrityManager.requestIntegrityToken(request).await() + response.token() + } catch (_: Exception) { + null + } + } + + suspend fun summarizeForDisplay(): String { + val token = requestIntegrityTokenOrNull() + ?: return if (BuildConfig.SMOA_PLAY_INTEGRITY_CLOUD_PROJECT_NUMBER.isBlank()) { + "Integrity: not configured (set smoa.playIntegrity.cloudProjectNumber)." + } else { + "Integrity: token request failed (emulator, sideload, or Play Services)." + } + return "Integrity: token length ${token.length} (send to server for verification)." + } +} diff --git a/app/src/main/java/com/smoa/security/SecureTokenStore.kt b/app/src/main/java/com/smoa/security/SecureTokenStore.kt new file mode 100644 index 0000000..cfa2b3f --- /dev/null +++ b/app/src/main/java/com/smoa/security/SecureTokenStore.kt @@ -0,0 +1,73 @@ +package com.smoa.security + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Encrypted storage for OIDC/OAuth access and refresh tokens (and optional id_token). + * Replace demo sign-in with real IdP flows, then persist tokens here. + */ +@Singleton +class SecureTokenStore @Inject constructor( + @ApplicationContext context: Context +) { + private val prefs by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + PREFS, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + fun peekAccessToken(): String? = prefs.getString(KEY_ACCESS, null) + fun peekRefreshToken(): String? = prefs.getString(KEY_REFRESH, null) + fun peekIdToken(): String? = prefs.getString(KEY_ID, null) + fun peekTokenEndpoint(): String? = prefs.getString(KEY_TOKEN_ENDPOINT, null) + fun peekOidcClientId(): String? = prefs.getString(KEY_OIDC_CLIENT_ID, null) + + fun persistTokens( + accessToken: String?, + refreshToken: String?, + idToken: String? = null, + expiresAtEpochSeconds: Long? = null + ) { + prefs.edit().apply { + if (accessToken != null) putString(KEY_ACCESS, accessToken) else remove(KEY_ACCESS) + if (refreshToken != null) putString(KEY_REFRESH, refreshToken) else remove(KEY_REFRESH) + if (idToken != null) putString(KEY_ID, idToken) else remove(KEY_ID) + if (expiresAtEpochSeconds != null) putLong(KEY_EXPIRES, expiresAtEpochSeconds) else remove(KEY_EXPIRES) + apply() + } + } + + fun persistOidcEndpoints(tokenEndpoint: String?, clientId: String?) { + prefs.edit().apply { + if (tokenEndpoint != null) putString(KEY_TOKEN_ENDPOINT, tokenEndpoint) else remove(KEY_TOKEN_ENDPOINT) + if (clientId != null) putString(KEY_OIDC_CLIENT_ID, clientId) else remove(KEY_OIDC_CLIENT_ID) + apply() + } + } + + fun clearAll() { + prefs.edit().clear().apply() + } + + companion object { + private const val PREFS = "smoa_oidc_tokens" + private const val KEY_ACCESS = "access_token" + private const val KEY_REFRESH = "refresh_token" + private const val KEY_ID = "id_token" + private const val KEY_EXPIRES = "expires_at_epoch" + private const val KEY_TOKEN_ENDPOINT = "token_endpoint" + private const val KEY_OIDC_CLIENT_ID = "oidc_client_id" + } +} diff --git a/app/src/main/java/com/smoa/security/SecurityStatusViewModel.kt b/app/src/main/java/com/smoa/security/SecurityStatusViewModel.kt new file mode 100644 index 0000000..e2fd12a --- /dev/null +++ b/app/src/main/java/com/smoa/security/SecurityStatusViewModel.kt @@ -0,0 +1,66 @@ +package com.smoa.security + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.smoa.BuildConfig +import com.smoa.config.RemoteEndpointStore +import com.smoa.network.NetworkPinningConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SecurityStatusViewModel @Inject constructor( + private val playIntegrityVerifier: PlayIntegrityVerifier, + private val knoxEnterpriseProbe: KnoxEnterpriseProbe, + private val secureTokenStore: SecureTokenStore, + private val biometricSecretsVault: BiometricSecretsVault, + private val remoteEndpointStore: RemoteEndpointStore +) : ViewModel() { + + private val _integrityLine = MutableStateFlow(null) + val integrityLine: StateFlow = _integrityLine.asStateFlow() + + val tlsPinningActive: Boolean + get() { + val multi = BuildConfig.SMOA_TLS_PIN_SPEC.isNotBlank() || + remoteEndpointStore.getTlsPinSpec()?.isNotBlank() == true + val single = BuildConfig.SMOA_TLS_PINS.isNotBlank() && + NetworkPinningConfig.resolveBackendHost(remoteEndpointStore) != null + return multi || single + } + + val oidcConfigured: Boolean get() = OidcBootstrap.isConfigured() + + val classificationBuildMarking: String get() = BuildConfig.SMOA_CLASSIFICATION_BUILD_MARKING + + val sessionTimeoutMinutes: Int get() = BuildConfig.SMOA_SESSION_TIMEOUT_MINUTES + + val knoxSummary: String get() = knoxEnterpriseProbe.summary() + + val bearerTokenPresent: Boolean get() = secureTokenStore.peekAccessToken() != null + + val biometricKeyReady: Boolean get() = biometricSecretsVault.hasKey() + + val oidcDiscoveryUrl: String? get() = OidcBootstrap.discoveryUrl() + + fun clearStoredTokens() { + secureTokenStore.clearAll() + } + + fun prepareBiometricKey() { + viewModelScope.launch(Dispatchers.IO) { + runCatching { biometricSecretsVault.getOrCreateSecretKey() } + } + } + + fun runIntegrityCheck() { + viewModelScope.launch { + _integrityLine.value = playIntegrityVerifier.summarizeForDisplay() + } + } +} diff --git a/app/src/main/java/com/smoa/security/SessionLockController.kt b/app/src/main/java/com/smoa/security/SessionLockController.kt new file mode 100644 index 0000000..504f7e3 --- /dev/null +++ b/app/src/main/java/com/smoa/security/SessionLockController.kt @@ -0,0 +1,76 @@ +package com.smoa.security + +import android.os.SystemClock +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.smoa.BuildConfig +import com.smoa.core.auth.UserSession +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Locks the UI after a period in background while a user session exists. Unlock with biometric (or device credential via policy). + * Set [BuildConfig.SMOA_SESSION_TIMEOUT_MINUTES] to `0` in Gradle to disable. + */ +@Singleton +class SessionLockController @Inject constructor( + private val userSession: UserSession +) : DefaultLifecycleObserver { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val started = AtomicBoolean(false) + private var backgroundElapsedRealtime: Long = 0L + + private val _locked = MutableStateFlow(false) + val locked: StateFlow = _locked.asStateFlow() + + private val timeoutMs: Long + get() { + val m = BuildConfig.SMOA_SESSION_TIMEOUT_MINUTES + return if (m <= 0) 0L else m * 60_000L + } + + fun start() { + if (!started.compareAndSet(false, true)) return + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + scope.launch { + userSession.currentUser.collect { user -> + if (user == null) { + _locked.value = false + backgroundElapsedRealtime = 0L + } + } + } + } + + override fun onStop(owner: LifecycleOwner) { + if (timeoutMs <= 0L) return + if (userSession.currentUser.value != null) { + backgroundElapsedRealtime = SystemClock.elapsedRealtime() + } + } + + override fun onStart(owner: LifecycleOwner) { + if (timeoutMs <= 0L) return + if (userSession.currentUser.value == null) return + if (backgroundElapsedRealtime == 0L) return + val elapsed = SystemClock.elapsedRealtime() - backgroundElapsedRealtime + if (SessionLockLogic.shouldLockAfterBackground(elapsed, timeoutMs, sessionActive = true)) { + _locked.value = true + } + backgroundElapsedRealtime = 0L + } + + fun unlockAfterAuthentication() { + _locked.value = false + } +} diff --git a/app/src/main/java/com/smoa/security/SessionLockLogic.kt b/app/src/main/java/com/smoa/security/SessionLockLogic.kt new file mode 100644 index 0000000..05a73d6 --- /dev/null +++ b/app/src/main/java/com/smoa/security/SessionLockLogic.kt @@ -0,0 +1,17 @@ +package com.smoa.security + +/** + * Pure helpers for background timeout policy (testable without Android lifecycle). + */ +object SessionLockLogic { + + fun shouldLockAfterBackground( + elapsedBackgroundMs: Long, + timeoutMs: Long, + sessionActive: Boolean + ): Boolean { + if (!sessionActive) return false + if (timeoutMs <= 0L) return false + return elapsedBackgroundMs >= timeoutMs + } +} diff --git a/app/src/main/java/com/smoa/sync/CompositeSyncRemoteDataCache.kt b/app/src/main/java/com/smoa/sync/CompositeSyncRemoteDataCache.kt new file mode 100644 index 0000000..bbf4506 --- /dev/null +++ b/app/src/main/java/com/smoa/sync/CompositeSyncRemoteDataCache.kt @@ -0,0 +1,34 @@ +package com.smoa.sync + +import com.smoa.core.common.sync.SyncRemoteDataCachePort +import com.smoa.modules.credentials.data.CredentialCacheMerger + +/** + * Credentials use structured Room ([CredentialCacheMerger]); other sync types store raw conflict JSON + * in [SyncConflictSnapshotStore] for offline inspection until feature-specific caches exist. + */ +class CompositeSyncRemoteDataCache( + private val credentialMerger: CredentialCacheMerger, + private val snapshots: SyncConflictSnapshotStore +) : SyncRemoteDataCachePort { + + override suspend fun mergeCredentialConflictRemoteData(json: ByteArray, itemId: String) { + credentialMerger.mergeCredentialConflictRemoteData(json, itemId) + } + + override suspend fun mergeOrderConflictRemoteData(json: ByteArray, itemId: String) { + snapshots.save(resourceType = "order", itemId = itemId, body = json) + } + + override suspend fun mergeEvidenceConflictRemoteData(json: ByteArray, itemId: String) { + snapshots.save(resourceType = "evidence", itemId = itemId, body = json) + } + + override suspend fun mergeDirectoryConflictRemoteData(json: ByteArray, itemId: String) { + snapshots.save(resourceType = "directory", itemId = itemId, body = json) + } + + override suspend fun mergeReportConflictRemoteData(json: ByteArray, itemId: String) { + snapshots.save(resourceType = "report", itemId = itemId, body = json) + } +} diff --git a/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDao.kt b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDao.kt new file mode 100644 index 0000000..3d9b192 --- /dev/null +++ b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDao.kt @@ -0,0 +1,33 @@ +package com.smoa.sync + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface SyncConflictSnapshotDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(row: SyncConflictSnapshotEntity) + + @Query( + "SELECT * FROM sync_conflict_snapshots WHERE resourceType = :resourceType AND itemId = :itemId LIMIT 1" + ) + suspend fun get(resourceType: String, itemId: String): SyncConflictSnapshotEntity? + + @Query("SELECT * FROM sync_conflict_snapshots ORDER BY savedAt DESC") + fun observeAll(): Flow> + + @Query( + "SELECT * FROM sync_conflict_snapshots WHERE resourceType = :resourceType ORDER BY savedAt DESC" + ) + fun observeByResourceType(resourceType: String): Flow> + + @Query("DELETE FROM sync_conflict_snapshots WHERE resourceType = :resourceType AND itemId = :itemId") + suspend fun delete(resourceType: String, itemId: String) + + @Query("DELETE FROM sync_conflict_snapshots") + suspend fun clearAll() +} diff --git a/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDatabase.kt b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDatabase.kt new file mode 100644 index 0000000..bee926b --- /dev/null +++ b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDatabase.kt @@ -0,0 +1,13 @@ +package com.smoa.sync + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [SyncConflictSnapshotEntity::class], + version = 1, + exportSchema = false +) +abstract class SyncConflictSnapshotDatabase : RoomDatabase() { + abstract fun dao(): SyncConflictSnapshotDao +} diff --git a/app/src/main/java/com/smoa/sync/SyncConflictSnapshotEntity.kt b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotEntity.kt new file mode 100644 index 0000000..87446b6 --- /dev/null +++ b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotEntity.kt @@ -0,0 +1,14 @@ +package com.smoa.sync + +import androidx.room.Entity + +@Entity( + tableName = "sync_conflict_snapshots", + primaryKeys = ["resourceType", "itemId"] +) +data class SyncConflictSnapshotEntity( + val resourceType: String, + val itemId: String, + val jsonUtf8: String, + val savedAt: Long +) diff --git a/app/src/main/java/com/smoa/sync/SyncConflictSnapshotRepository.kt b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotRepository.kt new file mode 100644 index 0000000..31346f7 --- /dev/null +++ b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotRepository.kt @@ -0,0 +1,39 @@ +package com.smoa.sync + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Read and manage persisted sync conflict snapshots (order, evidence, directory, report). + * Compose UI can collect [observeSnapshots] or [observeByResourceType] for settings / debug / recovery flows. + */ +@Singleton +class SyncConflictSnapshotRepository @Inject constructor( + private val dao: SyncConflictSnapshotDao +) { + + fun observeSnapshots(): Flow> = dao.observeAll() + + fun observeByResourceType(resourceType: String): Flow> = + dao.observeByResourceType(resourceType) + + suspend fun getSnapshot(resourceType: String, itemId: String): SyncConflictSnapshotEntity? = + withContext(Dispatchers.IO) { + dao.get(resourceType, itemId) + } + + suspend fun deleteSnapshot(resourceType: String, itemId: String) { + withContext(Dispatchers.IO) { + dao.delete(resourceType, itemId) + } + } + + suspend fun clearAll() { + withContext(Dispatchers.IO) { + dao.clearAll() + } + } +} diff --git a/app/src/main/java/com/smoa/sync/SyncConflictSnapshotStore.kt b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotStore.kt new file mode 100644 index 0000000..0612bfa --- /dev/null +++ b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotStore.kt @@ -0,0 +1,25 @@ +package com.smoa.sync + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SyncConflictSnapshotStore @Inject constructor( + private val dao: SyncConflictSnapshotDao +) { + + suspend fun save(resourceType: String, itemId: String, body: ByteArray) { + withContext(Dispatchers.IO) { + dao.upsert( + SyncConflictSnapshotEntity( + resourceType = resourceType, + itemId = itemId, + jsonUtf8 = body.toString(Charsets.UTF_8), + savedAt = System.currentTimeMillis() + ) + ) + } + } +} diff --git a/app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt b/app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt new file mode 100644 index 0000000..497e2cf --- /dev/null +++ b/app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt @@ -0,0 +1,410 @@ +package com.smoa.ui.auth + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.biometric.BiometricManager +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.fragment.app.FragmentActivity +import com.smoa.R +import com.smoa.auth.BiometricAuthenticator +import com.smoa.core.auth.RBACFramework +import com.smoa.core.auth.UserInfo +import com.smoa.core.auth.UserSession +import com.smoa.security.OidcBootstrap +import com.smoa.ui.components.ClassificationWatermark +import com.smoa.ui.theme.SmoaSpatial + +private sealed class SignInStep { + data object Landing : SignInStep() + data object Credentials : SignInStep() + data object Mfa : SignInStep() + data object Biometric : SignInStep() +} + +/** + * Onboarding, then landing → credentials → MFA → biometric before session is established. + */ +@Composable +fun AuthFlowHost( + activity: FragmentActivity, + userSession: UserSession, + windowSizeClass: WindowSizeClass, + oidcAvailable: Boolean = OidcBootstrap.isConfigured(), + onOrganizationSignIn: () -> Unit = {}, + modifier: Modifier = Modifier +) { + val padH = SmoaSpatial.horizontalPadding(windowSizeClass) + val padV = SmoaSpatial.verticalPadding(windowSizeClass) + var step by remember { mutableStateOf(SignInStep.Landing) } + var userId by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var mfaCode by remember { mutableStateOf("") } + var errorText by remember { mutableStateOf(null) } + var signInUsesDeviceCredential by remember { mutableStateOf(false) } + var biometricLaunchEpoch by remember { mutableStateOf(0) } + + val biometric = remember(activity) { BiometricAuthenticator(activity) } + + fun completeSignIn() { + val role = if (userId.equals("admin", ignoreCase = true)) { + RBACFramework.Role.ADMIN + } else { + RBACFramework.Role.OPERATOR + } + userSession.setUser( + UserInfo( + userId = userId.trim(), + userName = userId.trim(), + role = role, + unit = "Unit1", + clearanceLevel = null, + missionAssignment = null + ) + ) + } + + Box(modifier = modifier.fillMaxSize()) { + ClassificationWatermark(Modifier.fillMaxSize()) + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = padH, vertical = padV), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Image( + painter = painterResource(R.drawable.ic_smo_mark), + contentDescription = null, + modifier = Modifier.height(56.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + AnimatedContent( + targetState = step, + transitionSpec = { + fadeIn(animationSpec = tween(280)) togetherWith fadeOut(animationSpec = tween(220)) + }, + label = "signInStep" + ) { s -> + when (s) { + SignInStep.Landing -> { + Text( + text = stringResource(R.string.auth_landing_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.auth_landing_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { step = SignInStep.Credentials }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.auth_sign_in)) + } + if (oidcAvailable) { + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton( + onClick = onOrganizationSignIn, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.auth_sign_in_organization)) + } + } + } + + SignInStep.Credentials -> { + Text( + text = stringResource(R.string.auth_sign_in), + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = userId, + onValueChange = { userId = it; errorText = null }, + label = { Text(stringResource(R.string.auth_user_id)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it; errorText = null }, + label = { Text(stringResource(R.string.auth_password)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth() + ) + errorText?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + when { + userId.length < 3 -> errorText = activity.getString(R.string.auth_error_user_id) + password.length < 4 -> errorText = activity.getString(R.string.auth_error_password) + else -> step = SignInStep.Mfa + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.auth_continue)) + } + if (oidcAvailable) { + Spacer(modifier = Modifier.height(10.dp)) + OutlinedButton( + onClick = onOrganizationSignIn, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.auth_sign_in_organization)) + } + } + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = { step = SignInStep.Landing }) { + Text(stringResource(R.string.auth_back)) + } + } + + SignInStep.Mfa -> { + Text( + text = stringResource(R.string.auth_mfa_title), + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.auth_mfa_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = mfaCode, + onValueChange = { v -> mfaCode = v.filter { it.isDigit() }.take(6); errorText = null }, + label = { Text(stringResource(R.string.auth_mfa_code)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + modifier = Modifier.fillMaxWidth() + ) + errorText?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { + if (mfaCode.length != 6) { + errorText = activity.getString(R.string.auth_mfa_invalid_length) + return@Button + } + // Replace with server TOTP/WebAuthn verification when backend is wired. + if (mfaCode != DEMO_MFA_CODE) { + errorText = activity.getString(R.string.auth_mfa_invalid) + return@Button + } + val bio = biometric.canAuthenticateWithBiometrics() + val dev = biometric.canAuthenticateWithDeviceCredential() + when { + bio == BiometricManager.BIOMETRIC_SUCCESS -> { + signInUsesDeviceCredential = false + biometricLaunchEpoch++ + step = SignInStep.Biometric + } + dev == BiometricManager.BIOMETRIC_SUCCESS -> { + signInUsesDeviceCredential = true + biometricLaunchEpoch++ + step = SignInStep.Biometric + } + else -> { + errorText = activity.getString(R.string.auth_biometric_unavailable) + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.auth_verify_mfa)) + } + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = { step = SignInStep.Credentials }) { + Text(stringResource(R.string.auth_back)) + } + } + + SignInStep.Biometric -> { + SignInBiometricStep( + launchEpoch = biometricLaunchEpoch, + errorText = errorText, + biometric = biometric, + useDeviceCredentialFallback = signInUsesDeviceCredential, + onSuccess = { completeSignIn() }, + onError = { msg -> errorText = msg }, + onClearError = { errorText = null }, + onCancelled = { step = SignInStep.Mfa } + ) + } + } + } + } + } +} + +@Composable +private fun SignInBiometricStep( + launchEpoch: Int, + errorText: String?, + biometric: BiometricAuthenticator, + useDeviceCredentialFallback: Boolean, + onSuccess: () -> Unit, + onError: (String) -> Unit, + onClearError: () -> Unit, + onCancelled: () -> Unit +) { + LaunchedEffect(launchEpoch) { + onClearError() + biometric.authenticate( + onSuccess = onSuccess, + onError = onError, + onCancelled = onCancelled, + useDeviceCredentialFallback = useDeviceCredentialFallback + ) + } + Text( + text = stringResource(R.string.auth_biometric_required), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.auth_biometric_prompt_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + errorText?.let { + Spacer(modifier = Modifier.height(8.dp)) + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton( + onClick = { + onClearError() + biometric.authenticate( + onSuccess = onSuccess, + onError = onError, + onCancelled = { }, + useDeviceCredentialFallback = useDeviceCredentialFallback + ) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.auth_biometric_retry)) + } +} + +private const val DEMO_MFA_CODE = "123456" + +@Composable +fun OnboardingPager( + onFinished: () -> Unit, + windowSizeClass: WindowSizeClass, + modifier: Modifier = Modifier +) { + var page by remember { mutableStateOf(0) } + val pages = listOf( + stringResource(R.string.onboarding_page1_title) to stringResource(R.string.onboarding_page1_body), + stringResource(R.string.onboarding_page2_title) to stringResource(R.string.onboarding_page2_body), + stringResource(R.string.onboarding_page3_title) to stringResource(R.string.onboarding_page3_body) + ) + val padH = SmoaSpatial.horizontalPadding(windowSizeClass) + val padV = SmoaSpatial.verticalPadding(windowSizeClass) + Box(modifier = modifier.fillMaxSize()) { + ClassificationWatermark(Modifier.fillMaxSize()) + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = padH, vertical = padV), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + AnimatedContent( + targetState = page, + transitionSpec = { + fadeIn(tween(320)) togetherWith fadeOut(tween(240)) + }, + label = "onboardingPage" + ) { p -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = pages[p].first, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = pages[p].second, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + Spacer(modifier = Modifier.height(32.dp)) + if (page < pages.lastIndex) { + Button(onClick = { page++ }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.onboarding_next)) + } + } else { + Button(onClick = onFinished, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.onboarding_get_started)) + } + } + } + } +} diff --git a/app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt b/app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt new file mode 100644 index 0000000..8e3cf76 --- /dev/null +++ b/app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt @@ -0,0 +1,67 @@ +package com.smoa.ui.auth + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import com.smoa.R +import com.smoa.auth.BiometricAuthenticator + +@Composable +fun SessionLockOverlay( + activity: FragmentActivity, + onUnlocked: () -> Unit, + modifier: Modifier = Modifier +) { + val biometric = remember(activity) { BiometricAuthenticator(activity) } + Box( + modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.62f)), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(28.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.session_lock_title), + style = MaterialTheme.typography.headlineSmall, + color = Color.White, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.session_lock_body), + style = MaterialTheme.typography.bodyMedium, + color = Color.White.copy(alpha = 0.88f), + textAlign = TextAlign.Center + ) + Button( + onClick = { + biometric.authenticateForSessionUnlock( + onSuccess = onUnlocked, + onError = { }, + onCancelled = { } + ) + } + ) { + Text(stringResource(R.string.session_lock_unlock)) + } + } + } +} diff --git a/app/src/main/java/com/smoa/ui/components/ModulePlaceholderImage.kt b/app/src/main/java/com/smoa/ui/components/ModulePlaceholderImage.kt new file mode 100644 index 0000000..b89daed --- /dev/null +++ b/app/src/main/java/com/smoa/ui/components/ModulePlaceholderImage.kt @@ -0,0 +1,28 @@ +package com.smoa.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.smoa.R + +/** + * Vector placeholder for module hero / list graphics until final assets are approved. + */ +@Composable +fun ModulePlaceholderImage( + modifier: Modifier = Modifier, + size: Dp = 72.dp +) { + Image( + painter = painterResource(R.drawable.ic_module_placeholder), + contentDescription = stringResource(R.string.content_desc_module_placeholder), + modifier = modifier.size(size), + contentScale = ContentScale.Fit + ) +} diff --git a/app/src/main/java/com/smoa/ui/components/SmoaChrome.kt b/app/src/main/java/com/smoa/ui/components/SmoaChrome.kt new file mode 100644 index 0000000..4c60fc6 --- /dev/null +++ b/app/src/main/java/com/smoa/ui/components/SmoaChrome.kt @@ -0,0 +1,119 @@ +package com.smoa.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.smoa.di.SmoaRemoteConfigEntryPoint +import dagger.hilt.android.EntryPointAccessors +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.smoa.R + +/** + * Diagonal low-contrast classification marking (placeholder for org-specific CUI/FOUO labels). + * Does not intercept touches; drawn behind interactive content. + */ +@Composable +fun ClassificationWatermark( + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val store = remember(context) { + EntryPointAccessors.fromApplication( + context.applicationContext, + SmoaRemoteConfigEntryPoint::class.java + ).remoteEndpointStore() + } + val epoch by store.configEpoch.collectAsState(initial = 0L) + val defaultPrimary = stringResource(R.string.classification_watermark_primary) + val defaultSecondary = stringResource(R.string.classification_watermark_secondary) + val remotePrimary = remember(epoch) { store.getClassificationWatermarkPrimary() } + val remoteSecondary = remember(epoch) { store.getClassificationWatermarkSecondary() } + val primary = remotePrimary ?: defaultPrimary + val secondary = remoteSecondary ?: defaultSecondary + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = primary, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.045f), + textAlign = TextAlign.Center, + modifier = Modifier.graphicsLayer { + rotationZ = -32f + scaleX = 1.15f + scaleY = 1.15f + } + ) + Text( + text = secondary, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.04f), + textAlign = TextAlign.Center, + modifier = Modifier + .padding(top = 120.dp) + .graphicsLayer { rotationZ = 24f } + ) + } +} + +/** + * Constrains main session content width on wide screens; centers column; draws watermark under content. + */ +@Composable +fun SmoaSessionScaffoldFrame( + maxContentWidth: Dp, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit +) { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize()) { + ClassificationWatermark() + } + val inner = if (maxContentWidth != Dp.Unspecified) { + Modifier + .widthIn(max = maxContentWidth) + .align(Alignment.TopCenter) + .fillMaxWidth() + } else { + Modifier.fillMaxSize() + } + Box(inner) { + content() + } + } +} + +@Composable +fun SecurityPolicyStrip(modifier: Modifier = Modifier) { + Surface( + tonalElevation = 1.dp, + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.55f), + modifier = modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.security_policy_strip), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) + ) + } +} diff --git a/app/src/main/java/com/smoa/ui/main/HomeScreen.kt b/app/src/main/java/com/smoa/ui/main/HomeScreen.kt new file mode 100644 index 0000000..42fd407 --- /dev/null +++ b/app/src/main/java/com/smoa/ui/main/HomeScreen.kt @@ -0,0 +1,185 @@ +package com.smoa.ui.main + +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.navigation.NavController +import com.smoa.R +import com.smoa.core.auth.RBACFramework +import com.smoa.ui.components.ModulePlaceholderImage +import com.smoa.ui.navigation.SMOARoute +import com.smoa.ui.navigation.navigateSmoa +import com.smoa.ui.theme.SmoaSpatial + +private data class HomeRoute( + val labelRes: Int, + val route: String, + val module: RBACFramework.Module?, + val icon: ImageVector +) + +/** + * Adaptive hub: responsive grid on tablets/foldables, cards with placeholder art, launcher settings. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + navController: NavController, + userRole: RBACFramework.Role, + rbacFramework: RBACFramework, + windowSizeClass: WindowSizeClass, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val hPad = SmoaSpatial.horizontalPadding(windowSizeClass) + val vPad = SmoaSpatial.verticalPadding(windowSizeClass) + + val allRoutes = remember { + listOf( + HomeRoute(R.string.module_credentials, SMOARoute.Credentials.route, null, Icons.Default.Info), + HomeRoute(R.string.home_orders, SMOARoute.Orders.route, null, Icons.Default.Description), + HomeRoute(R.string.home_evidence, SMOARoute.Evidence.route, null, Icons.Default.Folder), + HomeRoute(R.string.home_reports, SMOARoute.Reports.route, null, Icons.Default.Description), + HomeRoute(R.string.module_directory, SMOARoute.Directory.route, RBACFramework.Module.DIRECTORY, Icons.Default.Person), + HomeRoute(R.string.module_communications, SMOARoute.Communications.route, RBACFramework.Module.COMMUNICATIONS, Icons.Default.Phone), + HomeRoute(R.string.module_meetings, SMOARoute.Meetings.route, RBACFramework.Module.MEETINGS, Icons.Default.Phone), + HomeRoute(R.string.module_browser, SMOARoute.Browser.route, RBACFramework.Module.BROWSER, Icons.Default.Info) + ) + } + val tiles = remember(userRole, rbacFramework) { + allRoutes.filter { route -> + route.module == null || rbacFramework.canAccessModule(userRole, route.module) + } + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 280.dp), + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = hPad, vertical = vPad), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Column(Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.home_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.height(6.dp)) + Text( + text = stringResource(R.string.home_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.regulatory_footer_notice), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + } + items(tiles, key = { it.route }) { tile -> + Card( + onClick = { navController.navigateSmoa(tile.route) }, + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Row( + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ModulePlaceholderImage(size = 52.dp) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(tile.icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text( + text = stringResource(tile.labelRes), + style = MaterialTheme.typography.titleMedium + ) + } + Text( + text = stringResource(R.string.home_module_tap_open), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + item(span = { GridItemSpan(maxLineSpan) }) { + Card( + onClick = { navController.navigateSmoa(SMOARoute.UserSettings.route) }, + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)) + ) { + Row( + Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Settings, contentDescription = null) + Text(stringResource(R.string.user_settings_title), style = MaterialTheme.typography.titleMedium) + } + } + } + item(span = { GridItemSpan(maxLineSpan) }) { + OutlinedButton( + onClick = { + context.startActivity(Intent(Settings.ACTION_HOME_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Home, contentDescription = null) + Text(stringResource(R.string.home_set_default_launcher)) + } + } + } + } +} diff --git a/app/src/main/java/com/smoa/ui/main/MainScreen.kt b/app/src/main/java/com/smoa/ui/main/MainScreen.kt index b1b9e67..9789d7c 100644 --- a/app/src/main/java/com/smoa/ui/main/MainScreen.kt +++ b/app/src/main/java/com/smoa/ui/main/MainScreen.kt @@ -1,8 +1,16 @@ package com.smoa.ui.main +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api @@ -13,6 +21,8 @@ import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -20,30 +30,37 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Menu import androidx.navigation.compose.rememberNavController +import com.smoa.R import com.smoa.core.auth.RBACFramework import com.smoa.core.auth.UserSession import com.smoa.core.common.ConnectivityManager import com.smoa.core.common.FoldableStateManager +import com.smoa.core.security.ScreenProtection +import com.smoa.core.security.VPNManager import com.smoa.modules.browser.domain.BrowserService import com.smoa.modules.browser.domain.URLFilter import com.smoa.modules.communications.domain.CommunicationsService import com.smoa.modules.directory.domain.DirectoryService import com.smoa.modules.meetings.domain.MeetingsService -import com.smoa.core.security.ScreenProtection -import com.smoa.core.security.VPNManager +import com.smoa.ui.components.SecurityPolicyStrip +import com.smoa.ui.components.SmoaSessionScaffoldFrame import com.smoa.ui.navigation.SMOANavigation +import com.smoa.ui.theme.SmoaSpatial /** - * Main application screen with module navigation. + * Main application screen with module navigation, responsive frame, and policy strip. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( + windowSizeClass: WindowSizeClass, connectivityManager: ConnectivityManager, foldableStateManager: FoldableStateManager, userSession: UserSession, @@ -60,14 +77,14 @@ fun MainScreen( val navController = rememberNavController() val currentUser by userSession.currentUser.collectAsState() var drawerOpen by remember { mutableStateOf(false) } - + val userRole = currentUser?.role ?: RBACFramework.Role.GUEST val userUnit = currentUser?.unit val userId = currentUser?.userId ?: "guest" val drawerState = remember { DrawerState(DrawerValue.Closed) } - - // Update drawer state when drawerOpen changes + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + LaunchedEffect(drawerOpen) { if (drawerOpen) { drawerState.open() @@ -75,57 +92,91 @@ fun MainScreen( drawerState.close() } } - - // Update drawerOpen when drawer state changes + LaunchedEffect(drawerState.currentValue) { drawerOpen = drawerState.currentValue == DrawerValue.Open } - - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - com.smoa.ui.navigation.NavigationDrawer( - navController = navController, - userSession = userSession, - rbacFramework = rbacFramework, - onDrawerDismiss = { drawerOpen = false } - ) - }, - modifier = modifier + + SmoaSessionScaffoldFrame( + maxContentWidth = SmoaSpatial.contentMaxWidth(windowSizeClass), + modifier = modifier.fillMaxSize() ) { - Scaffold( - topBar = { - TopAppBar( - title = { Text("SMOA") }, - navigationIcon = { - IconButton(onClick = { drawerOpen = true }) { - Icon(Icons.Default.Menu, contentDescription = "Menu") - } - }, - actions = { - // VPN status indicator - VPNStatusIndicator(vpnManager) - // Connectivity status indicator - ConnectivityStatusIndicator(connectivityManager) - } + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + com.smoa.ui.navigation.NavigationDrawer( + navController = navController, + userSession = userSession, + rbacFramework = rbacFramework, + onDrawerDismiss = { drawerOpen = false } ) }, - modifier = Modifier - ) { paddingValues -> - SMOANavigation( - navController = navController, - connectivityManager = connectivityManager, - foldableStateManager = foldableStateManager, - directoryService = directoryService, - communicationsService = communicationsService, - meetingsService = meetingsService, - browserService = browserService, - urlFilter = urlFilter, - screenProtection = screenProtection, - userRole = userRole, - userUnit = userUnit, - userId = userId - ) + modifier = Modifier.fillMaxSize() + ) { + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + Column(Modifier.fillMaxWidth()) { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(R.drawable.ic_smo_mark), + contentDescription = null, + modifier = Modifier.size(32.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + stringResource(R.string.app_name), + style = MaterialTheme.typography.titleLarge + ) + } + }, + navigationIcon = { + IconButton(onClick = { drawerOpen = true }) { + Icon( + Icons.Default.Menu, + contentDescription = stringResource(R.string.content_desc_menu) + ) + } + }, + actions = { + VPNStatusIndicator(vpnManager) + ConnectivityStatusIndicator(connectivityManager) + }, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface + ) + ) + SecurityPolicyStrip() + } + }, + ) { paddingValues -> + SMOANavigation( + navController = navController, + windowSizeClass = windowSizeClass, + connectivityManager = connectivityManager, + foldableStateManager = foldableStateManager, + directoryService = directoryService, + communicationsService = communicationsService, + meetingsService = meetingsService, + browserService = browserService, + urlFilter = urlFilter, + screenProtection = screenProtection, + rbacFramework = rbacFramework, + userSession = userSession, + userRole = userRole, + userUnit = userUnit, + userId = userId, + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) + } } } } @@ -144,7 +195,7 @@ fun VPNStatusIndicator( com.smoa.core.security.VPNState.Error -> "VPN ERR" com.smoa.core.security.VPNState.Unknown -> "VPN ?" } - + Text( text = statusText, style = MaterialTheme.typography.bodySmall, @@ -170,11 +221,10 @@ fun ConnectivityStatusIndicator( ConnectivityManager.ConnectivityState.Restricted -> "RESTRICTED" ConnectivityManager.ConnectivityState.Unknown -> "UNKNOWN" } - + Text( text = statusText, style = MaterialTheme.typography.bodySmall, modifier = modifier.padding(horizontal = 8.dp) ) } - diff --git a/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt b/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt index 4365481..843e858 100644 --- a/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt +++ b/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt @@ -2,18 +2,22 @@ package com.smoa.ui.navigation import androidx.compose.foundation.layout.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import com.smoa.R import com.smoa.core.auth.RBACFramework import com.smoa.core.auth.UserSession -import javax.inject.Inject /** * Navigation drawer for module selection. @@ -53,12 +57,51 @@ fun NavigationDrawer( Divider(modifier = Modifier.padding(vertical = 8.dp)) - // Navigation items - NavigationDrawerItem( - label = { Text("Credentials") }, + NavigationDrawerItem( + label = { Text(stringResource(R.string.home_title)) }, + selected = navController.currentDestination?.route == SMOARoute.Home.route, + onClick = { + navController.navigateSmoa(SMOARoute.Home.route) + onDrawerDismiss() + }, + icon = { Icon(Icons.Default.Home, contentDescription = null) } + ) + + NavigationDrawerItem( + label = { Text(stringResource(R.string.module_credentials)) }, selected = navController.currentDestination?.route == SMOARoute.Credentials.route, onClick = { - navController.navigate(SMOARoute.Credentials.route) + navController.navigateSmoa(SMOARoute.Credentials.route) + onDrawerDismiss() + }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + + NavigationDrawerItem( + label = { Text(stringResource(R.string.home_orders)) }, + selected = navController.currentDestination?.route == SMOARoute.Orders.route, + onClick = { + navController.navigateSmoa(SMOARoute.Orders.route) + onDrawerDismiss() + }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + + NavigationDrawerItem( + label = { Text(stringResource(R.string.home_evidence)) }, + selected = navController.currentDestination?.route == SMOARoute.Evidence.route, + onClick = { + navController.navigateSmoa(SMOARoute.Evidence.route) + onDrawerDismiss() + }, + icon = { Icon(Icons.Default.Info, contentDescription = null) } + ) + + NavigationDrawerItem( + label = { Text(stringResource(R.string.home_reports)) }, + selected = navController.currentDestination?.route == SMOARoute.Reports.route, + onClick = { + navController.navigateSmoa(SMOARoute.Reports.route) onDrawerDismiss() }, icon = { Icon(Icons.Default.Info, contentDescription = null) } @@ -66,10 +109,10 @@ fun NavigationDrawer( if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.DIRECTORY)) { NavigationDrawerItem( - label = { Text("Directory") }, + label = { Text(stringResource(R.string.module_directory)) }, selected = navController.currentDestination?.route == SMOARoute.Directory.route, onClick = { - navController.navigate(SMOARoute.Directory.route) + navController.navigateSmoa(SMOARoute.Directory.route) onDrawerDismiss() }, icon = { Icon(Icons.Default.Person, contentDescription = null) } @@ -78,10 +121,10 @@ fun NavigationDrawer( if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.COMMUNICATIONS)) { NavigationDrawerItem( - label = { Text("Communications") }, + label = { Text(stringResource(R.string.module_communications)) }, selected = navController.currentDestination?.route == SMOARoute.Communications.route, onClick = { - navController.navigate(SMOARoute.Communications.route) + navController.navigateSmoa(SMOARoute.Communications.route) onDrawerDismiss() }, icon = { Icon(Icons.Default.Phone, contentDescription = null) } @@ -90,10 +133,10 @@ fun NavigationDrawer( if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.MEETINGS)) { NavigationDrawerItem( - label = { Text("Meetings") }, + label = { Text(stringResource(R.string.module_meetings)) }, selected = navController.currentDestination?.route == SMOARoute.Meetings.route, onClick = { - navController.navigate(SMOARoute.Meetings.route) + navController.navigateSmoa(SMOARoute.Meetings.route) onDrawerDismiss() }, icon = { Icon(Icons.Default.Phone, contentDescription = null) } @@ -102,15 +145,28 @@ fun NavigationDrawer( if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.BROWSER)) { NavigationDrawerItem( - label = { Text("Browser") }, + label = { Text(stringResource(R.string.module_browser)) }, selected = navController.currentDestination?.route == SMOARoute.Browser.route, onClick = { - navController.navigate(SMOARoute.Browser.route) + navController.navigateSmoa(SMOARoute.Browser.route) onDrawerDismiss() }, icon = { Icon(Icons.Default.Info, contentDescription = null) } ) } + + Spacer(modifier = Modifier.weight(1f)) + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + NavigationDrawerItem( + label = { Text(stringResource(R.string.user_settings_title)) }, + selected = navController.currentDestination?.route == SMOARoute.UserSettings.route, + onClick = { + navController.navigateSmoa(SMOARoute.UserSettings.route) + onDrawerDismiss() + }, + icon = { Icon(Icons.Default.Settings, contentDescription = null) } + ) } } } diff --git a/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt b/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt index 20387cb..a2fef57 100644 --- a/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt +++ b/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt @@ -1,10 +1,16 @@ package com.smoa.ui.navigation +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.smoa.core.auth.RBACFramework +import com.smoa.core.auth.UserSession +import com.smoa.ui.main.HomeScreen +import com.smoa.ui.settings.UserSettingsScreen import com.smoa.modules.browser.BrowserModule import com.smoa.modules.browser.domain.BrowserService import com.smoa.modules.browser.domain.URLFilter @@ -15,6 +21,9 @@ import com.smoa.modules.directory.DirectoryModule import com.smoa.modules.directory.domain.DirectoryService import com.smoa.modules.meetings.MeetingsModule import com.smoa.modules.meetings.domain.MeetingsService +import com.smoa.modules.orders.ui.OrdersModule +import com.smoa.modules.evidence.ui.EvidenceModule +import com.smoa.modules.reports.ui.ReportGenerationScreen import com.smoa.core.common.ConnectivityManager import com.smoa.core.common.FoldableStateManager import com.smoa.core.security.ScreenProtection @@ -23,11 +32,16 @@ import com.smoa.core.security.ScreenProtection * Navigation routes for SMOA modules. */ sealed class SMOARoute(val route: String) { + object Home : SMOARoute("home") object Credentials : SMOARoute("credentials") object Directory : SMOARoute("directory") object Communications : SMOARoute("communications") object Meetings : SMOARoute("meetings") object Browser : SMOARoute("browser") + object Orders : SMOARoute("orders") + object Evidence : SMOARoute("evidence") + object Reports : SMOARoute("reports") + object UserSettings : SMOARoute("user_settings") } /** @@ -37,6 +51,7 @@ sealed class SMOARoute(val route: String) { @Composable fun SMOANavigation( navController: NavHostController, + windowSizeClass: WindowSizeClass, connectivityManager: ConnectivityManager, foldableStateManager: FoldableStateManager, directoryService: DirectoryService, @@ -45,26 +60,62 @@ fun SMOANavigation( browserService: BrowserService, urlFilter: URLFilter, screenProtection: ScreenProtection, + rbacFramework: RBACFramework, + userSession: UserSession, userRole: RBACFramework.Role, userUnit: String?, - userId: String + userId: String, + modifier: Modifier = Modifier ) { NavHost( navController = navController, - startDestination = SMOARoute.Credentials.route + startDestination = SMOARoute.Home.route, + modifier = modifier.fillMaxSize() ) { + composable(SMOARoute.Home.route) { + HomeScreen( + navController = navController, + userRole = userRole, + rbacFramework = rbacFramework, + windowSizeClass = windowSizeClass, + modifier = Modifier + ) + } + + composable(SMOARoute.UserSettings.route) { + UserSettingsScreen( + userSession = userSession, + modifier = Modifier + ) + } + composable(SMOARoute.Credentials.route) { CredentialsModule( - modifier = androidx.compose.ui.Modifier + modifier = Modifier, + onOpenOrders = { navController.navigateSmoa(SMOARoute.Orders.route) }, + onOpenEvidence = { navController.navigateSmoa(SMOARoute.Evidence.route) }, + onOpenReports = { navController.navigateSmoa(SMOARoute.Reports.route) } ) } + composable(SMOARoute.Orders.route) { + OrdersModule(modifier = Modifier) + } + + composable(SMOARoute.Evidence.route) { + EvidenceModule(modifier = Modifier) + } + + composable(SMOARoute.Reports.route) { + ReportGenerationScreen(modifier = Modifier) + } + composable(SMOARoute.Directory.route) { DirectoryModule( directoryService = directoryService, userRole = userRole, userUnit = userUnit, - modifier = androidx.compose.ui.Modifier + modifier = Modifier ) } @@ -73,7 +124,7 @@ fun SMOANavigation( communicationsService = communicationsService, userRole = userRole, userUnit = userUnit, - modifier = androidx.compose.ui.Modifier + modifier = Modifier ) } @@ -82,7 +133,7 @@ fun SMOANavigation( meetingsService = meetingsService, userRole = userRole, userId = userId, - modifier = androidx.compose.ui.Modifier + modifier = Modifier ) } @@ -91,7 +142,7 @@ fun SMOANavigation( browserService = browserService, urlFilter = urlFilter, screenProtection = screenProtection, - modifier = androidx.compose.ui.Modifier + modifier = Modifier ) } } diff --git a/app/src/main/java/com/smoa/ui/navigation/SmoaNav.kt b/app/src/main/java/com/smoa/ui/navigation/SmoaNav.kt new file mode 100644 index 0000000..153cae4 --- /dev/null +++ b/app/src/main/java/com/smoa/ui/navigation/SmoaNav.kt @@ -0,0 +1,22 @@ +package com.smoa.ui.navigation + +import androidx.navigation.NavController +import androidx.navigation.navOptions + +/** + * Drawer-friendly navigation: single top, state save/restore, pop back to [SMOARoute.Home] so the stack + * does not accumulate duplicate destinations (WCAG-friendly predictable back behavior). + */ +fun NavController.navigateSmoa(route: String) { + navigate( + route, + navOptions { + popUpTo(SMOARoute.Home.route) { + saveState = true + inclusive = false + } + launchSingleTop = true + restoreState = true + } + ) +} diff --git a/app/src/main/java/com/smoa/ui/root/AppRoot.kt b/app/src/main/java/com/smoa/ui/root/AppRoot.kt new file mode 100644 index 0000000..b0890e4 --- /dev/null +++ b/app/src/main/java/com/smoa/ui/root/AppRoot.kt @@ -0,0 +1,136 @@ +package com.smoa.ui.root + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.fragment.app.FragmentActivity +import com.smoa.security.SessionLockController +import com.smoa.ui.auth.SessionLockOverlay +import com.smoa.auth.AuthPreferencesStore +import com.smoa.core.auth.RBACFramework +import com.smoa.core.auth.UserSession +import com.smoa.core.common.ConnectivityManager +import com.smoa.core.common.FoldableStateManager +import com.smoa.core.security.ScreenProtection +import com.smoa.core.security.VPNManager +import com.smoa.modules.browser.domain.BrowserService +import com.smoa.modules.browser.domain.URLFilter +import com.smoa.modules.communications.domain.CommunicationsService +import com.smoa.modules.directory.domain.DirectoryService +import com.smoa.modules.meetings.domain.MeetingsService +import com.smoa.ui.auth.AuthFlowHost +import com.smoa.ui.auth.OnboardingPager +import com.smoa.ui.main.MainScreen + +private sealed interface RootPhase { + data object Onboarding : RootPhase + data object Auth : RootPhase + data object Main : RootPhase +} + +/** + * Chooses onboarding → sign-in (MFA + biometric) → main shell based on session and prefs. + */ +@Composable +fun AppRoot( + activity: FragmentActivity, + windowSizeClass: WindowSizeClass, + sessionLockController: SessionLockController, + authPreferencesStore: AuthPreferencesStore, + userSession: UserSession, + connectivityManager: ConnectivityManager, + foldableStateManager: FoldableStateManager, + rbacFramework: RBACFramework, + directoryService: DirectoryService, + communicationsService: CommunicationsService, + meetingsService: MeetingsService, + browserService: BrowserService, + urlFilter: URLFilter, + screenProtection: ScreenProtection, + vpnManager: VPNManager, + oidcAvailable: Boolean = com.smoa.security.OidcBootstrap.isConfigured(), + onOrganizationSignIn: () -> Unit = {}, + modifier: Modifier = Modifier +) { + var onboardingComplete by remember { + mutableStateOf(authPreferencesStore.isOnboardingComplete()) + } + val currentUser by userSession.currentUser.collectAsState() + val sessionLocked by sessionLockController.locked.collectAsState() + + val phase: RootPhase = when { + !onboardingComplete -> RootPhase.Onboarding + currentUser == null -> RootPhase.Auth + else -> RootPhase.Main + } + + AnimatedContent( + targetState = phase, + modifier = modifier, + transitionSpec = { + fadeIn(animationSpec = tween(340)) togetherWith fadeOut(animationSpec = tween(280)) + }, + label = "rootPhase" + ) { p -> + when (p) { + RootPhase.Onboarding -> { + OnboardingPager( + onFinished = { + authPreferencesStore.setOnboardingComplete() + onboardingComplete = true + }, + windowSizeClass = windowSizeClass, + modifier = Modifier + ) + } + RootPhase.Auth -> { + AuthFlowHost( + activity = activity, + userSession = userSession, + windowSizeClass = windowSizeClass, + oidcAvailable = oidcAvailable, + onOrganizationSignIn = onOrganizationSignIn, + modifier = Modifier + ) + } + RootPhase.Main -> { + Box(Modifier.fillMaxSize()) { + MainScreen( + windowSizeClass = windowSizeClass, + connectivityManager = connectivityManager, + foldableStateManager = foldableStateManager, + userSession = userSession, + rbacFramework = rbacFramework, + directoryService = directoryService, + communicationsService = communicationsService, + meetingsService = meetingsService, + browserService = browserService, + urlFilter = urlFilter, + screenProtection = screenProtection, + vpnManager = vpnManager, + modifier = Modifier.fillMaxSize() + ) + if (sessionLocked) { + SessionLockOverlay( + activity = activity, + onUnlocked = { sessionLockController.unlockAfterAuthentication() }, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/smoa/ui/settings/UserSettingsScreen.kt b/app/src/main/java/com/smoa/ui/settings/UserSettingsScreen.kt new file mode 100644 index 0000000..65ca239 --- /dev/null +++ b/app/src/main/java/com/smoa/ui/settings/UserSettingsScreen.kt @@ -0,0 +1,267 @@ +package com.smoa.ui.settings + +import android.content.ClipboardManager +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExitToApp +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import com.smoa.R +import com.smoa.core.auth.UserSession +import com.smoa.security.SecurityStatusViewModel + +@Composable +fun UserSettingsScreen( + userSession: UserSession, + modifier: Modifier = Modifier, + securityVm: SecurityStatusViewModel = hiltViewModel() +) { + val context = LocalContext.current + val currentUser by userSession.currentUser.collectAsState() + val integrityLine by securityVm.integrityLine.collectAsState() + var showLogoutConfirm by remember { mutableStateOf(false) } + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.user_settings_title), + style = MaterialTheme.typography.headlineSmall + ) + Text( + text = stringResource(R.string.user_settings_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)) + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.user_settings_account_section), + style = MaterialTheme.typography.titleMedium + ) + Divider() + SettingsRow( + label = stringResource(R.string.user_settings_user_id), + value = currentUser?.userId ?: "—" + ) + SettingsRow( + label = stringResource(R.string.user_settings_display_name), + value = currentUser?.userName ?: "—" + ) + SettingsRow( + label = stringResource(R.string.user_settings_role), + value = currentUser?.role?.name ?: "—" + ) + currentUser?.unit?.let { unit -> + SettingsRow( + label = stringResource(R.string.user_settings_unit), + value = unit + ) + } + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.45f)) + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = stringResource(R.string.enterprise_security_title), + style = MaterialTheme.typography.titleMedium + ) + Divider() + SettingsRow( + label = stringResource(R.string.enterprise_build_classification), + value = securityVm.classificationBuildMarking + ) + SettingsRow( + label = stringResource(R.string.enterprise_session_timeout), + value = if (securityVm.sessionTimeoutMinutes <= 0) { + stringResource(R.string.enterprise_session_disabled) + } else { + "${securityVm.sessionTimeoutMinutes} min" + } + ) + SettingsRow( + label = stringResource(R.string.enterprise_tls_pinning), + value = if (securityVm.tlsPinningActive) stringResource(R.string.enterprise_yes) else stringResource(R.string.enterprise_no) + ) + SettingsRow( + label = stringResource(R.string.enterprise_oidc), + value = if (securityVm.oidcConfigured) stringResource(R.string.enterprise_configured) else stringResource(R.string.enterprise_not_configured) + ) + securityVm.oidcDiscoveryUrl?.let { url -> + Text( + text = stringResource(R.string.enterprise_oidc_discovery, url), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.85f) + ) + } + SettingsRow( + label = stringResource(R.string.enterprise_bearer_token), + value = if (securityVm.bearerTokenPresent) stringResource(R.string.enterprise_present) else stringResource(R.string.enterprise_absent) + ) + SettingsRow( + label = stringResource(R.string.enterprise_biometric_keystore), + value = if (securityVm.biometricKeyReady) stringResource(R.string.enterprise_ready) else stringResource(R.string.enterprise_not_initialized) + ) + Text( + text = securityVm.knoxSummary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.9f) + ) + integrityLine?.let { line -> + Text( + text = line, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { securityVm.runIntegrityCheck() }) { + Text(stringResource(R.string.enterprise_run_integrity)) + } + OutlinedButton(onClick = { securityVm.prepareBiometricKey() }) { + Text(stringResource(R.string.enterprise_init_biometric_key)) + } + } + } + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)) + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.user_settings_security_section), + style = MaterialTheme.typography.titleMedium + ) + Divider() + Text( + text = stringResource(R.string.user_settings_security_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.9f) + ) + Text( + text = stringResource(R.string.user_settings_integrations_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.75f) + ) + } + } + + OutlinedButton( + onClick = { + val uri = Uri.fromParts("package", context.packageName, null) + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = uri + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.user_settings_app_details)) + } + + Button( + onClick = { showLogoutConfirm = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.ExitToApp, contentDescription = null) + Text(stringResource(R.string.auth_logout)) + } + } + } + + if (showLogoutConfirm) { + AlertDialog( + onDismissRequest = { showLogoutConfirm = false }, + title = { Text(stringResource(R.string.auth_logout_confirm_title)) }, + text = { Text(stringResource(R.string.auth_logout_confirm_body)) }, + confirmButton = { + TextButton( + onClick = { + showLogoutConfirm = false + clearPrimaryClipboard(context) + securityVm.clearStoredTokens() + userSession.clearUser() + } + ) { + Text(stringResource(R.string.auth_logout)) + } + }, + dismissButton = { + TextButton(onClick = { showLogoutConfirm = false }) { + Text(stringResource(R.string.cancel)) + } + } + ) + } +} + +private fun clearPrimaryClipboard(context: android.content.Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + ContextCompat.getSystemService(context, ClipboardManager::class.java)?.clearPrimaryClip() + } +} + +@Composable +private fun SettingsRow(label: String, value: String) { + Column(Modifier.fillMaxWidth()) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyLarge) + } +} diff --git a/app/src/main/java/com/smoa/ui/theme/SMOATheme.kt b/app/src/main/java/com/smoa/ui/theme/SMOATheme.kt new file mode 100644 index 0000000..4894e3c --- /dev/null +++ b/app/src/main/java/com/smoa/ui/theme/SMOATheme.kt @@ -0,0 +1,80 @@ +package com.smoa.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +private val SmoaNavy = Color(0xFF1B4F72) +private val SmoaNavyDark = Color(0xFF123A52) +private val SmoaSlate = Color(0xFF3D4F5F) +private val SmoaSurface = Color(0xFFF5F7FA) +private val SmoaSurfaceVariant = Color(0xFFE8EEF4) +private val SmoaError = Color(0xFFB3261E) + +private val SmoaLightColors = lightColorScheme( + primary = SmoaNavy, + onPrimary = Color.White, + primaryContainer = Color(0xFFD4E4F7), + onPrimaryContainer = SmoaNavyDark, + secondary = SmoaSlate, + onSecondary = Color.White, + secondaryContainer = Color(0xFFDCE3EA), + onSecondaryContainer = Color(0xFF1C2730), + tertiary = Color(0xFF5D6B7A), + background = SmoaSurface, + onBackground = Color(0xFF1A1C1E), + surface = Color.White, + onSurface = Color(0xFF1A1C1E), + surfaceVariant = SmoaSurfaceVariant, + onSurfaceVariant = Color(0xFF42474E), + outline = Color(0xFF72787E), + error = SmoaError, + onError = Color.White +) + +private val baselineTypography = Typography() + +private val SmoaTypography = baselineTypography.copy( + headlineLarge = baselineTypography.headlineLarge.copy(fontWeight = FontWeight.SemiBold), + headlineMedium = baselineTypography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + headlineSmall = baselineTypography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), + titleLarge = baselineTypography.titleLarge.copy(fontWeight = FontWeight.Medium), + titleMedium = baselineTypography.titleMedium.copy(fontWeight = FontWeight.Medium), + bodyLarge = baselineTypography.bodyLarge.copy( + lineHeight = 24.sp, + letterSpacing = 0.4.sp + ), + bodyMedium = baselineTypography.bodyMedium.copy(letterSpacing = 0.25.sp), + labelSmall = baselineTypography.labelSmall.copy( + fontWeight = FontWeight.Medium, + letterSpacing = 0.6.sp + ) +) + +private val SmoaShapes = Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(10.dp), + medium = RoundedCornerShape(14.dp), + large = RoundedCornerShape(18.dp), + extraLarge = RoundedCornerShape(24.dp) +) + +/** + * Application theme: high-contrast light scheme, rounded shapes, stable for foldables and MDM-managed devices. + */ +@Composable +fun SMOATheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = SmoaLightColors, + typography = SmoaTypography, + shapes = SmoaShapes, + content = content + ) +} diff --git a/app/src/main/java/com/smoa/ui/theme/SmoaSpatial.kt b/app/src/main/java/com/smoa/ui/theme/SmoaSpatial.kt new file mode 100644 index 0000000..ebda895 --- /dev/null +++ b/app/src/main/java/com/smoa/ui/theme/SmoaSpatial.kt @@ -0,0 +1,38 @@ +package com.smoa.ui.theme + +import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Responsive spacing for foldables, tablets, and phones (Material window size classes). + */ +object SmoaSpatial { + + fun horizontalPadding(windowSizeClass: WindowSizeClass): Dp = + when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> 16.dp + WindowWidthSizeClass.Medium -> 24.dp + WindowWidthSizeClass.Expanded -> 32.dp + else -> 20.dp + } + + fun verticalPadding(windowSizeClass: WindowSizeClass): Dp = + when (windowSizeClass.heightSizeClass) { + WindowHeightSizeClass.Compact -> 12.dp + WindowHeightSizeClass.Medium -> 16.dp + WindowHeightSizeClass.Expanded -> 24.dp + else -> 14.dp + } + + /** Max content width on large displays for readable line length (regulatory / long-form readability). */ + fun contentMaxWidth(windowSizeClass: WindowSizeClass): Dp = + when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> Dp.Unspecified + WindowWidthSizeClass.Medium -> 720.dp + WindowWidthSizeClass.Expanded -> 1080.dp + else -> 640.dp + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..4804151 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..f6e3a23 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_module_placeholder.xml b/app/src/main/res/drawable/ic_module_placeholder.xml new file mode 100644 index 0000000..6fe6ff4 --- /dev/null +++ b/app/src/main/res/drawable/ic_module_placeholder.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_smo_mark.xml b/app/src/main/res/drawable/ic_smo_mark.xml new file mode 100644 index 0000000..80ee1bd --- /dev/null +++ b/app/src/main/res/drawable/ic_smo_mark.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 7d5f2a7..2a6fee8 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - - + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 7d5f2a7..2a6fee8 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,6 @@ - - + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d49578c..75822ed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,54 @@ SMOA + Sign in + Sign in with organization (SSO) + Organization sign-in did not complete. Try again or use local credentials. + Sign out + Log out + Log out? + You will need to sign in again to use SMOA. + User settings + Account details and session. + Account + User ID + Display name + Role + Unit + App info & permissions + User ID + Password + Continue + Back + Secure mobile operations + Sign in with your credentials, second factor, and biometric verification. + Enter a user ID (at least 3 characters). + Enter a password (at least 4 characters). + Second factor + Enter the 6-digit code from your authenticator. Demo code: 123456. + One-time code + Enter all 6 digits. + Invalid code. Try again. + Verify code + Complete sign-in using your enrolled biometric. + A system prompt will open. You can also tap the button below to try again. + Try biometric again + No biometric or device PIN/pattern/password is available. Enroll one in system security settings. + Biometric verification did not match. + Welcome to SMOA + This app handles sensitive workflows. Complete onboarding once on this device. + Strong sign-in + Every session requires a password, a one-time code (MFA), and biometric confirmation. + Default launcher (optional) + From Home you can open system settings to set SMOA as the default home app if your deployment allows it. + Next + Get started + Home + Open a module or configure this app as your default launcher. + Orders (documents) + Evidence + Reports + Set as default home app Enter PIN Biometric Authentication Required Fingerprint Required @@ -23,6 +71,40 @@ Secure Meetings Controlled Browser + + OFFICIAL USE ONLY + CONTROLLED UNCLASSIFIED + Screen capture and recording are blocked on this screen (FLAG_SECURE). VPN and connectivity status appear in the app bar. + Use only as authorized. Minimum touch targets follow platform accessibility guidance. + Device security + Biometric sign-in, encrypted preferences, and clipboard clear on log out (Android 9+) reduce data exposure between sessions. + Managed deployments: pair with MDM VPN profiles, hosted client config (SMOA_CONFIG_URL), and backend API keys per tenant. + Module illustration placeholder + Tap to open + Open navigation menu + Session locked + Biometric unlock is required after the app was in the background beyond your policy timeout. + Unlock + Enterprise & device posture + Build classification label + Background lock timeout + Disabled (0) + TLS certificate pinning + Active + Inactive + OIDC / OAuth + Configured + Not configured + Discovery: %1$s + Bearer token in secure store + Present + None + Biometric-gated AES key + Initialized + Not initialized + Run Play Integrity + Prepare biometric key + Lock Unlock diff --git a/app/src/test/java/com/smoa/api/BackendPullAPITest.kt b/app/src/test/java/com/smoa/api/BackendPullAPITest.kt new file mode 100644 index 0000000..bf35042 --- /dev/null +++ b/app/src/test/java/com/smoa/api/BackendPullAPITest.kt @@ -0,0 +1,68 @@ +package com.smoa.api + +import com.smoa.core.common.Result +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Response + +class BackendPullAPITest { + private val service = BackendPullAPI { Pair(FakeBackendPullApiService(), "key-123") } + + @Test + fun `pullDirectory returns response bytes`() = runBlocking { + val result = service.pullDirectory("unit-1") + + assertTrue(result is Result.Success) + assertArrayEquals("directory-payload".toByteArray(), (result as Result.Success).data) + } + + @Test + fun `pullCredentialDetail returns json bytes`() = runBlocking { + val result = service.pullCredentialDetail("c1") + + assertTrue(result is Result.Success) + val text = (result as Result.Success).data.toString(Charsets.UTF_8) + assertTrue(text.contains("c1")) + } + + @Test + fun `pullOrders returns error on http failure`() = runBlocking { + val failing = BackendPullAPI { Pair(FakeBackendPullApiService(failRequests = true), "key-123") } + + val result = failing.pullOrders(null, 100, null) + + assertTrue(result is Result.Error) + } + + private class FakeBackendPullApiService( + private val failRequests: Boolean = false + ) : BackendPullApiService { + override suspend fun getDirectory(unit: String?, xUnit: String?, apiKey: String?) = + response("directory-payload") + + override suspend fun getOrders(since: Long?, limit: Int, jurisdiction: String?, xUnit: String?, apiKey: String?) = + response("orders-payload") + + override suspend fun getEvidence(since: Long?, limit: Int, caseNumber: String?, apiKey: String?) = + response("evidence-payload") + + override suspend fun getCredentials(since: Long?, limit: Int, holderId: String?, apiKey: String?) = + response("credentials-payload") + + override suspend fun getCredential(credentialId: String, apiKey: String?) = + response("""{"credentialId":"$credentialId","holderId":"h","credentialType":"piv_pki","issuer":null,"issuedAt":1,"expiresAt":null,"payloadJson":"{}","updatedAt":2}""") + + override suspend fun getReports(since: Long?, limit: Int, apiKey: String?) = + response("reports-payload") + + private fun response(body: String): Response { + if (failRequests) { + return Response.error(500, "boom".toResponseBody()) + } + return Response.success(body.toResponseBody()) + } + } +} diff --git a/app/src/test/java/com/smoa/api/BackendSyncAPITest.kt b/app/src/test/java/com/smoa/api/BackendSyncAPITest.kt new file mode 100644 index 0000000..2cc9f2a --- /dev/null +++ b/app/src/test/java/com/smoa/api/BackendSyncAPITest.kt @@ -0,0 +1,98 @@ +package com.smoa.api + +import com.smoa.api.dto.SyncResponseDto +import com.smoa.core.common.Result +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.Response +import java.util.Base64 + +class BackendSyncAPITest { + private val fakeApi = FakeBackendSyncApiService() + private val service = BackendSyncAPI { Pair(fakeApi, "key-123") } + + @Test + fun `syncOrder maps backend success response`() = runBlocking { + val result = service.syncOrder("""{"id":"order-1"}""".toByteArray()) + + assertTrue(result is Result.Success) + val response = (result as Result.Success).data + assertTrue(response.success) + assertEquals("order-1", response.itemId) + assertArrayEquals("remote-order-1".toByteArray(), response.remoteData) + assertEquals("synced", response.message) + } + + @Test + fun `deleteOrder maps backend delete response`() = runBlocking { + val result = service.deleteOrder("order-1") + + assertTrue(result is Result.Success) + assertEquals("order-1", (result as Result.Success).data.itemId) + } + + @Test + fun `syncOrder returns error for non successful response`() = runBlocking { + val failing = BackendSyncAPI { Pair(FakeBackendSyncApiService(failRequests = true), "key-123") } + + val result = failing.syncOrder("""{"id":"order-1"}""".toByteArray()) + + assertTrue(result is Result.Error) + } + + private class FakeBackendSyncApiService( + private val failRequests: Boolean = false + ) : BackendSyncApiService { + override suspend fun syncDirectory(body: RequestBody, apiKey: String?): Response = + response("directory") + + override suspend fun syncOrder(body: RequestBody, apiKey: String?): Response = + response("order-1") + + override suspend fun syncEvidence(body: RequestBody, apiKey: String?): Response = + response("evidence") + + override suspend fun syncCredential(body: RequestBody, apiKey: String?): Response = + response("credential") + + override suspend fun syncReport(body: RequestBody, apiKey: String?): Response = + response("report") + + override suspend fun deleteDirectory(id: String, apiKey: String?): Response = + response(id) + + override suspend fun deleteOrder(orderId: String, apiKey: String?): Response = + response(orderId) + + override suspend fun deleteEvidence(evidenceId: String, apiKey: String?): Response = + response(evidenceId) + + override suspend fun deleteCredential(credentialId: String, apiKey: String?): Response = + response(credentialId) + + override suspend fun deleteReport(reportId: String, apiKey: String?): Response = + response(reportId) + + private fun response(id: String): Response { + if (failRequests) { + return Response.error(500, "boom".toResponseBody("text/plain".toMediaType())) + } + return Response.success( + SyncResponseDto( + success = true, + itemId = id, + serverTimestamp = 1234L, + conflict = true, + remoteDataBase64 = Base64.getEncoder().encodeToString("remote-$id".toByteArray()), + message = "synced" + ) + ) + } + } +} diff --git a/app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt b/app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt new file mode 100644 index 0000000..e35d533 --- /dev/null +++ b/app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt @@ -0,0 +1,37 @@ +package com.smoa.network + +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class CertificatePinnerFactoryTest { + + @Test + fun build_ignoresInvalidPins() { + assertNull(CertificatePinnerFactory.build("api.example.com", "")) + assertNull(CertificatePinnerFactory.build("api.example.com", "not-a-pin")) + } + + @Test + fun build_addsValidPins() { + val pinner = CertificatePinnerFactory.build( + "api.example.com", + "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=,sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" + ) + assertNotNull(pinner) + } + + @Test + fun buildFromMultiSpec_parsesHosts() { + val pinner = CertificatePinnerFactory.buildFromMultiSpec( + "api.example.com|sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=;" + + "cdn.example.com|sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" + ) + assertNotNull(pinner) + } + + @Test + fun buildFromMultiSpec_rejectsMalformed() { + assertNull(CertificatePinnerFactory.buildFromMultiSpec("nohostseparator")) + } +} diff --git a/app/src/test/java/com/smoa/security/SessionLockLogicTest.kt b/app/src/test/java/com/smoa/security/SessionLockLogicTest.kt new file mode 100644 index 0000000..264db58 --- /dev/null +++ b/app/src/test/java/com/smoa/security/SessionLockLogicTest.kt @@ -0,0 +1,41 @@ +package com.smoa.security + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionLockLogicTest { + + @Test + fun noLockWhenTimeoutDisabled() { + assertFalse( + SessionLockLogic.shouldLockAfterBackground( + elapsedBackgroundMs = 999_999L, + timeoutMs = 0L, + sessionActive = true + ) + ) + } + + @Test + fun noLockWithoutSession() { + assertFalse( + SessionLockLogic.shouldLockAfterBackground( + elapsedBackgroundMs = 999_999L, + timeoutMs = 60_000L, + sessionActive = false + ) + ) + } + + @Test + fun locksWhenElapsedExceedsTimeout() { + assertTrue( + SessionLockLogic.shouldLockAfterBackground( + elapsedBackgroundMs = 60_000L, + timeoutMs = 60_000L, + sessionActive = true + ) + ) + } +} diff --git a/backend/README.md b/backend/README.md index d9f7437..770f739 100644 --- a/backend/README.md +++ b/backend/README.md @@ -6,6 +6,7 @@ Ground-up backend with REST APIs for the **Secure Mobile Operations Application - **JDK 17** - **Gradle 8.x** (wrapper included in repo root; run from `backend/` with `../gradlew` or install Gradle) +- **Production DB:** PostgreSQL 14+ recommended. The PostgreSQL JDBC driver is on the classpath; set `SPRING_DATASOURCE_URL`, `SPRING_DATASOURCE_USERNAME`, `SPRING_DATASOURCE_PASSWORD` (or `spring.datasource.*`) and run with `SPRING_PROFILES_ACTIVE=prod`. ## Quick Start @@ -107,14 +108,26 @@ Sync and delete operations are **audit logged** (resource type, id, operation, s ## Connecting the Android app -1. Point the app’s sync base URL to this backend (e.g. `http://:8080`). -2. Implement a real `SyncAPI` (e.g. with Retrofit) that: +Gradle (examples): **`-Psmoa.backend.baseUrl=`**, **`-Psmoa.api.key=`**, **`-Psmoa.config.url=`** (hosted JSON; load is **non-blocking** — Retrofit resolves URL on each call after prefs update), **`-Psmoa.stun.urls=`** / **`-Psmoa.signaling.urls=`**, **`-Psmoa.browser.vpnEnforced=true`** (in-app browser requires VPN when enabled). + +1. Point the app’s sync base URL to this backend (e.g. `http://:8080`), or rely on hosted config — see `docs/examples/smoa-client-config.example.json`. +2. Use **`CredentialSyncRequestDto`** (or an equivalent map) when queueing credential sync — see `core/common/.../CredentialSyncRequestDto.kt`. +3. Implement a real `SyncAPI` (e.g. with Retrofit) that: - Serializes domain models to JSON matching the backend DTOs (`DirectorySyncRequest`, `OrderSyncRequest`, etc.). - POSTs to `/api/v1/sync/directory`, `/api/v1/sync/order`, etc. - Parses `SyncResponse` (and handles `conflict` / `remoteData` when present). Request DTOs align with the app’s directory, order, evidence, report, and credential concepts; field names and types are chosen for easy mapping from the mobile side. +## Deployment and client configuration + +- **Proxmox LXC layout, FQDNs, and client JSON contract:** [docs/LXC-PROXMOX-CONTAINERS.md](docs/LXC-PROXMOX-CONTAINERS.md) +- **Example client config** (for CI / static hosting): [docs/examples/smoa-client-config.example.json](docs/examples/smoa-client-config.example.json) + +## Identity templates (Complete Credential alignment) + +Credential sync types and mobile barcode templates are aligned with the **Complete Credential** shared schema (`CredentialRef.domain`) and SMOA formats (ICAO 9303, AAMVA, MIL-STD-129). See [../docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md](../docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md) and backend `SmoaCredentialType`. + ## Gap analysis and roadmap See [docs/BACKEND-GAPS-AND-ROADMAP.md](docs/BACKEND-GAPS-AND-ROADMAP.md) for a full review: what's covered, completed gaps (delete sync, pull/GET, enum validation, rate limiting, audit, tests, Dockerfile), and optional follow-ups (prod profile, unit/tenant scoping, migrations). diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index aa441b0..8535d40 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -1,9 +1,9 @@ plugins { - kotlin("jvm") version "1.9.20" - kotlin("plugin.spring") version "1.9.20" - kotlin("plugin.jpa") version "1.9.20" - id("org.springframework.boot") version "3.2.2" - id("io.spring.dependency-management") version "1.1.4" + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.plugin.spring") + id("org.jetbrains.kotlin.plugin.jpa") + id("org.springframework.boot") + id("io.spring.dependency-management") } group = "com.smoa" @@ -14,9 +14,7 @@ java { targetCompatibility = JavaVersion.VERSION_17 } -repositories { - mavenCentral() -} +// Repositories: use root settings.gradle.kts dependencyResolutionManagement (FAIL_ON_PROJECT_REPOS). dependencies { implementation("org.springframework.boot:spring-boot-starter-web") @@ -32,8 +30,9 @@ dependencies { // Auth implementation("org.springframework.boot:spring-boot-starter-security") - // Database (H2 for development; switch to PostgreSQL in production) + // Database: H2 for dev/test; PostgreSQL driver for production (activate via spring.datasource.*) runtimeOnly("com.h2database:h2") + runtimeOnly("org.postgresql:postgresql") implementation("org.flywaydb:flyway-core") testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/backend/docs/BACKEND-GAPS-AND-ROADMAP.md b/backend/docs/BACKEND-GAPS-AND-ROADMAP.md index 5e18d70..f29b4ec 100644 --- a/backend/docs/BACKEND-GAPS-AND-ROADMAP.md +++ b/backend/docs/BACKEND-GAPS-AND-ROADMAP.md @@ -45,10 +45,10 @@ The backend implements the **sync contract** expected by the mobile app (POST sy - **Gap:** Backend returns remoteData as base64; client must decode. - **Done:** Documented in OpenAPI description that remoteData is base64-encoded JSON when conflict=true. -### 5. **Production and ops** +### 5. **Production and ops** ✅ Done (baseline) -- **Gap:** H2 console enabled in all profiles; no explicit prod profile with console off and stricter settings. -- **Recommendation:** Add application-prod.yml: disable H2 console, set logging, optionally require API key. Document PostgreSQL (or other DB) and env vars. +- **Done:** `application-prod.yml` disables H2 console, sets `ddl-auto: validate`, Flyway on, stricter logging. +- **Done:** `org.postgresql:postgresql` on the runtime classpath; set `SPRING_DATASOURCE_*` (see `application-prod.yml` and backend README) for PostgreSQL. Dev/test keep H2 unless overridden. ### 6. **Rate limiting** ✅ Done @@ -63,7 +63,7 @@ The backend implements the **sync contract** expected by the mobile app (POST sy ### 8. **Tests** ✅ Done - **Gap:** No backend unit or integration tests. -- **Done:** DirectorySyncServiceTest (create, conflict/remoteData, delete); GlobalExceptionHandlerTest (500); SyncControllerIntegrationTest (POST valid/invalid, health); application-test.yml (H2 in-memory, rate limit off); mockk for unit tests. +- **Done:** DirectorySyncServiceTest (create, conflict/remoteData, delete); GlobalExceptionHandlerTest (500); SyncControllerIntegrationTest (directory, credential type validation, health); application-test.yml (H2 in-memory, rate limit off); mockk for unit tests. ### 9. **Ids and authorization** @@ -79,10 +79,11 @@ The backend implements the **sync contract** expected by the mobile app (POST sy ## Optional improvements -- **Pagination:** For any future GET list endpoints, use page/size or limit/offset and document in OpenAPI. -- **ETag / If-None-Match:** For GET-by-id or list endpoints, support caching with ETag. -- **Request ID:** Add a filter to assign and log a request ID for tracing. +- **Pagination:** Pull endpoints use `limit`/`since`; extend with cursor or page/size if lists grow very large. +- **ETag / If-None-Match:** ✅ `ShallowEtagHeaderFilter` on `/api/v1/**` (see `WebConfig`). +- **Request ID:** ✅ `RequestIdFilter` assigns/propagates `X-Request-Id`. - **API versioning:** Keep /api/v1; when introducing breaking changes, add /api/v2 and document deprecation. +- **Credential types:** ✅ `@Pattern` on `CredentialSyncRequest.credentialType` (`SmoaCredentialType.CREDENTIAL_TYPE_PATTERN`). --- @@ -102,4 +103,4 @@ The backend implements the **sync contract** expected by the mobile app (POST sy ## Summary -The backend is **ready for mobile sync** with: push and **delete** sync, **pull/GET** endpoints, **conflict handling**, **enum validation**, **rate limiting**, **audit logging**, **tests**, and a **Dockerfile**. Remaining optional work: **prod profile and DB** (PostgreSQL, H2 console off), **unit/tenant scoping** (filter by unit from API key or header), and **migrations** (Flyway/Liquibase with ddl-auto: validate). +The backend is **ready for mobile sync** with: push and **delete** sync, **pull/GET** endpoints (with **ETag**), **conflict handling**, **enum validation** (including **credential types**), **rate limiting**, **audit logging**, **request IDs**, **tests**, **Dockerfile**, **prod profile**, **PostgreSQL driver**, and **Flyway** with `ddl-auto: validate` in prod. Remaining: **strong tenant isolation** (per-tenant credentials, RLS, or JWT claims) — see `docs/security/TENANT-THREAT-MODEL.md`. diff --git a/backend/docs/LXC-PROXMOX-CONTAINERS.md b/backend/docs/LXC-PROXMOX-CONTAINERS.md new file mode 100644 index 0000000..8e7020c --- /dev/null +++ b/backend/docs/LXC-PROXMOX-CONTAINERS.md @@ -0,0 +1,168 @@ +# Proxmox LXC layout for SMOA backend and client endpoint mapping + +This document describes **Linux containers (LXC)** on a **Proxmox VE guest VM** used to host all SMOA backend-related components, how **FQDNs** map to those services, and a **JSON contract** operators can use as the canonical list of endpoints and settings for client builds or a future runtime bootstrap. + +For **hardware sizing** of the same components (VM-level), see [../../docs/infrastructure/PROXMOX-VE-TEMPLATE-REQUIREMENTS.md](../../docs/infrastructure/PROXMOX-VE-TEMPLATE-REQUIREMENTS.md). For API behavior, see [../README.md](../README.md). + +--- + +## 1. Design goals + +- **Isolation:** Separate OS instances for edge (TLS, static files), API (Spring Boot), and database (PostgreSQL). +- **Clear DNS:** One stable FQDN per outward-facing role; internal-only names for DB and API when the edge reverse-proxies to them. +- **Client contract:** A single JSON document per environment (e.g. production, staging) that lists base URLs and optional WebRTC-related settings, aligned with Android `BuildConfig` fields today (`SMOA_BACKEND_BASE_URL`, `SMOA_API_KEY`, `SMOA_STUN_URLS`, `SMOA_SIGNALING_URLS`). + +--- + +## 2. Recommended LXC inventory + +Run these on a single Proxmox **VM** (the “infrastructure VM”) or spread across multiple VMs if you need stronger isolation. Each row is one **unprivileged LXC** (recommended) on Debian 12 or Ubuntu 22.04 LTS templates. + +| CT ID (example) | Hostname (internal) | Role | Outward FQDN (example) | Listens (internal) | Notes | +|-----------------|---------------------|------|-------------------------|---------------------|--------| +| **100** | `smoa-edge` | Reverse proxy + TLS + config CDN | `api.smoa.example.gov` (API), `config.smoa.example.gov` (JSON) | `443` (public), upstream to API | Nginx, Caddy, or Traefik; terminates TLS; serves `/.well-known/` or `/smoa/` static JSON. | +| **101** | `smoa-api` | Spring Boot `smoa-backend` | (none public; only via edge) | `8080` → proxy to `http://smoa-api.lan:8080` | `SPRING_PROFILES_ACTIVE=prod`, `SMOA_API_KEY`, JDBC URL to DB container. | +| **102** | `smoa-db` | PostgreSQL 15+ | (none public) | `5432` (LAN only) | Database for production; Flyway migrations from the backend JAR. | +| **103** | `smoa-turn` | Coturn or equivalent TURN | `turn.smoa.example.gov` | `3478` (UDP/TCP), TLS if used | Optional; for WebRTC media relay when not using a third-party TURN. | +| **104** | `smoa-signal` | Signaling server (if self-hosted) | `signal.smoa.example.gov` | `443` or app-specific | Optional; only if you host signaling instead of a SaaS or peer mesh. | + +**Minimal production set:** **100 (edge)**, **101 (api)**, **102 (db)**. Add **103/104** when meetings/WebRTC are anchored to your infra. + +**Small lab / pilot:** One LXC running API + PostgreSQL + Nginx (all-in-one) is acceptable; split into the table above before production traffic or compliance review. + +--- + +## 3. Resource hints per container + +| Container | vCPU | RAM | Root disk | Data volume | +|-----------|------|-----|-----------|-------------| +| `smoa-edge` | 1 | 512 MiB–1 GiB | 8–16 GiB | Optional: ACME cert store, access logs | +| `smoa-api` | 2–4 | 2–4 GiB | 16 GiB | Optional: app logs if not shipped to syslog | +| `smoa-db` | 2 | 2–4 GiB | 16 GiB | **Dedicated:** PostgreSQL data (SSD-backed) | +| `smoa-turn` | 2–4 | 1–2 GiB | 10 GiB | Logs; sizing scales with concurrent sessions | +| `smoa-signal` | 1–2 | 1–2 GiB | 10 GiB | App-specific | + +Tune using [PROXMOX-VE-TEMPLATE-REQUIREMENTS.md](../../docs/infrastructure/PROXMOX-VE-TEMPLATE-REQUIREMENTS.md). + +--- + +## 4. Networking on Proxmox + +1. **Bridge:** Attach all LXCs to the same VM bridge (e.g. `vmbr0` inside the guest, or a Proxmox bridge on the host with a VLAN per tenant). +2. **DNS (internal):** Resolve `smoa-api.lan`, `smoa-db.lan` (or your internal suffix) to static LXC IPs. The edge proxies to `http://smoa-api.lan:8080` only from the edge host. +3. **Firewall:** From the internet, allow **443** (and **3478** if TURN is public). **Do not** publish PostgreSQL or raw API :8080 to untrusted networks. +4. **TLS:** Issue certificates on `smoa-edge` (Let’s Encrypt internal ACME or org PKI) for every public FQDN. + +--- + +## 5. FQDN → service mapping (operator checklist) + +Use this table when creating DNS records and reverse-proxy `server_name` / upstream blocks. + +| FQDN | Points to | Backend / purpose | +|------|-----------|-------------------| +| `api.smoa.example.gov` | `smoa-edge` public IP | Proxy `location /` → `http://smoa-api.lan:8080` (Spring Boot context path `/`). Health: `GET /health`, sync: `/api/v1/...`. | +| `config.smoa.example.gov` | `smoa-edge` public IP | Static file or small JSON generator: **client config** (see §6). Prefer path such as `https://config.smoa.example.gov/smoa/client-config.json`. | +| `turn.smoa.example.gov` | `smoa-turn` (or edge with UDP pass-through) | TURN `urls` for ICE (often `turn:turn.smoa.example.gov:3478?transport=udp`). | +| `signal.smoa.example.gov` | `smoa-signal` | WebSocket/WebRTC signaling base URL(s) if applicable. | + +**Android app today:** Retrofit uses `SMOA_BACKEND_BASE_URL`, which should be the **public API base**, e.g. `https://api.smoa.example.gov/` (trailing slash optional at build; the app normalizes). See `app/build.gradle.kts` and `AppModule.kt`. + +--- + +## 6. Client configuration JSON + +### 6.1 Purpose + +- **Operations:** Single file per environment describing which FQDNs clients should use. +- **Build pipeline:** CI reads this JSON (or a copy in git) and passes Gradle properties: + `-Psmoa.backend.baseUrl=...` `-Psmoa.api.key=...` `-Psmoa.stun.urls=...` `-Psmoa.signaling.urls=...` +- **Future:** The app could fetch this URL at first launch (bootstrap) and cache settings; that is not implemented in the current codebase—this document defines the **contract** so you can implement it or generate `BuildConfig` consistently. + +### 6.2 Security notes + +- **Do not** put long-lived API keys in a **world-readable** JSON on the public internet unless your threat model accepts it (e.g. key is only a device enrollment token with tight scope). Prefer: public JSON without secrets + **MDM / enterprise** delivery of `api_key`, or short-lived tokens from an identity service. +- Serve `client-config.json` over **HTTPS** only. +- Optionally add a **`config_signature`** (JWS) or host the file on object storage with signed URLs. + +### 6.3 Schema (informal) + +| Field | Type | Required | Maps to Android (today) | Description | +|-------|------|----------|-------------------------|-------------| +| `schema_version` | string | yes | — | e.g. `"1.0"`. Bump when fields change. | +| `environment` | string | yes | — | e.g. `"production"`, `"staging"`. | +| `api_base_url` | string (URL) | yes | `SMOA_BACKEND_BASE_URL` | Public REST base, e.g. `https://api.smoa.example.gov` (no trailing slash required). | +| `api_key` | string \| null | no | `SMOA_API_KEY` | Omit or null if auth is device-only or injected elsewhere. | +| `stun_urls` | string | no | `SMOA_STUN_URLS` | Comma-separated STUN URLs for ICE, e.g. `stun:stun.l.google.com:19302`. | +| `signaling_urls` | string | no | `SMOA_SIGNALING_URLS` | Comma-separated signaling/WebSocket bases if used by communications module. | +| `turn_urls` | string | no | (future / custom) | Comma-separated TURN URLs, e.g. `turn:turn.smoa.example.gov:3478?transport=udp`. | +| `well_known_health_url` | string | no | — | Optional; e.g. `https://api.smoa.example.gov/health` for monitoring scripts. | +| `openapi_url` | string | no | — | Optional; e.g. `https://api.smoa.example.gov/swagger-ui.html` for admins. | + +### 6.4 Example: `client-config.json` + +Host at a stable URL, e.g. `https://config.smoa.example.gov/smoa/client-config.json`. A copy-checked example lives in this repo at [examples/smoa-client-config.example.json](examples/smoa-client-config.example.json). + +```json +{ + "schema_version": "1.0", + "environment": "production", + "api_base_url": "https://api.smoa.example.gov", + "api_key": null, + "stun_urls": "stun:stun.l.google.com:19302", + "signaling_urls": "wss://signal.smoa.example.gov/ws", + "turn_urls": "turn:turn.smoa.example.gov:3478?transport=udp", + "well_known_health_url": "https://api.smoa.example.gov/health", + "openapi_url": "https://api.smoa.example.gov/swagger-ui.html" +} +``` + +**Gradle mapping example (CI or local):** + +```text +-Psmoa.backend.baseUrl=https://api.smoa.example.gov +-Psmoa.api.key= +-Psmoa.stun.urls=stun:stun.l.google.com:19302 +-Psmoa.signaling.urls=wss://signal.smoa.example.gov/ws +``` + +--- + +## 7. `smoa-api` environment variables (reference) + +Typical production settings for LXC **101** (adjust names to match your JDBC host): + +| Variable | Example | +|----------|---------| +| `SPRING_PROFILES_ACTIVE` | `prod` | +| `SERVER_PORT` | `8080` | +| `SMOA_API_KEY` | `` | +| `SMOA_CORS_ORIGINS` | `https://trusted-web-origin.example.gov` | +| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://smoa-db.lan:5432/smoa` | +| `SPRING_DATASOURCE_USERNAME` | `smoa` | +| `SPRING_DATASOURCE_PASSWORD` | `` | +| `SPRING_DATASOURCE_DRIVER_CLASS_NAME` | `org.postgresql.Driver` | + +Ensure `application-prod.yml` (or overrides) use PostgreSQL and `ddl-auto: validate` with Flyway, per [../README.md](../README.md). + +--- + +## 8. Quick Proxmox LXC creation notes + +1. Create CT from Debian 12 / Ubuntu 22.04 template; enable **nesting** only if you run Docker inside the CT (not required for JAR + systemd). +2. **Static DHCP or CT config:** Fixed IPv4 for `smoa-api`, `smoa-db`, `smoa-edge`. +3. **Backups:** Include `smoa-db` data volume in Proxmox backup jobs; test restore. +4. **Updates:** Patch guest OS per org baseline; restart API after JVM or jar updates. + +--- + +## 9. Related paths in this repository + +| Topic | Location | +|-------|----------| +| Backend API and env | [../README.md](../README.md) | +| Docker image (optional alternative to bare JAR in LXC) | [../Dockerfile](../Dockerfile) | +| Proxmox VM sizing | [../../docs/infrastructure/PROXMOX-VE-TEMPLATE-REQUIREMENTS.md](../../docs/infrastructure/PROXMOX-VE-TEMPLATE-REQUIREMENTS.md) | +| Android backend URL wiring | `app/build.gradle.kts`, `app/.../di/AppModule.kt` | + +This file is the **LXC + FQDN + JSON contract** reference; keep it updated when you add services (e.g. object storage, IdP callbacks, or push notification gateways). diff --git a/backend/docs/examples/smoa-client-config.example.json b/backend/docs/examples/smoa-client-config.example.json new file mode 100644 index 0000000..7a313b0 --- /dev/null +++ b/backend/docs/examples/smoa-client-config.example.json @@ -0,0 +1,14 @@ +{ + "schema_version": "1.0", + "environment": "production", + "api_base_url": "https://api.smoa.example.gov", + "api_key": null, + "stun_urls": "stun:stun.l.google.com:19302", + "signaling_urls": "wss://signal.smoa.example.gov/ws", + "turn_urls": "turn:turn.smoa.example.gov:3478?transport=udp", + "well_known_health_url": "https://api.smoa.example.gov/health", + "openapi_url": "https://api.smoa.example.gov/swagger-ui.html", + "tls_pin_spec": "api.smoa.example.gov|sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "classification_watermark_primary": "OFFICIAL USE ONLY", + "classification_watermark_secondary": "CONTROLLED UNCLASSIFIED" +} diff --git a/backend/src/main/kotlin/com/smoa/backend/api/CredentialController.kt b/backend/src/main/kotlin/com/smoa/backend/api/CredentialController.kt new file mode 100644 index 0000000..780017b --- /dev/null +++ b/backend/src/main/kotlin/com/smoa/backend/api/CredentialController.kt @@ -0,0 +1,41 @@ +package com.smoa.backend.api + +import com.smoa.backend.api.dto.CredentialDetailItem +import com.smoa.backend.api.dto.CredentialSyncRequest +import com.smoa.backend.api.dto.SyncResponse +import com.smoa.backend.service.CredentialSyncService +import com.smoa.backend.service.SyncAuditService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +/** + * Authoritative credential issuance (POST) and full record retrieval (GET by id). + * List without payload remains on [PullController] GET /api/v1/credentials. + */ +@RestController +@RequestMapping("/api/v1/credentials") +@Tag(name = "Credentials", description = "Issue and retrieve full credential records") +class CredentialController( + private val credentialSyncService: CredentialSyncService, + private val syncAuditService: SyncAuditService +) { + + @GetMapping("/{credentialId}") + @Operation(summary = "Get credential by id including payload JSON") + fun getCredential(@PathVariable credentialId: String): ResponseEntity { + val entity = credentialSyncService.getById(credentialId) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(CredentialDetailItem.from(entity)) + } + + @PostMapping(consumes = [MediaType.APPLICATION_JSON_VALUE]) + @Operation(summary = "Issue or upsert a credential (same semantics as POST /api/v1/sync/credential)") + fun issueCredential(@Valid @RequestBody request: CredentialSyncRequest): ResponseEntity { + val response = credentialSyncService.sync(request) + syncAuditService.log("credential", request.credentialId, "issue", response.success) + return ResponseEntity.ok(response) + } +} diff --git a/backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt b/backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt new file mode 100644 index 0000000..da2c2c5 --- /dev/null +++ b/backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt @@ -0,0 +1,39 @@ +package com.smoa.backend.api + +import com.smoa.backend.api.dto.IntegrityVerifyRequest +import com.smoa.backend.api.dto.IntegrityVerifyResponse +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Play Integrity token verification (Android). Production wiring requires Google API credentials + * and nonce lifecycle; this endpoint documents the contract and returns 501 until configured. + */ +@RestController +@RequestMapping("/api/v1/integrity") +class IntegrityAttestationController { + + @PostMapping("/verify") + fun verify(@RequestBody body: IntegrityVerifyRequest): ResponseEntity { + if (body.token.isBlank()) { + return ResponseEntity.badRequest().body( + IntegrityVerifyResponse( + verified = false, + message = "token is required" + ) + ) + } + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body( + IntegrityVerifyResponse( + verified = false, + message = "Play Integrity verification is not enabled on this server. " + + "Configure Google Play Integrity API credentials and implement DecryptIntegrityToken " + + "(see backend docs / pairs with Android PlayIntegrityVerifier)." + ) + ) + } +} diff --git a/backend/src/main/kotlin/com/smoa/backend/api/dto/CredentialDetailItem.kt b/backend/src/main/kotlin/com/smoa/backend/api/dto/CredentialDetailItem.kt new file mode 100644 index 0000000..80a0dec --- /dev/null +++ b/backend/src/main/kotlin/com/smoa/backend/api/dto/CredentialDetailItem.kt @@ -0,0 +1,28 @@ +package com.smoa.backend.api.dto + +import com.smoa.backend.domain.CredentialEntity + +/** Full credential for GET /api/v1/credentials/{credentialId} (includes presentation payload). */ +data class CredentialDetailItem( + val credentialId: String, + val holderId: String, + val credentialType: String, + val issuer: String?, + val issuedAt: Long?, + val expiresAt: Long?, + val payloadJson: String?, + val updatedAt: Long +) { + companion object { + fun from(e: CredentialEntity) = CredentialDetailItem( + credentialId = e.credentialId, + holderId = e.holderId, + credentialType = e.credentialType, + issuer = e.issuer, + issuedAt = e.issuedAt, + expiresAt = e.expiresAt, + payloadJson = e.payloadJson, + updatedAt = e.updatedAt + ) + } +} diff --git a/backend/src/main/kotlin/com/smoa/backend/api/dto/IntegrityVerifyRequest.kt b/backend/src/main/kotlin/com/smoa/backend/api/dto/IntegrityVerifyRequest.kt new file mode 100644 index 0000000..9cae5b8 --- /dev/null +++ b/backend/src/main/kotlin/com/smoa/backend/api/dto/IntegrityVerifyRequest.kt @@ -0,0 +1,13 @@ +package com.smoa.backend.api.dto + +import com.fasterxml.jackson.annotation.JsonProperty + +data class IntegrityVerifyRequest( + @JsonProperty("token") val token: String, + @JsonProperty("nonce") val nonce: String? = null +) + +data class IntegrityVerifyResponse( + @JsonProperty("verified") val verified: Boolean, + @JsonProperty("message") val message: String +) diff --git a/backend/src/main/kotlin/com/smoa/backend/api/dto/SyncRequest.kt b/backend/src/main/kotlin/com/smoa/backend/api/dto/SyncRequest.kt index f1a9313..91cd426 100644 --- a/backend/src/main/kotlin/com/smoa/backend/api/dto/SyncRequest.kt +++ b/backend/src/main/kotlin/com/smoa/backend/api/dto/SyncRequest.kt @@ -1,5 +1,6 @@ package com.smoa.backend.api.dto +import com.smoa.backend.domain.SmoaCredentialType import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Pattern @@ -53,11 +54,21 @@ data class EvidenceSyncRequest( val clientUpdatedAt: Long? = null ) -/** Request body for credential sync. Generic payload for issued credentials. */ +/** + * Request body for credential sync. Generic payload for issued credentials. + * Prefer [com.smoa.backend.domain.SmoaCredentialType] values for [credentialType]; + * optional payload key [com.smoa.backend.domain.SmoaCredentialType.PAYLOAD_KEY_CC_DOMAIN] + * mirrors Complete Credential OpenAPI CredentialRef.domain. + */ data class CredentialSyncRequest( @field:NotBlank val credentialId: String, @field:NotBlank val holderId: String, - @field:NotBlank val credentialType: String, + @field:NotBlank + @field:Pattern( + regexp = SmoaCredentialType.CREDENTIAL_TYPE_PATTERN, + message = "credentialType must be a known template (see SmoaCredentialType / IDENTITY-TEMPLATE-ALIGNMENT.md)" + ) + val credentialType: String, val issuer: String? = null, val issuedAt: Long? = null, val expiresAt: Long? = null, diff --git a/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt b/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt index c6b9081..a911de8 100644 --- a/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt +++ b/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component import org.springframework.web.filter.OncePerRequestFilter /** - * When smoa.tenant.require-unit is true, requires X-Unit header for /api/v1/sync and /api/v1/* (pull) requests. + * When smoa.tenant.require-unit is true, requires X-Unit header for /api/v1/sync and other /api/v1 pull routes. * Returns 400 if unit is required but missing. */ @Component diff --git a/backend/src/main/kotlin/com/smoa/backend/config/WebConfig.kt b/backend/src/main/kotlin/com/smoa/backend/config/WebConfig.kt index 934dc6d..21edf37 100644 --- a/backend/src/main/kotlin/com/smoa/backend/config/WebConfig.kt +++ b/backend/src/main/kotlin/com/smoa/backend/config/WebConfig.kt @@ -22,7 +22,7 @@ class WebConfig : WebMvcConfigurer { .allowCredentials(if (allowedOrigins == "*") false else true) } - /** ETag support for GET /api/v1/*: sets ETag header and returns 304 when If-None-Match matches. */ + /** ETag support for GET requests under /api/v1; sets ETag header and returns 304 when If-None-Match matches. */ @Bean fun etagFilter(): FilterRegistrationBean { val filter = ShallowEtagHeaderFilter() diff --git a/backend/src/main/kotlin/com/smoa/backend/domain/SmoaCredentialType.kt b/backend/src/main/kotlin/com/smoa/backend/domain/SmoaCredentialType.kt new file mode 100644 index 0000000..f3bc2cb --- /dev/null +++ b/backend/src/main/kotlin/com/smoa/backend/domain/SmoaCredentialType.kt @@ -0,0 +1,44 @@ +package com.smoa.backend.domain + +/** + * Canonical `credentialType` / `credentials.credential_type` values, aligned with + * Complete Credential `CredentialRef.domain` and SMOA barcode templates. + * + * Keep in sync with `SmoaCredentialTemplateIds` in the Android credentials module and + * `docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md`. + */ +object SmoaCredentialType { + const val PIV_PKI = "piv_pki" + const val PAYMENT = "payment" + const val NFC_ACCESS = "nfc_access" + const val MOBILE = "mobile" + + const val ICAO9303_MRTD = "icao9303_mrtd" + const val AAMVA_DLID = "aamva_dlid" + const val MIL_STD_129 = "mil_std_129" + const val AGENCY_BADGE = "agency_badge" + + val CANONICAL: Set = setOf( + PIV_PKI, + PAYMENT, + NFC_ACCESS, + MOBILE, + ICAO9303_MRTD, + AAMVA_DLID, + MIL_STD_129, + AGENCY_BADGE + ) + + /** Legacy API / docs values; prefer [CANONICAL]. */ + val LEGACY: Set = setOf("id", "badge", "license", "permit", "other") + + val ALL_ALLOWED: Set = CANONICAL + LEGACY + + /** + * Bean Validation [@Pattern] for [com.smoa.backend.api.dto.CredentialSyncRequest.credentialType]. + */ + const val CREDENTIAL_TYPE_PATTERN = + "^(piv_pki|payment|nfc_access|mobile|icao9303_mrtd|aamva_dlid|mil_std_129|agency_badge|id|badge|license|permit|other)$" + + const val PAYLOAD_KEY_CC_DOMAIN = "completeCredentialDomain" +} diff --git a/backend/src/main/kotlin/com/smoa/backend/service/CredentialSyncService.kt b/backend/src/main/kotlin/com/smoa/backend/service/CredentialSyncService.kt index d619f60..301de02 100644 --- a/backend/src/main/kotlin/com/smoa/backend/service/CredentialSyncService.kt +++ b/backend/src/main/kotlin/com/smoa/backend/service/CredentialSyncService.kt @@ -76,4 +76,7 @@ class CredentialSyncService( else -> credentialRepository.findAll(page).content } } + + fun getById(credentialId: String): CredentialEntity? = + credentialRepository.findById(credentialId).orElse(null) } diff --git a/backend/src/main/kotlin/com/smoa/backend/service/ReportSyncService.kt b/backend/src/main/kotlin/com/smoa/backend/service/ReportSyncService.kt index 0ef9903..9587e4a 100644 --- a/backend/src/main/kotlin/com/smoa/backend/service/ReportSyncService.kt +++ b/backend/src/main/kotlin/com/smoa/backend/service/ReportSyncService.kt @@ -1,5 +1,6 @@ package com.smoa.backend.service +import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper import com.smoa.backend.api.dto.ReportSyncRequest import com.smoa.backend.api.dto.SyncResponse @@ -22,15 +23,26 @@ class ReportSyncService( val existing = reportRepository.findById(request.reportId).orElse(null) if (existing != null && request.clientUpdatedAt != null && existing.updatedAt > request.clientUpdatedAt) { - val remoteMap = mapOf( - "reportId" to existing.reportId, - "reportType" to existing.reportType, - "title" to existing.title, - "format" to existing.format, - "generatedDate" to existing.generatedDate, - "generatedBy" to existing.generatedBy, - "updatedAt" to existing.updatedAt - ) + val remoteMap = buildMap { + put("reportId", existing.reportId) + put("reportType", existing.reportType) + put("title", existing.title) + put("format", existing.format) + put("generatedDate", existing.generatedDate) + put("generatedBy", existing.generatedBy) + put("updatedAt", existing.updatedAt) + existing.content?.let { bytes -> + put("contentBase64", Base64.getEncoder().encodeToString(bytes)) + } + existing.metadataJson?.trim()?.takeIf { it.isNotEmpty() }?.let { mj -> + runCatching { + put( + "metadata", + objectMapper.readValue(mj, object : TypeReference>() {}) + ) + } + } + } val remoteData = objectMapper.writeValueAsBytes(remoteMap) return SyncResponse( success = false, diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index b88dd02..c0ebbd4 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -1,9 +1,15 @@ # Production profile: secure defaults, no H2 console, validate schema spring: + datasource: + url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/smoa} + username: ${SPRING_DATASOURCE_USERNAME:smoa} + password: ${SPRING_DATASOURCE_PASSWORD:} + driver-class-name: ${SPRING_DATASOURCE_DRIVER_CLASS_NAME:org.postgresql.Driver} jpa: show-sql: false hibernate: ddl-auto: validate + database-platform: ${SPRING_JPA_DATABASE_PLATFORM:org.hibernate.dialect.PostgreSQLDialect} h2: console: enabled: false @@ -11,8 +17,8 @@ spring: enabled: true baseline-on-migrate: true -# In prod: set SMOA_API_KEY (required for auth), SMOA_CORS_ORIGINS for web client. -# Switch datasource to PostgreSQL: spring.datasource.url=jdbc:postgresql://... driver-class-name=org.postgresql.Driver +# Set SPRING_DATASOURCE_* (or spring.datasource.*) for PostgreSQL. Driver on classpath via backend/build.gradle.kts. +# SMOA_API_KEY, SMOA_CORS_ORIGINS — see backend README. logging: level: root: WARN diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 305e804..e3bf090 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -16,15 +16,11 @@ spring: hibernate: ddl-auto: update show-sql: false + open-in-view: false flyway: enabled: true locations: classpath:db/migration baseline-on-migrate: true - properties: - hibernate: - format_sql: true - default_schema: PUBLIC - open-in-view: false server: port: ${SERVER_PORT:8080} diff --git a/backend/src/test/kotlin/com/smoa/backend/api/IntegrityAttestationControllerTest.kt b/backend/src/test/kotlin/com/smoa/backend/api/IntegrityAttestationControllerTest.kt new file mode 100644 index 0000000..0a2020f --- /dev/null +++ b/backend/src/test/kotlin/com/smoa/backend/api/IntegrityAttestationControllerTest.kt @@ -0,0 +1,48 @@ +package com.smoa.backend.api + +import com.fasterxml.jackson.databind.ObjectMapper +import com.smoa.backend.api.dto.IntegrityVerifyRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class IntegrityAttestationControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + private val objectMapper = ObjectMapper() + + @Test + fun `POST verify with token returns 501 not implemented`() { + val body = IntegrityVerifyRequest(token = "dummy-integrity-token", nonce = "n1") + mockMvc.perform( + post("/api/v1/integrity/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + ) + .andExpect(status().isNotImplemented) + .andExpect(jsonPath("$.verified").value(false)) + } + + @Test + fun `POST verify with blank token returns 400`() { + val body = IntegrityVerifyRequest(token = " ") + mockMvc.perform( + post("/api/v1/integrity/verify") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + ) + .andExpect(status().isBadRequest) + } +} diff --git a/backend/src/test/kotlin/com/smoa/backend/api/SyncControllerIntegrationTest.kt b/backend/src/test/kotlin/com/smoa/backend/api/SyncControllerIntegrationTest.kt index df84409..9dc81e8 100644 --- a/backend/src/test/kotlin/com/smoa/backend/api/SyncControllerIntegrationTest.kt +++ b/backend/src/test/kotlin/com/smoa/backend/api/SyncControllerIntegrationTest.kt @@ -63,4 +63,66 @@ class SyncControllerIntegrationTest { .andExpect(status().isOk) .andExpect(jsonPath("$.application").value("smoa-backend")) } + + @Test + fun `POST sync credential with valid credentialType returns 200`() { + val body = mapOf( + "credentialId" to "cred-int-1", + "holderId" to "holder-1", + "credentialType" to "piv_pki", + "issuer" to "Test CA" + ) + mockMvc.perform( + post("/api/v1/sync/credential") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.itemId").value("cred-int-1")) + } + + @Test + fun `POST sync credential with unknown credentialType returns 400`() { + val body = mapOf( + "credentialId" to "cred-int-2", + "holderId" to "holder-1", + "credentialType" to "unknown_template_xyz" + ) + mockMvc.perform( + post("/api/v1/sync/credential") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + ) + .andExpect(status().isBadRequest) + } + + @Test + fun `POST issue credential at REST path returns 200 and GET returns payload`() { + val id = "cred-detail-1" + val body = mapOf( + "credentialId" to id, + "holderId" to "holder-1", + "credentialType" to "icao9303_mrtd", + "issuer" to "State Dept", + "payload" to mapOf("documentNumber" to "AB1234567", "nationality" to "UTO") + ) + mockMvc.perform( + post("/api/v1/credentials") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + mockMvc.perform(get("/api/v1/credentials/$id")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.credentialType").value("icao9303_mrtd")) + .andExpect(jsonPath("$.payloadJson").exists()) + } + + @Test + fun `GET credential returns 404 when missing`() { + mockMvc.perform(get("/api/v1/credentials/nonexistent-credential-id")) + .andExpect(status().isNotFound) + } } diff --git a/backend/src/test/kotlin/com/smoa/backend/service/DirectorySyncServiceTest.kt b/backend/src/test/kotlin/com/smoa/backend/service/DirectorySyncServiceTest.kt index d0cd8ac..3dd62b2 100644 --- a/backend/src/test/kotlin/com/smoa/backend/service/DirectorySyncServiceTest.kt +++ b/backend/src/test/kotlin/com/smoa/backend/service/DirectorySyncServiceTest.kt @@ -4,11 +4,14 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.smoa.backend.api.dto.DirectorySyncRequest import com.smoa.backend.domain.DirectoryEntity import com.smoa.backend.repository.DirectoryRepository +import io.mockk.Runs import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test +import java.util.Optional class DirectorySyncServiceTest { @@ -18,7 +21,7 @@ class DirectorySyncServiceTest { @Test fun `sync creates new entry and returns success`() { - every { directoryRepository.findById("id1") } returns null + every { directoryRepository.findById("id1") } returns Optional.empty() every { directoryRepository.save(any()) } returnsArgument 0 val request = DirectorySyncRequest( @@ -84,7 +87,7 @@ class DirectorySyncServiceTest { @Test fun `delete removes existing entry`() { every { directoryRepository.existsById("id1") } returns true - every { directoryRepository.deleteById("id1") } just runs + every { directoryRepository.deleteById("id1") } just Runs val response = service.delete("id1") diff --git a/backend/src/test/kotlin/com/smoa/backend/service/ReportSyncServiceTest.kt b/backend/src/test/kotlin/com/smoa/backend/service/ReportSyncServiceTest.kt new file mode 100644 index 0000000..5858f93 --- /dev/null +++ b/backend/src/test/kotlin/com/smoa/backend/service/ReportSyncServiceTest.kt @@ -0,0 +1,55 @@ +package com.smoa.backend.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.smoa.backend.api.dto.ReportSyncRequest +import com.smoa.backend.domain.ReportEntity +import com.smoa.backend.repository.ReportRepository +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.Base64 +import java.util.Optional + +class ReportSyncServiceTest { + + private val reportRepository = mockk(relaxed = true) + private val objectMapper = ObjectMapper() + private val service = ReportSyncService(reportRepository, objectMapper) + + @Test + fun `conflict remoteData includes contentBase64 and metadata when present`() { + val content = byteArrayOf(1, 2, 3) + val existing = ReportEntity( + reportId = "r1", + reportType = "OPERATIONAL", + title = "T", + format = "PDF", + generatedDate = 100L, + generatedBy = "u1", + content = content, + metadataJson = """{"k":"v"}""", + updatedAt = 500L + ) + every { reportRepository.findById("r1") } returns Optional.of(existing) + + val request = ReportSyncRequest( + reportId = "r1", + reportType = "OPERATIONAL", + title = "T", + format = "PDF", + generatedDate = 100L, + generatedBy = "u1", + clientUpdatedAt = 100L + ) + val response = service.sync(request) + + assertFalse(response.success) + assertTrue(response.conflict) + val remote = objectMapper.readValue(response.remoteData, Map::class.java) + assertEquals(Base64.getEncoder().encodeToString(content), remote["contentBase64"]) + assertEquals("v", (remote["metadata"] as Map<*, *>)["k"]) + } +} diff --git a/backend/src/test/resources/application-test.yml b/backend/src/test/resources/application-test.yml new file mode 100644 index 0000000..e80c082 --- /dev/null +++ b/backend/src/test/resources/application-test.yml @@ -0,0 +1,17 @@ +# Integration tests: in-memory H2, no Flyway (avoids H2/Flyway edge cases in test JVM). +spring: + datasource: + url: jdbc:h2:mem:smoa_test;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: false + flyway: + enabled: false + +smoa: + api: + key: "" diff --git a/build.gradle.kts b/build.gradle.kts index a1f63a8..9ea272a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,3 +10,9 @@ tasks.register("clean", Delete::class) { delete(rootProject.buildDir) } +/** One-shot: backend, core, and Android verification (requires Android SDK for :app). */ +tasks.register("smoaVerify") { + group = "verification" + description = "Runs :backend:test, :app:testDebugUnitTest, and :app:assembleDebug" + dependsOn(":backend:test", ":app:testDebugUnitTest", ":app:assembleDebug") +} diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 943fc18..cb1f655 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -38,6 +38,9 @@ object Dependencies { // AndroidX Core const val androidxCoreKtx = "androidx.core:core-ktx:${Versions.androidxCore}" const val androidxLifecycleRuntimeKtx = "androidx.lifecycle:lifecycle-runtime-ktx:${Versions.androidxLifecycle}" + const val androidxLifecycleViewmodelKtx = "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.androidxLifecycle}" + const val androidxLifecycleViewmodelCompose = "androidx.lifecycle:lifecycle-viewmodel-compose:${Versions.androidxLifecycle}" + const val androidxLifecycleRuntimeCompose = "androidx.lifecycle:lifecycle-runtime-compose:${Versions.androidxLifecycle}" const val androidxActivityCompose = "androidx.activity:activity-compose:${Versions.androidxActivity}" // Compose @@ -55,7 +58,8 @@ object Dependencies { // Hilt const val hiltAndroid = "com.google.dagger:hilt-android:${Versions.hilt}" - const val hiltAndroidCompiler = "com.google.dagger:hilt-android-compiler:${Versions.hilt}" + const val hiltAndroidCompiler = "com.google.dagger:hilt-compiler:${Versions.hilt}" + const val hiltAndroidTesting = "com.google.dagger:hilt-android-testing:${Versions.hilt}" const val hiltNavigationCompose = "androidx.hilt:hilt-navigation-compose:${Versions.hiltNavigationCompose}" // Room @@ -67,6 +71,7 @@ object Dependencies { const val retrofit = "com.squareup.retrofit2:retrofit:${Versions.retrofit}" const val retrofitGson = "com.squareup.retrofit2:converter-gson:${Versions.retrofit}" const val okHttp = "com.squareup.okhttp3:okhttp:${Versions.okHttp}" + const val mockWebServer = "com.squareup.okhttp3:mockwebserver:${Versions.okHttp}" const val okHttpLogging = "com.squareup.okhttp3:logging-interceptor:${Versions.okHttp}" // Security @@ -76,6 +81,10 @@ object Dependencies { // Coroutines const val coroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}" const val coroutinesAndroid = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutines}" + const val coroutinesPlayServices = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:${Versions.coroutines}" + + const val lifecycleProcess = "androidx.lifecycle:lifecycle-process:${Versions.androidxLifecycle}" + const val playIntegrity = "com.google.android.play:integrity:1.3.0" // Barcode const val zxingCore = "com.google.zxing:core:${Versions.zxing}" diff --git a/core/as4/README.md b/core/as4/README.md new file mode 100644 index 0000000..2e821ea --- /dev/null +++ b/core/as4/README.md @@ -0,0 +1,10 @@ +# AS4 / ebMS module + +Current code builds **AS4 message-shaped domain objects** and audit hooks. A production AS4 endpoint needs: + +1. **Apache CXF** – SOAP client/server, WS-Policy, WS-Addressing +2. **Santuario (xmlsec)** – XMLDSig and optional XMLEnc +3. **Partner CPA** – agreement on service, action, P-Mode, retry, receipts +4. **WS-ReliableMessaging** – sequence and duplicate elimination per profile + +See `AS4Service` KDoc and [BACKEND-GAPS-AND-ROADMAP.md](../../backend/docs/BACKEND-GAPS-AND-ROADMAP.md) for backend alignment. diff --git a/core/as4/build.gradle.kts b/core/as4/build.gradle.kts index c596a3c..529ab37 100644 --- a/core/as4/build.gradle.kts +++ b/core/as4/build.gradle.kts @@ -1,8 +1,8 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") id("kotlin-kapt") - id("dagger.hilt.android.plugin") } android { @@ -21,6 +21,14 @@ android { kotlinOptions { jvmTarget = "17" } + + hilt { + enableAggregatingTask = true + } +} + +kapt { + correctErrorTypes = true } dependencies { @@ -44,5 +52,8 @@ dependencies { implementation(Dependencies.coroutinesCore) implementation(Dependencies.coroutinesAndroid) -} + // Testing + testImplementation(Dependencies.junit) + testImplementation(Dependencies.mockk) +} diff --git a/core/as4/src/main/java/com/smoa/core/as4/AS4Gateway.kt b/core/as4/src/main/java/com/smoa/core/as4/AS4Gateway.kt index a1bd518..b7ee9a9 100644 --- a/core/as4/src/main/java/com/smoa/core/as4/AS4Gateway.kt +++ b/core/as4/src/main/java/com/smoa/core/as4/AS4Gateway.kt @@ -2,6 +2,8 @@ package com.smoa.core.as4 import com.smoa.core.as4.domain.AS4Service import com.smoa.core.common.Result +import java.security.PrivateKey +import java.security.cert.X509Certificate import javax.inject.Inject /** @@ -12,8 +14,14 @@ 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 { - val messageResult = as4Service.createMessage(fromParty, toParty, payload, null) + suspend fun sendMessage( + fromParty: com.smoa.core.as4.domain.AS4Party, + toParty: com.smoa.core.as4.domain.AS4Party, + payload: ByteArray, + privateKey: PrivateKey, + certificate: X509Certificate + ): Result { + val messageResult = as4Service.createMessage(fromParty, toParty, payload, null, privateKey, certificate) return when (messageResult) { is Result.Success -> { val sendResult = as4Service.sendMessage(messageResult.data) diff --git a/core/as4/src/main/java/com/smoa/core/as4/domain/AS4Service.kt b/core/as4/src/main/java/com/smoa/core/as4/domain/AS4Service.kt index de07cca..7292f8d 100644 --- a/core/as4/src/main/java/com/smoa/core/as4/domain/AS4Service.kt +++ b/core/as4/src/main/java/com/smoa/core/as4/domain/AS4Service.kt @@ -1,40 +1,286 @@ 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 com.smoa.core.security.AuditLogger +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.Signature +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Base64 import java.util.Date import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import org.w3c.dom.Element /** * 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 + * + * The current implementation produces a cryptographically signed envelope and verifies it + * locally, while the actual network transport remains a deployment concern. */ @Singleton class AS4Service @Inject constructor( private val auditLogger: AuditLogger ) { - + private val xmlFactory: DocumentBuilderFactory = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeature("http://xml.org/sax/features/external-general-entities", false) + setFeature("http://xml.org/sax/features/external-parameter-entities", false) + isExpandEntityReferences = false + } + + private fun payloadDigest(payload: ByteArray): String = + Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-256").digest(payload)) + + private fun signingAlgorithmFor(privateKey: PrivateKey): String = when (privateKey.algorithm) { + "RSA" -> "SHA256withRSA" + "EC" -> "SHA256withECDSA" + else -> "SHA256withRSA" + } + + private fun decodeCertificate(certificateData: String): X509Certificate { + val decoded = Base64.getDecoder().decode(certificateData) + val factory = CertificateFactory.getInstance("X.509") + return factory.generateCertificate(ByteArrayInputStream(decoded)) as X509Certificate + } + + private fun canonicalMessageBytes(message: AS4Message): ByteArray { + val builder = StringBuilder() + builder.append("messageId=").append(message.messageId).append('\n') + builder.append("timestamp=").append(message.timestamp.time).append('\n') + builder.append("conversationId=").append(message.conversationId.orEmpty()).append('\n') + builder.append("service=").append(message.service.orEmpty()).append('\n') + builder.append("action=").append(message.action.orEmpty()).append('\n') + builder.append("fromPartyId=").append(message.fromParty.partyId).append('\n') + builder.append("fromPartyRole=").append(message.fromParty.role.orEmpty()).append('\n') + builder.append("toPartyId=").append(message.toParty.partyId).append('\n') + builder.append("toPartyRole=").append(message.toParty.role.orEmpty()).append('\n') + builder.append("payload=").append(Base64.getEncoder().encodeToString(message.payload)).append('\n') + builder.append("signatureMethod=").append(message.security.signature.signatureMethod).append('\n') + builder.append("canonicalizationMethod=").append(message.security.signature.canonicalizationMethod).append('\n') + builder.append("certificate=").append(message.security.certificate).append('\n') + builder.append("encryptionMethod=").append(message.security.encryption?.encryptionMethod.orEmpty()).append('\n') + builder.append("cipherValue=").append(message.security.encryption?.cipherData?.cipherValue.orEmpty()).append('\n') + builder.append("sequenceNumber=").append(message.reliability?.messageSequenceNumber ?: 0L).append('\n') + builder.append("acknowledgmentRequested=").append(message.reliability?.acknowledgmentRequested ?: false).append('\n') + builder.append("duplicateElimination=").append(message.reliability?.duplicateElimination ?: false).append('\n') + message.security.signature.signedInfo.references.forEach { reference -> + builder.append("reference=").append(reference.uri).append('|') + .append(reference.digestMethod).append('|') + .append(reference.digestValue).append('\n') + } + return builder.toString().toByteArray(StandardCharsets.UTF_8) + } + + private fun signMessage(message: AS4Message, privateKey: PrivateKey): AS4Message { + val unsignedMessage = message.copy( + security = message.security.copy( + signature = message.security.signature.copy(signatureValue = "") + ) + ) + val algorithm = signingAlgorithmFor(privateKey) + val signature = Signature.getInstance(algorithm) + signature.initSign(privateKey) + signature.update(canonicalMessageBytes(unsignedMessage)) + val signatureValue = Base64.getEncoder().encodeToString(signature.sign()) + return message.copy( + security = message.security.copy( + signature = message.security.signature.copy( + signatureMethod = algorithm, + signatureValue = signatureValue + ) + ) + ) + } + + private fun verifyMessageSignature(message: AS4Message): Boolean { + val certificate = decodeCertificate(message.security.certificate) + val signatureBytes = Base64.getDecoder().decode(message.security.signature.signatureValue) + val signature = Signature.getInstance(message.security.signature.signatureMethod) + val unsignedMessage = message.copy( + security = message.security.copy( + signature = message.security.signature.copy(signatureValue = "") + ) + ) + signature.initVerify(certificate.publicKey) + signature.update(canonicalMessageBytes(unsignedMessage)) + return signature.verify(signatureBytes) + } + + private fun serializeMessage(message: AS4Message): ByteArray { + val doc = xmlFactory.newDocumentBuilder().newDocument() + val root = doc.createElement("as4Message") + root.setAttribute("messageId", message.messageId) + root.setAttribute("timestamp", message.timestamp.time.toString()) + root.setAttribute("conversationId", message.conversationId ?: "") + root.setAttribute("service", message.service ?: "") + root.setAttribute("action", message.action ?: "") + doc.appendChild(root) + + fun appendParty(name: String, party: AS4Party) { + val node = doc.createElement(name) + node.setAttribute("partyId", party.partyId) + node.setAttribute("role", party.role ?: "") + root.appendChild(node) + } + + appendParty("from", message.fromParty) + appendParty("to", message.toParty) + + val payload = doc.createElement("payload") + payload.setAttribute("encoding", "base64") + payload.textContent = Base64.getEncoder().encodeToString(message.payload) + root.appendChild(payload) + + val security = doc.createElement("security") + val signature = doc.createElement("signature") + signature.setAttribute("signatureMethod", message.security.signature.signatureMethod) + signature.setAttribute("canonicalizationMethod", message.security.signature.canonicalizationMethod) + signature.setAttribute("signatureValue", message.security.signature.signatureValue) + val signedInfo = doc.createElement("signedInfo") + signedInfo.setAttribute("signatureMethod", message.security.signature.signedInfo.signatureMethod) + signedInfo.setAttribute("canonicalizationMethod", message.security.signature.signedInfo.canonicalizationMethod) + message.security.signature.signedInfo.references.forEach { reference -> + val ref = doc.createElement("reference") + ref.setAttribute("uri", reference.uri) + ref.setAttribute("digestMethod", reference.digestMethod) + ref.setAttribute("digestValue", reference.digestValue) + signedInfo.appendChild(ref) + } + signature.appendChild(signedInfo) + security.appendChild(signature) + val cert = doc.createElement("certificate") + cert.textContent = message.security.certificate + security.appendChild(cert) + if (message.security.encryption != null) { + val encryption = doc.createElement("encryption") + encryption.setAttribute("encryptionMethod", message.security.encryption.encryptionMethod) + encryption.textContent = message.security.encryption.cipherData.cipherValue + security.appendChild(encryption) + } + root.appendChild(security) + + val reliability = doc.createElement("reliability") + reliability.setAttribute("sequenceNumber", (message.reliability?.messageSequenceNumber ?: 0L).toString()) + reliability.setAttribute("acknowledgmentRequested", (message.reliability?.acknowledgmentRequested ?: false).toString()) + reliability.setAttribute("duplicateElimination", (message.reliability?.duplicateElimination ?: false).toString()) + root.appendChild(reliability) + + val transformer = TransformerFactory.newInstance().newTransformer().apply { + setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes") + setOutputProperty(OutputKeys.INDENT, "no") + } + val out = ByteArrayOutputStream() + transformer.transform(DOMSource(doc), StreamResult(out)) + return out.toByteArray() + } + + private fun parseMessage(messageData: ByteArray): AS4Message { + val doc = xmlFactory.newDocumentBuilder().parse(ByteArrayInputStream(messageData)) + val root = doc.documentElement + fun child(name: String): Element = root.getElementsByTagName(name).item(0) as Element + fun attr(value: String?): String? = value?.takeIf { it.isNotEmpty() } + fun parseParty(element: Element): AS4Party = AS4Party( + partyId = element.getAttribute("partyId"), + role = attr(element.getAttribute("role")) + ) + + val payload = Base64.getDecoder().decode(child("payload").textContent.trim()) + val securityNode = child("security") + val signatureNode = securityNode.getElementsByTagName("signature").item(0) as Element + val signedInfoNode = signatureNode.getElementsByTagName("signedInfo").item(0) as Element + val references = signedInfoNode.getElementsByTagName("reference").let { nodes -> + buildList { + for (i in 0 until nodes.length) { + val ref = nodes.item(i) as Element + add( + Reference( + uri = ref.getAttribute("uri"), + digestMethod = ref.getAttribute("digestMethod"), + digestValue = ref.getAttribute("digestValue") + ) + ) + } + } + } + val encryptionNode = securityNode.getElementsByTagName("encryption").item(0) as? Element + val reliabilityNode = child("reliability") + val signatureValue = signatureNode.getAttribute("signatureValue") + val expectedDigest = payloadDigest(payload) + val referenceDigest = references.firstOrNull { it.uri == "#payload" }?.digestValue + if (referenceDigest != null && referenceDigest != expectedDigest) { + throw IllegalStateException("AS4 payload digest verification failed") + } + + val parsedMessage = AS4Message( + messageId = root.getAttribute("messageId"), + timestamp = Date(root.getAttribute("timestamp").toLong()), + fromParty = parseParty(child("from")), + toParty = parseParty(child("to")), + conversationId = root.getAttribute("conversationId").takeIf { it.isNotEmpty() }, + service = root.getAttribute("service").takeIf { it.isNotEmpty() }, + action = root.getAttribute("action").takeIf { it.isNotEmpty() }, + payload = payload, + security = AS4Security( + signature = XMLSignature( + signatureValue = signatureValue, + signatureMethod = signatureNode.getAttribute("signatureMethod"), + canonicalizationMethod = signatureNode.getAttribute("canonicalizationMethod"), + signedInfo = SignedInfo( + canonicalizationMethod = signedInfoNode.getAttribute("canonicalizationMethod"), + signatureMethod = signedInfoNode.getAttribute("signatureMethod"), + references = references + ) + ), + encryption = encryptionNode?.let { + XMLEncryption( + encryptionMethod = it.getAttribute("encryptionMethod"), + cipherData = CipherData(it.textContent.trim()) + ) + }, + certificate = child("certificate").textContent.trim() + ), + reliability = AS4Reliability( + messageSequenceNumber = reliabilityNode.getAttribute("sequenceNumber").toLong(), + acknowledgmentRequested = reliabilityNode.getAttribute("acknowledgmentRequested").toBoolean(), + duplicateElimination = reliabilityNode.getAttribute("duplicateElimination").toBoolean() + ) + ) + + if (parsedMessage.security.signature.signatureValue.isEmpty() || !verifyMessageSignature(parsedMessage)) { + throw IllegalStateException("AS4 signature verification failed") + } + + return parsedMessage + } + /** * Create AS4 message envelope. - * TODO: Full implementation with Apache CXF and Santuario */ suspend fun createMessage( fromParty: AS4Party, toParty: AS4Party, payload: ByteArray, - action: String? + action: String?, + privateKey: PrivateKey, + certificate: X509Certificate ): Result { return try { + certificate.checkValidity() + val encodedCertificate = Base64.getEncoder().encodeToString(certificate.encoded) val message = AS4Message( messageId = UUID.randomUUID().toString(), timestamp = Date(), @@ -46,17 +292,23 @@ class AS4Service @Inject constructor( payload = payload, security = AS4Security( signature = XMLSignature( - signatureValue = "", // TODO: Generate signature - signatureMethod = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + signatureValue = "", + signatureMethod = signingAlgorithmFor(privateKey), 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() + signatureMethod = signingAlgorithmFor(privateKey), + references = listOf( + Reference( + uri = "#payload", + digestMethod = "http://www.w3.org/2001/04/xmlenc#sha256", + digestValue = payloadDigest(payload) + ) + ) ) ), - encryption = null, // TODO: Add encryption if needed - certificate = "" // TODO: Include X.509 certificate + encryption = null, + certificate = encodedCertificate ), reliability = AS4Reliability( messageSequenceNumber = 1L, @@ -64,35 +316,49 @@ class AS4Service @Inject constructor( duplicateElimination = true ) ) - + val signedMessage = signMessage(message, privateKey) + auditLogger.logEvent( AuditEventType.COMMUNICATION_SESSION_START, userId = fromParty.partyId, module = "as4", - details = "AS4 message created: ${message.messageId}" + details = "AS4 message created: ${signedMessage.messageId}" ) - - Result.Success(message) + + Result.Success(signedMessage) } catch (e: Exception) { Result.Error(e) } } - + /** * Send AS4 message. - * TODO: Implement actual sending via HTTP/HTTPS with SOAP + * Local envelope serialization/validation. */ suspend fun sendMessage(message: AS4Message): Result { - // Placeholder - full implementation will use Apache CXF - return Result.Success("Message sent (simulated)") + return try { + val serialized = serializeMessage(message) + val parsed = parseMessage(serialized) + auditLogger.logEvent( + AuditEventType.COMMUNICATION_SESSION_START, + userId = parsed.fromParty.partyId, + module = "as4", + details = "AS4 message prepared: ${parsed.messageId}" + ) + Result.Success("Message prepared: ${parsed.messageId}") + } catch (e: Exception) { + Result.Error(e) + } } - + /** * Receive and process AS4 message. */ suspend fun receiveMessage(messageData: ByteArray): Result { - // Placeholder - full implementation will parse SOAP envelope - return Result.Error(NotImplementedError("AS4 message reception not yet implemented")) + return try { + Result.Success(parseMessage(messageData)) + } catch (e: Exception) { + Result.Error(e) + } } } - diff --git a/core/as4/src/test/java/com/smoa/core/as4/domain/AS4ServiceTest.kt b/core/as4/src/test/java/com/smoa/core/as4/domain/AS4ServiceTest.kt new file mode 100644 index 0000000..a46974a --- /dev/null +++ b/core/as4/src/test/java/com/smoa/core/as4/domain/AS4ServiceTest.kt @@ -0,0 +1,62 @@ +package com.smoa.core.as4.domain + +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AS4ServiceTest { + private val service = AS4Service(mockk(relaxed = true)) + + @Test + fun `message round trips through local serialization`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val messageResult = service.createMessage( + fromParty = AS4Party("sender", "originator"), + toParty = AS4Party("receiver", "responder"), + payload = "payload".toByteArray(), + action = "sync", + privateKey = keyPair.private, + certificate = certificate + ) + + assertTrue(messageResult.isSuccess) + val message = messageResult.getOrThrow() + + val sendResult = service.sendMessage(message) + assertTrue(sendResult.isSuccess) + assertTrue(sendResult.getOrThrow().contains(message.messageId)) + + val parsed = service.receiveMessage(messagePayload(service, message)) + assertTrue(parsed.isSuccess) + assertEquals(message.messageId, parsed.getOrThrow().messageId) + assertEquals("sync", parsed.getOrThrow().action) + assertEquals("payload", String(parsed.getOrThrow().payload)) + } + + @Test + fun `receiveMessage fails for tampered payload`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val messageResult = service.createMessage( + fromParty = AS4Party("sender", "originator"), + toParty = AS4Party("receiver", "responder"), + payload = "payload".toByteArray(), + action = "sync", + privateKey = keyPair.private, + certificate = certificate + ) + + val message = messageResult.getOrThrow() + val tampered = message.copy(payload = "tampered".toByteArray()) + val parsed = service.receiveMessage(messagePayload(service, tampered)) + + assertTrue(parsed.isFailure) + } + + private fun messagePayload(service: AS4Service, message: AS4Message): ByteArray { + val method = AS4Service::class.java.getDeclaredMethod("serializeMessage", AS4Message::class.java) + method.isAccessible = true + return method.invoke(service, message) as ByteArray + } +} diff --git a/core/as4/src/test/java/com/smoa/core/as4/domain/TestCertificates.kt b/core/as4/src/test/java/com/smoa/core/as4/domain/TestCertificates.kt new file mode 100644 index 0000000..8e0cf3c --- /dev/null +++ b/core/as4/src/test/java/com/smoa/core/as4/domain/TestCertificates.kt @@ -0,0 +1,53 @@ +package com.smoa.core.as4.domain + +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.KeyUsage +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.math.BigInteger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Security +import java.security.cert.X509Certificate +import java.util.Date + +internal object TestCertificates { + fun generateSelfSignedCertificate( + keyUsage: Int? = KeyUsage.digitalSignature or KeyUsage.nonRepudiation, + keyPair: KeyPair? = null, + notBefore: Date? = null, + notAfter: Date? = null + ): Pair { + val actualKeyPair = keyPair ?: KeyPairGenerator.getInstance("RSA").run { + initialize(2048) + generateKeyPair() + } + val now = Date() + val actualNotBefore = notBefore ?: Date(now.time - 1000L) + val actualNotAfter = notAfter ?: Date(now.time + 365L * 24 * 60 * 60 * 1000L) + val subject = X500Name("CN=SMOA AS4 Test") + val serial = BigInteger.valueOf(now.time) + val builder = JcaX509v3CertificateBuilder( + subject, + serial, + actualNotBefore, + actualNotAfter, + subject, + actualKeyPair.public + ) + if (keyUsage != null) { + builder.addExtension(org.bouncycastle.asn1.x509.Extension.keyUsage, true, KeyUsage(keyUsage)) + } + val signer = JcaContentSignerBuilder("SHA256withRSA").build(actualKeyPair.private) + val provider = BouncyCastleProvider() + Security.addProvider(provider) + val certificate = JcaX509CertificateConverter() + .setProvider(provider) + .getCertificate(builder.build(signer)) + certificate.checkValidity() + certificate.verify(actualKeyPair.public) + return actualKeyPair to certificate + } +} diff --git a/core/auth/build.gradle.kts b/core/auth/build.gradle.kts index c6c75e0..7490247 100644 --- a/core/auth/build.gradle.kts +++ b/core/auth/build.gradle.kts @@ -1,8 +1,8 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") id("kotlin-kapt") - id("dagger.hilt.android.plugin") } android { @@ -21,6 +21,14 @@ android { kotlinOptions { jvmTarget = "17" } + + hilt { + enableAggregatingTask = true + } +} + +kapt { + correctErrorTypes = true } dependencies { diff --git a/core/barcode/build.gradle.kts b/core/barcode/build.gradle.kts index a60d318..e9f363f 100644 --- a/core/barcode/build.gradle.kts +++ b/core/barcode/build.gradle.kts @@ -1,8 +1,8 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") id("kotlin-kapt") - id("dagger.hilt.android.plugin") } android { @@ -29,6 +29,14 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.4" } + + hilt { + enableAggregatingTask = true + } +} + +kapt { + correctErrorTypes = true } dependencies { @@ -56,4 +64,3 @@ dependencies { // Testing testImplementation(Dependencies.junit) } - diff --git a/core/barcode/src/main/java/com/smoa/core/barcode/formats/AAMVACredential.kt b/core/barcode/src/main/java/com/smoa/core/barcode/formats/AAMVACredential.kt index d71a031..0732c0f 100644 --- a/core/barcode/src/main/java/com/smoa/core/barcode/formats/AAMVACredential.kt +++ b/core/barcode/src/main/java/com/smoa/core/barcode/formats/AAMVACredential.kt @@ -29,8 +29,8 @@ data class AAMVACredential( val sex: String? = null // M, F, or X ) { /** - * Encode to AAMVA format string for PDF417 barcode. - * Format: @\nANSI [version]\n[data elements]\n + * Encode to AAMVA-style key/value lines for PDF417 prototyping. + * Element IDs and ordering must match the target jurisdiction’s AAMVA / ANSI 636 revision for production readers. */ fun encodeToAAMVAFormat(): String { val builder = StringBuilder() @@ -103,13 +103,14 @@ data class AAMVACredential( builder.append("\n") if (restrictions != null) { - builder.append("DBA") // Restrictions + // Jurisdiction-specific; DCK used for federal commercial restriction codes in some AAMVA revisions — pin to your card version. + builder.append("DCK\n") builder.append(restrictions) builder.append("\n") } - + if (endorsements != null) { - builder.append("DBC") // Endorsements + builder.append("DCJ\n") builder.append(endorsements) builder.append("\n") } diff --git a/core/barcode/src/main/java/com/smoa/core/barcode/formats/ICAO9303Credential.kt b/core/barcode/src/main/java/com/smoa/core/barcode/formats/ICAO9303Credential.kt index c8989c1..3dd2fc9 100644 --- a/core/barcode/src/main/java/com/smoa/core/barcode/formats/ICAO9303Credential.kt +++ b/core/barcode/src/main/java/com/smoa/core/barcode/formats/ICAO9303Credential.kt @@ -2,8 +2,11 @@ package com.smoa.core.barcode.formats /** * ICAO 9303 Machine Readable Travel Document (MRTD) data structure. - * - * Format specification: ICAO Document 9303 - Machine Readable Travel Documents + * + * Format specification: ICAO Document 9303 - Machine Readable Travel Documents. + * + * **Compliance:** [encodeToICAO9303Format] is a simplified MRZ-style builder for demos; + * production use requires full TD1/TD2/TD3 layout, check digits, and official test vectors. */ data class ICAO9303Credential( val documentType: String, // P = Passport, I = ID card, A = Alien, etc. diff --git a/core/certificates/build.gradle.kts b/core/certificates/build.gradle.kts index 76334a5..1f42c27 100644 --- a/core/certificates/build.gradle.kts +++ b/core/certificates/build.gradle.kts @@ -1,8 +1,8 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") id("kotlin-kapt") - id("dagger.hilt.android.plugin") } android { @@ -21,6 +21,14 @@ android { kotlinOptions { jvmTarget = "17" } + + hilt { + enableAggregatingTask = true + } +} + +kapt { + correctErrorTypes = true } dependencies { @@ -38,5 +46,7 @@ dependencies { implementation(Dependencies.coroutinesCore) implementation(Dependencies.coroutinesAndroid) -} + // Testing + testImplementation(Dependencies.junit) +} diff --git a/core/certificates/src/main/java/com/smoa/core/certificates/CertificateManager.kt b/core/certificates/src/main/java/com/smoa/core/certificates/CertificateManager.kt index 303ce33..6b1a01b 100644 --- a/core/certificates/src/main/java/com/smoa/core/certificates/CertificateManager.kt +++ b/core/certificates/src/main/java/com/smoa/core/certificates/CertificateManager.kt @@ -16,4 +16,7 @@ class CertificateManager @Inject constructor( fun getCertificate(certificateId: String) = domainManager.getCertificate(certificateId) fun isCertificateValid(certificate: X509Certificate) = domainManager.isCertificateValid(certificate) + + suspend fun checkRevocationStatus(certificate: X509Certificate) = + domainManager.checkRevocationStatus(certificate) } diff --git a/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt b/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt index a7bedda..c65c692 100644 --- a/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt +++ b/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt @@ -1,9 +1,12 @@ package com.smoa.core.certificates.domain +import java.security.cert.X509CRL import java.security.cert.X509Certificate import java.util.Date import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Certificate management system. @@ -12,6 +15,7 @@ import javax.inject.Singleton class CertificateManager @Inject constructor() { private val certificates = mutableMapOf() + private val crls = mutableListOf() /** * Store certificate. @@ -31,6 +35,20 @@ class CertificateManager @Inject constructor() { fun getCertificate(certificateId: String): CertificateInfo? { return certificates[certificateId] } + + /** + * Store a CRL locally for deterministic revocation checks. + */ + fun storeCrl(crl: X509CRL) { + crls.add(crl) + } + + /** + * Clear stored CRLs. + */ + fun clearCrls() { + crls.clear() + } /** * Check certificate validity. @@ -46,10 +64,46 @@ class CertificateManager @Inject constructor() { /** * Check certificate revocation status via OCSP/CRL. - * Minimal implementation: returns UNKNOWN. Extend with an OCSP client or CRL fetcher for production. + * Best-effort implementation: + * - Uses locally stored CRLs for deterministic results. + * - Treats self-signed certificates as VALID when no CRL is available. + * - Returns UNKNOWN when issuer CRL is not present. */ suspend fun checkRevocationStatus(certificate: X509Certificate): RevocationStatus { - return RevocationStatus.UNKNOWN + return withContext(Dispatchers.IO) { + try { + val issuer = certificate.issuerX500Principal + val relevantCrls = crls.filter { it.issuerX500Principal == issuer } + if (relevantCrls.isEmpty()) { + return@withContext if (isSelfSigned(certificate)) RevocationStatus.VALID else RevocationStatus.UNKNOWN + } + + var hasFreshCrl = false + for (crl in relevantCrls) { + val nextUpdate = crl.nextUpdate + if (nextUpdate != null && nextUpdate.before(Date())) { + continue + } + hasFreshCrl = true + if (crl.isRevoked(certificate)) { + return@withContext RevocationStatus.REVOKED + } + } + if (hasFreshCrl) RevocationStatus.VALID else RevocationStatus.UNKNOWN + } catch (_: Exception) { + RevocationStatus.UNKNOWN + } + } + } + + private fun isSelfSigned(certificate: X509Certificate): Boolean { + return try { + if (certificate.subjectX500Principal != certificate.issuerX500Principal) return false + certificate.verify(certificate.publicKey) + true + } catch (_: Exception) { + false + } } } @@ -78,4 +132,3 @@ enum class RevocationStatus { REVOKED, UNKNOWN } - diff --git a/core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt b/core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt new file mode 100644 index 0000000..3b217fd --- /dev/null +++ b/core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt @@ -0,0 +1,95 @@ +package com.smoa.core.certificates.domain + +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.cert.jcajce.JcaX509CRLConverter +import org.bouncycastle.cert.jcajce.JcaX509v2CRLBuilder +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.Security +import java.util.Date + +class CertificateManagerTest { + @Test + fun `store and retrieve certificate`() { + val (_, certificate) = TestCertificates.generateSelfSignedCertificate() + val manager = CertificateManager() + val metadata = CertificateMetadata( + issuer = "Test Issuer", + subject = "Test Subject", + purpose = CertificatePurpose.SIGNING, + isQualified = false + ) + + manager.storeCertificate("cert-1", certificate, metadata) + + val stored = manager.getCertificate("cert-1") + assertNotNull(stored) + assertEquals("cert-1", stored?.certificateId) + assertEquals(CertificatePurpose.SIGNING, stored?.metadata?.purpose) + } + + @Test + fun `certificate validity returns true for valid cert`() { + val (_, certificate) = TestCertificates.generateSelfSignedCertificate() + val manager = CertificateManager() + + val isValid = manager.isCertificateValid(certificate) + + assertTrue(isValid) + } + + @Test + fun `checkRevocationStatus returns unknown without revocation data`() = runBlocking { + val (_, certificate) = TestCertificates.generateSelfSignedCertificate() + val manager = CertificateManager() + + val status = manager.checkRevocationStatus(certificate) + + assertEquals(RevocationStatus.UNKNOWN, status) + } + + @Test + fun `checkRevocationStatus returns valid when crl does not revoke certificate`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val manager = CertificateManager() + manager.storeCrl(buildCrl(keyPair.private, certificate.subjectX500Principal.name, emptyList())) + + val status = manager.checkRevocationStatus(certificate) + + assertEquals(RevocationStatus.VALID, status) + } + + @Test + fun `checkRevocationStatus returns revoked when certificate is listed in crl`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val manager = CertificateManager() + manager.storeCrl(buildCrl(keyPair.private, certificate.subjectX500Principal.name, listOf(certificate.serialNumber))) + + val status = manager.checkRevocationStatus(certificate) + + assertEquals(RevocationStatus.REVOKED, status) + } + + private fun buildCrl( + issuerKey: java.security.PrivateKey, + issuerDn: String, + revokedSerials: List + ): java.security.cert.X509CRL { + val provider = BouncyCastleProvider() + Security.addProvider(provider) + val now = Date() + val builder = JcaX509v2CRLBuilder(X500Name(issuerDn), now) + builder.setNextUpdate(Date(now.time + 24L * 60 * 60 * 1000L)) + revokedSerials.forEach { serial -> + builder.addCRLEntry(serial, now, 0) + } + return JcaX509CRLConverter() + .setProvider(provider) + .getCRL(builder.build(JcaContentSignerBuilder("SHA256withRSA").build(issuerKey))) + } +} diff --git a/core/certificates/src/test/java/com/smoa/core/certificates/domain/TestCertificates.kt b/core/certificates/src/test/java/com/smoa/core/certificates/domain/TestCertificates.kt new file mode 100644 index 0000000..1911011 --- /dev/null +++ b/core/certificates/src/test/java/com/smoa/core/certificates/domain/TestCertificates.kt @@ -0,0 +1,43 @@ +package com.smoa.core.certificates.domain + +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.math.BigInteger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Security +import java.security.cert.X509Certificate +import java.util.Date + +internal object TestCertificates { + fun generateSelfSignedCertificate(): Pair { + val keyPairGen = KeyPairGenerator.getInstance("RSA") + keyPairGen.initialize(2048) + val keyPair = keyPairGen.generateKeyPair() + val now = Date() + val notBefore = Date(now.time - 1000L) + val notAfter = Date(now.time + 365L * 24 * 60 * 60 * 1000L) + val subject = X500Name("CN=SMOA Cert Test") + val serial = BigInteger.valueOf(now.time) + val builder = JcaX509v3CertificateBuilder( + subject, + serial, + notBefore, + notAfter, + subject, + keyPair.public + ) + val signer = JcaContentSignerBuilder("SHA256withRSA").build(keyPair.private) + val provider = BouncyCastleProvider() + Security.addProvider(provider) + val certificate = JcaX509CertificateConverter() + .setProvider(provider) + .getCertificate(builder.build(signer)) + certificate.checkValidity() + certificate.verify(keyPair.public) + return keyPair to certificate + } +} diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 2547a17..cd40466 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -1,8 +1,8 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") id("kotlin-kapt") - id("dagger.hilt.android.plugin") } android { @@ -21,6 +21,14 @@ android { kotlinOptions { jvmTarget = "17" } + + hilt { + enableAggregatingTask = true + } +} + +kapt { + correctErrorTypes = true } java { diff --git a/core/common/src/main/java/com/smoa/core/common/ConnectivityManager.kt b/core/common/src/main/java/com/smoa/core/common/ConnectivityManager.kt index ded7888..faaebf0 100644 --- a/core/common/src/main/java/com/smoa/core/common/ConnectivityManager.kt +++ b/core/common/src/main/java/com/smoa/core/common/ConnectivityManager.kt @@ -156,12 +156,19 @@ class ConnectivityManager @Inject constructor( } } + /** + * 5G MW (NR-Advanced) when [TelephonyDisplayInfo] reports override type NR_ADVANCED (API 31+). + * Implemented via reflection so the module compiles even if the build machine's android.jar + * lacks API 31+ symbols (e.g. partial SDK install). + */ @Suppress("DEPRECATION") private fun cellularGenerationFrom5G(tm: TelephonyManager): CellularGeneration { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return CellularGeneration.NR_5G return try { - val displayInfo = tm.telephonyDisplayInfo - val override = displayInfo.overrideNetworkType + val displayInfo = + tm.javaClass.getMethod("getTelephonyDisplayInfo").invoke(tm) ?: return CellularGeneration.NR_5G + val override = displayInfo.javaClass.getMethod("getOverrideNetworkType").invoke(displayInfo) as? Int ?: 0 + // TelephonyManager.OVERRIDE_NETWORK_TYPE_NR_ADVANCED == 5 if (override == 5) CellularGeneration.NR_5G_MW else CellularGeneration.NR_5G } catch (_: Throwable) { CellularGeneration.NR_5G diff --git a/core/common/src/main/java/com/smoa/core/common/PullAPI.kt b/core/common/src/main/java/com/smoa/core/common/PullAPI.kt index 50ce356..f8d7aed 100644 --- a/core/common/src/main/java/com/smoa/core/common/PullAPI.kt +++ b/core/common/src/main/java/com/smoa/core/common/PullAPI.kt @@ -9,6 +9,8 @@ interface PullAPI { suspend fun pullOrders(since: Long? = null, limit: Int = 100, jurisdiction: String? = null): Result suspend fun pullEvidence(since: Long? = null, limit: Int = 100, caseNumber: String? = null): Result suspend fun pullCredentials(since: Long? = null, limit: Int = 100, holderId: String? = null): Result + /** Full credential JSON including [payloadJson] (GET /api/v1/credentials/{id}). */ + suspend fun pullCredentialDetail(credentialId: String): Result suspend fun pullReports(since: Long? = null, limit: Int = 100): Result } @@ -20,5 +22,7 @@ class DefaultPullAPI : PullAPI { override suspend fun pullOrders(since: Long?, limit: Int, jurisdiction: String?) = Result.Success(ByteArray(0)) override suspend fun pullEvidence(since: Long?, limit: Int, caseNumber: String?) = Result.Success(ByteArray(0)) override suspend fun pullCredentials(since: Long?, limit: Int, holderId: String?) = Result.Success(ByteArray(0)) + override suspend fun pullCredentialDetail(credentialId: String) = + Result.Error(IllegalStateException("Backend pull not configured")) override suspend fun pullReports(since: Long?, limit: Int) = Result.Success(ByteArray(0)) } diff --git a/core/common/src/main/java/com/smoa/core/common/SmartCardReader.kt b/core/common/src/main/java/com/smoa/core/common/SmartCardReader.kt index ca99a79..6de74aa 100644 --- a/core/common/src/main/java/com/smoa/core/common/SmartCardReader.kt +++ b/core/common/src/main/java/com/smoa/core/common/SmartCardReader.kt @@ -4,9 +4,10 @@ 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. + * Smart card reader for ISO 7816 integration. + * + * **Stub:** No PC/SC or USB CCID reader is bound. Returns not-present / not-implemented until + * a deployment wires USB CCID / PC/SC or OEM smartcard APIs. */ @Singleton class SmartCardReader @Inject constructor() { @@ -14,24 +15,20 @@ class SmartCardReader @Inject constructor() { /** * Check if smart card is present. */ - suspend fun isCardPresent(): Boolean { - // TODO: Implement actual card detection - return false - } + suspend fun isCardPresent(): Boolean = false /** * Connect to smart card. */ suspend fun connect(): Result { - // TODO: Implement actual card connection - return Result.Error(NotImplementedError("Smart card connection not yet implemented")) + return Result.Error(NotImplementedError("No smart card reader driver bound (stub)")) } /** * Disconnect from smart card. */ suspend fun disconnect() { - // TODO: Implement actual card disconnection + // No-op until a real connection exists } } diff --git a/core/common/src/main/java/com/smoa/core/common/SyncAPI.kt b/core/common/src/main/java/com/smoa/core/common/SyncAPI.kt index 748f7f7..9eaa526 100644 --- a/core/common/src/main/java/com/smoa/core/common/SyncAPI.kt +++ b/core/common/src/main/java/com/smoa/core/common/SyncAPI.kt @@ -69,13 +69,11 @@ data class SyncResponse( ) /** - * Default implementation of SyncAPI. - * In production, this would use Retrofit or similar to call actual backend APIs. + * Offline/local stub for [SyncAPI]. Returns success with synthetic ids. + * The app module supplies [com.smoa.api.BackendSyncAPI] when `SMOA_BACKEND_BASE_URL` is set. */ class DefaultSyncAPI : SyncAPI { override suspend fun syncOrder(orderData: ByteArray): Result { - // TODO: Implement actual API call - // This would use Retrofit to POST order data to backend return Result.Success( SyncResponse( success = true, @@ -86,7 +84,6 @@ class DefaultSyncAPI : SyncAPI { } override suspend fun syncEvidence(evidenceData: ByteArray): Result { - // TODO: Implement actual API call return Result.Success( SyncResponse( success = true, @@ -97,7 +94,6 @@ class DefaultSyncAPI : SyncAPI { } override suspend fun syncCredential(credentialData: ByteArray): Result { - // TODO: Implement actual API call return Result.Success( SyncResponse( success = true, @@ -108,7 +104,6 @@ class DefaultSyncAPI : SyncAPI { } override suspend fun syncDirectoryEntry(entryData: ByteArray): Result { - // TODO: Implement actual API call return Result.Success( SyncResponse( success = true, @@ -119,7 +114,6 @@ class DefaultSyncAPI : SyncAPI { } override suspend fun syncReport(reportData: ByteArray): Result { - // TODO: Implement actual API call return Result.Success( SyncResponse( success = true, diff --git a/core/common/src/main/java/com/smoa/core/common/SyncService.kt b/core/common/src/main/java/com/smoa/core/common/SyncService.kt index b478675..838ea34 100644 --- a/core/common/src/main/java/com/smoa/core/common/SyncService.kt +++ b/core/common/src/main/java/com/smoa/core/common/SyncService.kt @@ -2,6 +2,13 @@ package com.smoa.core.common import android.content.Context import com.google.gson.Gson +import dagger.hilt.android.qualifiers.ApplicationContext +import com.smoa.core.common.sync.CredentialCacheSyncPort +import com.smoa.core.common.sync.CredentialConflictRemoteJson +import com.smoa.core.common.sync.CredentialSyncRequestDto +import com.smoa.core.common.sync.NoOpCredentialCacheSyncPort +import com.smoa.core.common.sync.NoOpSyncRemoteDataCachePort +import com.smoa.core.common.sync.SyncRemoteDataCachePort import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -20,10 +27,12 @@ data class PullResultData(val resourceType: String, val data: ByteArray) @Singleton class SyncService @Inject constructor( - private val context: Context, + @ApplicationContext private val context: Context, private val connectivityManager: ConnectivityManager, private val syncAPI: SyncAPI = DefaultSyncAPI(), - private val pullAPI: PullAPI = DefaultPullAPI() + private val pullAPI: PullAPI = DefaultPullAPI(), + private val credentialCacheSyncPort: CredentialCacheSyncPort = NoOpCredentialCacheSyncPort(), + private val syncRemoteDataCachePort: SyncRemoteDataCachePort = NoOpSyncRemoteDataCachePort() ) { private val gson = Gson() private val _syncState = MutableStateFlow(SyncState.Idle) @@ -60,7 +69,12 @@ class SyncService @Inject constructor( pullAPI.pullDirectory(null).let { if (it is Result.Success) results.add(PullResultData("directory", it.data)) } pullAPI.pullOrders(null, 100, null).let { if (it is Result.Success) results.add(PullResultData("orders", it.data)) } pullAPI.pullEvidence(null, 100, null).let { if (it is Result.Success) results.add(PullResultData("evidence", it.data)) } - pullAPI.pullCredentials(null, 100, null).let { if (it is Result.Success) results.add(PullResultData("credentials", it.data)) } + pullAPI.pullCredentials(null, 100, null).let { r -> + if (r is Result.Success) { + results.add(PullResultData("credentials", r.data)) + runCatching { credentialCacheSyncPort.mergeFromPullCredentialsJson(r.data) } + } + } pullAPI.pullReports(null, 100).let { if (it is Result.Success) results.add(PullResultData("reports", it.data)) } if (results.isNotEmpty()) _pullResults.value = results } @@ -83,7 +97,11 @@ class SyncService @Inject constructor( val resolution = conflictResolver.resolveConflict(item, e) when (resolution) { is ConflictResolution.UseLocal -> { - // Keep local version + if (item.type == SyncItemType.Credential) { + runCatching { + credentialCacheSyncPort.restoreLocalCredentialAfterUseLocal(item.data, item.id) + } + } } is ConflictResolution.UseRemote -> { // Use remote version @@ -103,6 +121,23 @@ class SyncService @Inject constructor( _syncState.value = SyncState.Idle } + private suspend fun cacheRemoteDataOnConflict(item: SyncItem, remoteData: ByteArray) { + runCatching { + when (item.type) { + SyncItemType.Credential -> + syncRemoteDataCachePort.mergeCredentialConflictRemoteData(remoteData, item.id) + SyncItemType.Order -> + syncRemoteDataCachePort.mergeOrderConflictRemoteData(remoteData, item.id) + SyncItemType.Evidence -> + syncRemoteDataCachePort.mergeEvidenceConflictRemoteData(remoteData, item.id) + SyncItemType.Directory -> + syncRemoteDataCachePort.mergeDirectoryConflictRemoteData(remoteData, item.id) + SyncItemType.Report -> + syncRemoteDataCachePort.mergeReportConflictRemoteData(remoteData, item.id) + } + } + } + /** * Sync a single item (create/update or delete). */ @@ -111,7 +146,14 @@ class SyncService @Inject constructor( val result = when (item.type) { SyncItemType.Order -> syncAPI.deleteOrder(item.id) SyncItemType.Evidence -> syncAPI.deleteEvidence(item.id) - SyncItemType.Credential -> syncAPI.deleteCredential(item.id) + SyncItemType.Credential -> { + val result = syncAPI.deleteCredential(item.id) + when (result) { + is Result.Success -> credentialCacheSyncPort.removeCredential(item.id) + else -> Unit + } + result + } SyncItemType.Directory -> syncAPI.deleteDirectory(item.id) SyncItemType.Report -> syncAPI.deleteReport(item.id) } @@ -145,7 +187,7 @@ class SyncService @Inject constructor( is Result.Success -> { val response = result.data if (response.conflict && response.remoteData != null) { - // Handle conflict + cacheRemoteDataOnConflict(item, response.remoteData) throw ConflictException( localData = item.data, remoteData = response.remoteData, @@ -176,6 +218,7 @@ class SyncService @Inject constructor( is Result.Success -> { val response = result.data if (response.conflict && response.remoteData != null) { + cacheRemoteDataOnConflict(item, response.remoteData) throw ConflictException( localData = item.data, remoteData = response.remoteData, @@ -198,19 +241,29 @@ class SyncService @Inject constructor( */ private suspend fun syncCredential(item: SyncItem) { try { - val credentialData = serializeCredentialData(item.data) + val credentialData = serializeCredentialData(item.data, item.id) val result = syncAPI.syncCredential(credentialData) when (result) { is Result.Success -> { val response = result.data if (response.conflict && response.remoteData != null) { + cacheRemoteDataOnConflict(item, response.remoteData) throw ConflictException( localData = item.data, remoteData = response.remoteData, message = "Credential conflict detected: ${item.id}" ) } + if (response.success) { + runCatching { + credentialCacheSyncPort.upsertAfterCredentialSync( + item.data, + response.serverTimestamp, + response.itemId + ) + } + } } is Result.Error -> throw result.exception is Result.Loading -> throw Exception("Unexpected loading state") @@ -234,6 +287,7 @@ class SyncService @Inject constructor( is Result.Success -> { val response = result.data if (response.conflict && response.remoteData != null) { + cacheRemoteDataOnConflict(item, response.remoteData) throw ConflictException( localData = item.data, remoteData = response.remoteData, @@ -263,6 +317,7 @@ class SyncService @Inject constructor( is Result.Success -> { val response = result.data if (response.conflict && response.remoteData != null) { + cacheRemoteDataOnConflict(item, response.remoteData) throw ConflictException( localData = item.data, remoteData = response.remoteData, @@ -284,11 +339,47 @@ class SyncService @Inject constructor( * Serialize data for transmission. Expects data to be a Map or object with property names * matching backend DTOs (camelCase). Uses Gson for JSON serialization. */ - private fun serializeOrderData(data: Any): ByteArray = gson.toJson(data).toByteArray(Charsets.UTF_8) - private fun serializeEvidenceData(data: Any): ByteArray = gson.toJson(data).toByteArray(Charsets.UTF_8) - private fun serializeCredentialData(data: Any): ByteArray = gson.toJson(data).toByteArray(Charsets.UTF_8) - private fun serializeDirectoryEntryData(data: Any): ByteArray = gson.toJson(data).toByteArray(Charsets.UTF_8) - private fun serializeReportData(data: Any): ByteArray = gson.toJson(data).toByteArray(Charsets.UTF_8) + private fun serializeOrderData(data: Any): ByteArray = when (data) { + is ByteArray -> data + else -> gson.toJson(data).toByteArray(Charsets.UTF_8) + } + + private fun serializeEvidenceData(data: Any): ByteArray = when (data) { + is ByteArray -> data + else -> gson.toJson(data).toByteArray(Charsets.UTF_8) + } + + /** + * [data] may be a prior sync body or raw JSON from [ConflictException.remoteData] after a conflict + * (server snapshot bytes). [credentialItemId] must match [CredentialSyncRequestDto.credentialId]. + */ + private fun serializeCredentialData(data: Any, credentialItemId: String): ByteArray { + val json = when (data) { + is ByteArray -> { + val parsed = CredentialConflictRemoteJson.parse(data, credentialItemId) + ?: throw IllegalArgumentException( + "Credential sync remoteData is not a valid server snapshot for id=$credentialItemId" + ) + gson.toJson(parsed.syncDto) + } + is CredentialSyncRequestDto -> gson.toJson(data) + is Map<*, *> -> gson.toJson(data) + else -> throw IllegalArgumentException( + "Credential sync expects CredentialSyncRequestDto, Map, or conflict remoteData JSON; got ${data::class.java.name}" + ) + } + return json.toByteArray(Charsets.UTF_8) + } + + private fun serializeDirectoryEntryData(data: Any): ByteArray = when (data) { + is ByteArray -> data + else -> gson.toJson(data).toByteArray(Charsets.UTF_8) + } + + private fun serializeReportData(data: Any): ByteArray = when (data) { + is ByteArray -> data + else -> gson.toJson(data).toByteArray(Charsets.UTF_8) + } /** * Check if offline duration threshold has been exceeded. @@ -311,12 +402,8 @@ class SyncService @Inject constructor( } 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 + // Domain-specific purging (Room DBs, encrypted stores) is delegated to feature modules: + // register listeners or call module services from application code when policies require it. } } @@ -374,9 +461,12 @@ class ConflictException( * Conflict resolver. */ class ConflictResolver { + /** + * Default: server wins ([ConflictResolution.UseRemote]). [ConflictResolution.Merge] should supply + * [ConflictResolution.Merge.mergedData] as a [Map] or DTO compatible with the same serializers + * as the original [SyncItem.data] (see [SyncService] private `serialize*` methods). + */ 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 } } diff --git a/core/common/src/main/java/com/smoa/core/common/di/CommonModule.kt b/core/common/src/main/java/com/smoa/core/common/di/CommonModule.kt index 59c0fdb..c2d8de3 100644 --- a/core/common/src/main/java/com/smoa/core/common/di/CommonModule.kt +++ b/core/common/src/main/java/com/smoa/core/common/di/CommonModule.kt @@ -27,17 +27,6 @@ object CommonModule { return ConnectivityManager(context) } - @Provides - @Singleton - fun provideSyncService( - @ApplicationContext context: Context, - connectivityManager: ConnectivityManager, - syncAPI: com.smoa.core.common.SyncAPI, - pullAPI: com.smoa.core.common.PullAPI - ): com.smoa.core.common.SyncService { - return com.smoa.core.common.SyncService(context, connectivityManager, syncAPI, pullAPI) - } - @Provides @Singleton fun provideOfflinePolicyManager(): com.smoa.core.common.OfflinePolicyManager { diff --git a/core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt b/core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt new file mode 100644 index 0000000..9eb441e --- /dev/null +++ b/core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt @@ -0,0 +1,34 @@ +package com.smoa.core.common.sync + +import javax.inject.Inject + +/** + * Optional hook for persisting credential pull/sync results into local storage (e.g. Room). + * Default implementation is a no-op for modules/tests that do not register a binding. + */ +interface CredentialCacheSyncPort { + suspend fun mergeFromPullCredentialsJson(json: ByteArray) + + suspend fun upsertAfterCredentialSync(data: Any, serverTimestamp: Long, itemId: String) + + suspend fun removeCredential(credentialId: String) + + /** + * After [com.smoa.core.common.ConflictResolution.UseLocal], re-apply the client's credential to the + * local cache so it does not keep the server snapshot written on conflict. + */ + suspend fun restoreLocalCredentialAfterUseLocal(data: Any, itemId: String) +} + +/** + * No-op used when no credential cache is wired, and for unit tests. + */ +class NoOpCredentialCacheSyncPort @Inject constructor() : CredentialCacheSyncPort { + override suspend fun mergeFromPullCredentialsJson(json: ByteArray) = Unit + + override suspend fun upsertAfterCredentialSync(data: Any, serverTimestamp: Long, itemId: String) = Unit + + override suspend fun removeCredential(credentialId: String) = Unit + + override suspend fun restoreLocalCredentialAfterUseLocal(data: Any, itemId: String) = Unit +} diff --git a/core/common/src/main/java/com/smoa/core/common/sync/CredentialConflictRemoteJson.kt b/core/common/src/main/java/com/smoa/core/common/sync/CredentialConflictRemoteJson.kt new file mode 100644 index 0000000..3c51eee --- /dev/null +++ b/core/common/src/main/java/com/smoa/core/common/sync/CredentialConflictRemoteJson.kt @@ -0,0 +1,87 @@ +package com.smoa.core.common.sync + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +/** + * Parses server JSON from [com.smoa.core.common.SyncResponse.remoteData] on credential sync conflict + * (see backend [com.smoa.backend.service.CredentialSyncService]). + */ +object CredentialConflictRemoteJson { + + private val gson = Gson() + private val mapType = object : TypeToken>() {}.type + + data class ParseResult( + val syncDto: CredentialSyncRequestDto, + val cacheRow: CredentialConflictCacheRow + ) + + /** + * Server snapshot fields for local credential cache (Room, etc.). + */ + data class CredentialConflictCacheRow( + val credentialId: String, + val holderId: String, + val credentialType: String, + val issuer: String?, + val issuedAt: Long?, + val expiresAt: Long?, + val payloadJson: String?, + val updatedAt: Long + ) + + fun parse(bytes: ByteArray, expectCredentialId: String): ParseResult? { + val map: Map = runCatching { + gson.fromJson>(bytes.toString(Charsets.UTF_8), mapType) + }.getOrNull() ?: return null + + val credentialId = map["credentialId"] as? String ?: return null + if (credentialId != expectCredentialId) return null + + val holderId = map["holderId"] as? String ?: return null + val credentialType = map["credentialType"] as? String ?: return null + val updatedAt = anyToLong(map["updatedAt"]) ?: return null + + val payloadJsonString = map["payloadJson"] as? String + val payload: Map? = payloadJsonString + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { pj -> + runCatching { gson.fromJson>(pj, mapType) }.getOrNull() + } + + val syncDto = CredentialSyncRequestDto( + credentialId = credentialId, + holderId = holderId, + credentialType = credentialType, + issuer = map["issuer"] as? String, + issuedAt = anyToLong(map["issuedAt"]), + expiresAt = anyToLong(map["expiresAt"]), + payload = payload, + clientUpdatedAt = updatedAt + ) + + val stashPayloadJson = payloadJsonString?.takeIf { it.isNotBlank() } + ?: payload?.let { gson.toJson(it) } + + val row = CredentialConflictCacheRow( + credentialId = credentialId, + holderId = holderId, + credentialType = credentialType, + issuer = map["issuer"] as? String, + issuedAt = anyToLong(map["issuedAt"]), + expiresAt = anyToLong(map["expiresAt"]), + payloadJson = stashPayloadJson, + updatedAt = updatedAt + ) + return ParseResult(syncDto, row) + } + + private fun anyToLong(v: Any?): Long? = when (v) { + null -> null + is Number -> v.toLong() + is String -> v.toLongOrNull() + else -> null + } +} diff --git a/core/common/src/main/java/com/smoa/core/common/sync/CredentialSyncRequestDto.kt b/core/common/src/main/java/com/smoa/core/common/sync/CredentialSyncRequestDto.kt new file mode 100644 index 0000000..1c63042 --- /dev/null +++ b/core/common/src/main/java/com/smoa/core/common/sync/CredentialSyncRequestDto.kt @@ -0,0 +1,19 @@ +package com.smoa.core.common.sync + +import com.google.gson.annotations.SerializedName + +/** + * JSON body for `POST /api/v1/sync/credential`, aligned with backend [CredentialSyncRequest]. + * Use this (or a [Map] with identical camelCase keys) when queueing [com.smoa.core.common.SyncItemType.Credential]. + */ +data class CredentialSyncRequestDto( + @SerializedName("credentialId") val credentialId: String, + @SerializedName("holderId") val holderId: String, + @SerializedName("credentialType") val credentialType: String, + @SerializedName("issuer") val issuer: String? = null, + @SerializedName("issuedAt") val issuedAt: Long? = null, + @SerializedName("expiresAt") val expiresAt: Long? = null, + /** Maps to backend `payload` (arbitrary JSON object). */ + @SerializedName("payload") val payload: Map? = null, + @SerializedName("clientUpdatedAt") val clientUpdatedAt: Long? = null +) diff --git a/core/common/src/main/java/com/smoa/core/common/sync/SyncRemoteDataCachePort.kt b/core/common/src/main/java/com/smoa/core/common/sync/SyncRemoteDataCachePort.kt new file mode 100644 index 0000000..fda1ba9 --- /dev/null +++ b/core/common/src/main/java/com/smoa/core/common/sync/SyncRemoteDataCachePort.kt @@ -0,0 +1,34 @@ +package com.smoa.core.common.sync + +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Apply server [com.smoa.core.common.SyncResponse.remoteData] snapshots to local caches when a sync + * conflict is returned. Credential rows are persisted in Room; other types are no-ops until those + * modules define caches. + */ +interface SyncRemoteDataCachePort { + suspend fun mergeCredentialConflictRemoteData(json: ByteArray, itemId: String) + + suspend fun mergeOrderConflictRemoteData(json: ByteArray, itemId: String) + + suspend fun mergeEvidenceConflictRemoteData(json: ByteArray, itemId: String) + + suspend fun mergeDirectoryConflictRemoteData(json: ByteArray, itemId: String) + + suspend fun mergeReportConflictRemoteData(json: ByteArray, itemId: String) +} + +@Singleton +class NoOpSyncRemoteDataCachePort @Inject constructor() : SyncRemoteDataCachePort { + override suspend fun mergeCredentialConflictRemoteData(json: ByteArray, itemId: String) = Unit + + override suspend fun mergeOrderConflictRemoteData(json: ByteArray, itemId: String) = Unit + + override suspend fun mergeEvidenceConflictRemoteData(json: ByteArray, itemId: String) = Unit + + override suspend fun mergeDirectoryConflictRemoteData(json: ByteArray, itemId: String) = Unit + + override suspend fun mergeReportConflictRemoteData(json: ByteArray, itemId: String) = Unit +} diff --git a/core/common/src/test/java/com/smoa/core/common/MockHelpers.kt b/core/common/src/test/java/com/smoa/core/common/MockHelpers.kt index c62f13a..0bd5e5d 100644 --- a/core/common/src/test/java/com/smoa/core/common/MockHelpers.kt +++ b/core/common/src/test/java/com/smoa/core/common/MockHelpers.kt @@ -1,8 +1,5 @@ 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 @@ -12,22 +9,15 @@ import kotlinx.coroutines.flow.flowOf */ object MockHelpers { /** - * Create a mock that returns a successful Result. + * Create a relaxed mock instance (placeholder for shared test setup). */ - inline fun mockSuccess(value: T): T { - return mockk { - // Add common mock behaviors here - } - } + inline fun mockSuccess(@Suppress("UNUSED_PARAMETER") value: T): T = mockk(relaxed = true) /** - * Create a mock that returns a failed Result. + * Create a relaxed mock instance for failure-oriented tests. */ - inline fun mockFailure(exception: Exception): T { - return mockk { - // Add common mock behaviors here - } - } + inline fun mockFailure(@Suppress("UNUSED_PARAMETER") exception: Exception): T = + mockk(relaxed = true) /** * Create a Flow mock that emits a single value. @@ -39,22 +29,3 @@ object MockHelpers { */ fun mockFlow(vararg values: T): Flow = flowOf(*values) } - -/** - * Extension function for coEvery with Result. - */ -fun MockKMatcherScope.coEveryResult( - block: suspend MockKMatcherScope.() -> Result -): Result { - return coEvery { block() } -} - -/** - * Extension function for coVerify with Result. - */ -fun MockKMatcherScope.coVerifyResult( - verifyBlock: suspend MockKMatcherScope.(Result) -> Unit -) { - coVerify { verifyBlock(any()) } -} - diff --git a/core/common/src/test/java/com/smoa/core/common/sync/CredentialConflictRemoteJsonTest.kt b/core/common/src/test/java/com/smoa/core/common/sync/CredentialConflictRemoteJsonTest.kt new file mode 100644 index 0000000..2f201b6 --- /dev/null +++ b/core/common/src/test/java/com/smoa/core/common/sync/CredentialConflictRemoteJsonTest.kt @@ -0,0 +1,41 @@ +package com.smoa.core.common.sync + +import com.google.gson.Gson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class CredentialConflictRemoteJsonTest { + + @Test + fun parse_mapsPayloadJsonToDtoAndCacheRow() { + val map = mapOf( + "credentialId" to "c1", + "holderId" to "h1", + "credentialType" to "ID_CARD", + "issuer" to "gov", + "issuedAt" to 1000L, + "expiresAt" to 2000L, + "payloadJson" to """{"domain":"x"}""", + "updatedAt" to 3000L + ) + val bytes = Gson().toJson(map).toByteArray(Charsets.UTF_8) + + val r = CredentialConflictRemoteJson.parse(bytes, expectCredentialId = "c1") + + assertNotNull(r) + assertEquals("c1", r!!.syncDto.credentialId) + assertEquals(3000L, r.syncDto.clientUpdatedAt) + assertEquals("x", r.syncDto.payload?.get("domain")) + assertEquals("""{"domain":"x"}""", r.cacheRow.payloadJson) + assertEquals(3000L, r.cacheRow.updatedAt) + } + + @Test + fun parse_rejectsCredentialIdMismatch() { + val bytes = """{"credentialId":"a","holderId":"h","credentialType":"T","updatedAt":1}""" + .toByteArray(Charsets.UTF_8) + assertNull(CredentialConflictRemoteJson.parse(bytes, expectCredentialId = "b")) + } +} diff --git a/core/eidas/build.gradle.kts b/core/eidas/build.gradle.kts index 66dea80..3e29c94 100644 --- a/core/eidas/build.gradle.kts +++ b/core/eidas/build.gradle.kts @@ -1,8 +1,8 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") id("kotlin-kapt") - id("dagger.hilt.android.plugin") } android { @@ -21,6 +21,14 @@ android { kotlinOptions { jvmTarget = "17" } + + hilt { + enableAggregatingTask = true + } +} + +kapt { + correctErrorTypes = true } dependencies { @@ -40,5 +48,8 @@ dependencies { implementation(Dependencies.coroutinesCore) implementation(Dependencies.coroutinesAndroid) -} + // Testing + testImplementation(Dependencies.junit) + testImplementation(Dependencies.mockk) +} diff --git a/core/eidas/src/main/java/com/smoa/core/eidas/domain/EIDASService.kt b/core/eidas/src/main/java/com/smoa/core/eidas/domain/EIDASService.kt index e74a6f1..5235fec 100644 --- a/core/eidas/src/main/java/com/smoa/core/eidas/domain/EIDASService.kt +++ b/core/eidas/src/main/java/com/smoa/core/eidas/domain/EIDASService.kt @@ -2,6 +2,12 @@ package com.smoa.core.eidas.domain import com.smoa.core.security.AuditLogger import com.smoa.core.security.AuditEventType +import java.io.ByteArrayInputStream +import java.security.PrivateKey +import java.security.Signature +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.Base64 import java.util.Date import java.util.UUID import javax.inject.Inject @@ -15,25 +21,71 @@ import javax.inject.Singleton class EIDASService @Inject constructor( private val auditLogger: AuditLogger ) { + private fun decodeCertificate(certificate: EIDASCertificate): X509Certificate? = try { + val bytes = Base64.getDecoder().decode(certificate.certificateData) + val factory = CertificateFactory.getInstance("X.509") + factory.generateCertificate(ByteArrayInputStream(bytes)) as X509Certificate + } catch (_: Exception) { + null + } + + private fun signingAlgorithmFor(privateKey: PrivateKey): String = when (privateKey.algorithm) { + "RSA" -> "SHA256withRSA" + "EC" -> "SHA256withECDSA" + else -> "SHA256withRSA" + } + + private fun validateLocalCertificate(certificate: EIDASCertificate): ValidationResult { + val decoded = decodeCertificate(certificate) ?: return ValidationResult.INVALID + return try { + if (certificate.certificateLevel != CertificateLevel.QUALIFIED) { + return ValidationResult.INVALID + } + if (decoded.notBefore.time != certificate.validFrom.time || decoded.notAfter.time != certificate.validTo.time) { + return ValidationResult.INVALID + } + decoded.checkValidity() + when (certificate.revocationStatus) { + RevocationStatus.REVOKED -> ValidationResult.REVOKED + RevocationStatus.UNKNOWN -> ValidationResult.UNKNOWN + RevocationStatus.VALID -> ValidationResult.VALID + } + } catch (_: java.security.cert.CertificateExpiredException) { + ValidationResult.EXPIRED + } catch (_: java.security.cert.CertificateNotYetValidException) { + ValidationResult.INVALID + } catch (_: Exception) { + ValidationResult.INVALID + } + } /** * Create qualified electronic signature. - * TODO: Integrate with Qualified Trust Service Provider (QTSP) + * Qualified signatures require a contracted QTSP and TSA in production; this local + * implementation signs with the provided private key and leaves the timestamp token null. */ suspend fun createQualifiedSignature( documentHash: String, + privateKey: PrivateKey, certificate: EIDASCertificate, signerInfo: SignerInfo ): Result { return try { - // TODO: Actual signature creation with QTSP + if (validateCertificate(certificate).getOrNull() != ValidationResult.VALID) { + return Result.failure(IllegalArgumentException("Certificate is not valid for qualified signing")) + } + val algorithm = signingAlgorithmFor(privateKey) + val sig = Signature.getInstance(algorithm) + sig.initSign(privateKey) + sig.update(documentHash.toByteArray(Charsets.UTF_8)) + val signatureBytes = sig.sign() val signature = QualifiedSignature( signatureId = UUID.randomUUID().toString(), documentHash = documentHash, - signatureValue = ByteArray(256), // Placeholder + signatureValue = signatureBytes, certificate = certificate, timestamp = Date(), - timestampToken = null, // TODO: Get from qualified TSA + timestampToken = null, signerInfo = signerInfo ) @@ -54,8 +106,7 @@ class EIDASService @Inject constructor( * Validate qualified certificate against EU Trust Lists. */ suspend fun validateCertificate(certificate: EIDASCertificate): Result { - // TODO: Validate against EU Trust Lists - return Result.success(ValidationResult.VALID) + return Result.success(validateLocalCertificate(certificate)) } /** @@ -63,14 +114,23 @@ class EIDASService @Inject constructor( */ suspend fun createElectronicSeal( documentHash: String, + privateKey: PrivateKey, certificate: EIDASCertificate, legalEntityInfo: LegalEntityInfo ): Result { return try { + if (validateCertificate(certificate).getOrNull() != ValidationResult.VALID) { + return Result.failure(IllegalArgumentException("Certificate is not valid for sealing")) + } + val algorithm = signingAlgorithmFor(privateKey) + val sig = Signature.getInstance(algorithm) + sig.initSign(privateKey) + sig.update(documentHash.toByteArray(Charsets.UTF_8)) + val sealBytes = sig.sign() val seal = ElectronicSeal( sealId = UUID.randomUUID().toString(), documentHash = documentHash, - sealValue = ByteArray(256), // Placeholder + sealValue = sealBytes, certificate = certificate, timestamp = Date(), legalEntityInfo = legalEntityInfo @@ -90,4 +150,3 @@ enum class ValidationResult { EXPIRED, UNKNOWN } - diff --git a/core/eidas/src/test/java/com/smoa/core/eidas/domain/EIDASServiceTest.kt b/core/eidas/src/test/java/com/smoa/core/eidas/domain/EIDASServiceTest.kt new file mode 100644 index 0000000..e3578c9 --- /dev/null +++ b/core/eidas/src/test/java/com/smoa/core/eidas/domain/EIDASServiceTest.kt @@ -0,0 +1,130 @@ +package com.smoa.core.eidas.domain + +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Base64 +import java.util.Date + +class EIDASServiceTest { + private val service = EIDASService(mockk(relaxed = true)) + + @Test + fun `validateCertificate returns valid for qualified certificate`() = runBlocking { + val (_, certificate) = TestCertificates.generateSelfSignedCertificate() + val eidasCertificate = certificate.toEidasCertificate(revocationStatus = RevocationStatus.VALID) + + val result = service.validateCertificate(eidasCertificate) + + assertTrue(result.isSuccess) + assertEquals(ValidationResult.VALID, result.getOrThrow()) + } + + @Test + fun `validateCertificate returns revoked when certificate is revoked`() = runBlocking { + val (_, certificate) = TestCertificates.generateSelfSignedCertificate() + val eidasCertificate = certificate.toEidasCertificate(revocationStatus = RevocationStatus.REVOKED) + + val result = service.validateCertificate(eidasCertificate) + + assertTrue(result.isSuccess) + assertEquals(ValidationResult.REVOKED, result.getOrThrow()) + } + + @Test + fun `validateCertificate returns expired when validity window has passed`() = runBlocking { + val now = Date() + val (_, certificate) = TestCertificates.generateSelfSignedCertificate( + notBefore = Date(now.time - 10L * 24 * 60 * 60 * 1000L), + notAfter = Date(now.time - 24 * 60 * 60 * 1000L) + ) + val eidasCertificate = EIDASCertificate( + certificateId = "expired-cert", + certificateData = Base64.getEncoder().encodeToString(certificate.encoded), + issuer = certificate.issuerX500Principal.name, + subject = certificate.subjectX500Principal.name, + validFrom = certificate.notBefore, + validTo = certificate.notAfter, + certificateLevel = CertificateLevel.QUALIFIED, + revocationStatus = RevocationStatus.VALID, + lastChecked = now + ) + + val result = service.validateCertificate(eidasCertificate) + + assertTrue(result.isSuccess) + assertEquals(ValidationResult.EXPIRED, result.getOrThrow()) + } + + @Test + fun `validateCertificate returns unknown when revocation status is unknown`() = runBlocking { + val (_, certificate) = TestCertificates.generateSelfSignedCertificate() + val eidasCertificate = certificate.toEidasCertificate(revocationStatus = RevocationStatus.UNKNOWN) + + val result = service.validateCertificate(eidasCertificate) + + assertTrue(result.isSuccess) + assertEquals(ValidationResult.UNKNOWN, result.getOrThrow()) + } + + @Test + fun `createQualifiedSignature signs document hash`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val eidasCertificate = certificate.toEidasCertificate(revocationStatus = RevocationStatus.VALID) + val signerInfo = SignerInfo( + signerId = "signer-1", + signerName = "Test Signer", + signerAttributes = mapOf("role" to "officer") + ) + + val result = service.createQualifiedSignature( + documentHash = "abc123", + privateKey = keyPair.private, + certificate = eidasCertificate, + signerInfo = signerInfo + ) + + assertTrue(result.isSuccess) + assertTrue(result.getOrThrow().signatureValue.isNotEmpty()) + } + + @Test + fun `createElectronicSeal signs document hash`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val eidasCertificate = certificate.toEidasCertificate(revocationStatus = RevocationStatus.VALID) + val legalEntityInfo = LegalEntityInfo( + entityName = "SMOA Test Org", + registrationNumber = "REG-123", + jurisdiction = "US", + address = "1 Test Way" + ) + + val result = service.createElectronicSeal( + documentHash = "abc123", + privateKey = keyPair.private, + certificate = eidasCertificate, + legalEntityInfo = legalEntityInfo + ) + + assertTrue(result.isSuccess) + assertTrue(result.getOrThrow().sealValue.isNotEmpty()) + } + + private fun java.security.cert.X509Certificate.toEidasCertificate( + revocationStatus: RevocationStatus + ): EIDASCertificate { + return EIDASCertificate( + certificateId = "cert-1", + certificateData = Base64.getEncoder().encodeToString(encoded), + issuer = issuerX500Principal.name, + subject = subjectX500Principal.name, + validFrom = notBefore, + validTo = notAfter, + certificateLevel = CertificateLevel.QUALIFIED, + revocationStatus = revocationStatus, + lastChecked = Date() + ) + } +} diff --git a/core/eidas/src/test/java/com/smoa/core/eidas/domain/TestCertificates.kt b/core/eidas/src/test/java/com/smoa/core/eidas/domain/TestCertificates.kt new file mode 100644 index 0000000..8d4b9c3 --- /dev/null +++ b/core/eidas/src/test/java/com/smoa/core/eidas/domain/TestCertificates.kt @@ -0,0 +1,53 @@ +package com.smoa.core.eidas.domain + +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.KeyUsage +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.math.BigInteger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Security +import java.security.cert.X509Certificate +import java.util.Date + +internal object TestCertificates { + fun generateSelfSignedCertificate( + keyUsage: Int? = KeyUsage.digitalSignature or KeyUsage.nonRepudiation, + keyPair: KeyPair? = null, + notBefore: Date? = null, + notAfter: Date? = null + ): Pair { + val actualKeyPair = keyPair ?: KeyPairGenerator.getInstance("RSA").run { + initialize(2048) + generateKeyPair() + } + val now = Date() + val actualNotBefore = notBefore ?: Date(now.time - 1000L) + val actualNotAfter = notAfter ?: Date(now.time + 365L * 24 * 60 * 60 * 1000L) + val subject = X500Name("CN=SMOA eIDAS Test") + val serial = BigInteger.valueOf(now.time) + val builder = JcaX509v3CertificateBuilder( + subject, + serial, + actualNotBefore, + actualNotAfter, + subject, + actualKeyPair.public + ) + if (keyUsage != null) { + builder.addExtension(org.bouncycastle.asn1.x509.Extension.keyUsage, true, KeyUsage(keyUsage)) + } + val signer = JcaContentSignerBuilder("SHA256withRSA").build(actualKeyPair.private) + val provider = BouncyCastleProvider() + Security.addProvider(provider) + val certificate = JcaX509CertificateConverter() + .setProvider(provider) + .getCertificate(builder.build(signer)) + certificate.checkValidity() + certificate.verify(actualKeyPair.public) + return actualKeyPair to certificate + } +} diff --git a/core/security/build.gradle.kts b/core/security/build.gradle.kts index a65feb4..f0f8c17 100644 --- a/core/security/build.gradle.kts +++ b/core/security/build.gradle.kts @@ -1,8 +1,8 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") id("kotlin-kapt") - id("dagger.hilt.android.plugin") } android { @@ -11,6 +11,7 @@ android { defaultConfig { minSdk = AppConfig.minSdk + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } compileOptions { @@ -29,6 +30,14 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.4" } + + hilt { + enableAggregatingTask = true + } +} + +kapt { + correctErrorTypes = true } dependencies { @@ -59,4 +68,7 @@ dependencies { testImplementation(Dependencies.mockk) testImplementation(Dependencies.coroutinesTest) testImplementation(Dependencies.truth) + + androidTestImplementation(Dependencies.androidxJunit) + androidTestImplementation("androidx.test:core:1.5.0") } diff --git a/core/security/src/androidTest/java/com/smoa/core/security/EncryptionManagerInstrumentedTest.kt b/core/security/src/androidTest/java/com/smoa/core/security/EncryptionManagerInstrumentedTest.kt new file mode 100644 index 0000000..6aa7739 --- /dev/null +++ b/core/security/src/androidTest/java/com/smoa/core/security/EncryptionManagerInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.smoa.core.security + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EncryptionManagerInstrumentedTest { + + @Test + fun getOrCreateEncryptionKey_usesAndroidKeyStore() { + val context = ApplicationProvider.getApplicationContext() + val manager = EncryptionManager(context) + val alias = "instrumented_smoa_enc_key" + + val key = manager.getOrCreateEncryptionKey(alias) + + assertNotNull(key) + assertEquals("AES", key.algorithm) + } +} diff --git a/core/security/src/main/java/com/smoa/core/security/EncryptedDatabaseHelper.kt b/core/security/src/main/java/com/smoa/core/security/EncryptedDatabaseHelper.kt index 255efc0..2035a62 100644 --- a/core/security/src/main/java/com/smoa/core/security/EncryptedDatabaseHelper.kt +++ b/core/security/src/main/java/com/smoa/core/security/EncryptedDatabaseHelper.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.room.RoomDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import net.zetetic.database.sqlcipher.SupportOpenHelperFactory -import javax.crypto.SecretKey +import java.security.SecureRandom import javax.inject.Inject import javax.inject.Singleton @@ -14,11 +14,12 @@ import javax.inject.Singleton */ @Singleton class EncryptedDatabaseHelper @Inject constructor( - private val encryptionManager: EncryptionManager, private val keyManager: KeyManager ) { companion object { private const val KEY_ALIAS_PREFIX = "db_encryption_key_" + /** Raw key length for SQLCipher (256-bit AES). */ + private const val RAW_KEY_BYTES = 32 } /** @@ -33,14 +34,11 @@ class EncryptedDatabaseHelper @Inject constructor( // 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) + // AndroidKeyStore SecretKey.getEncoded() is null for hardware-backed keys — unusable for SQLCipher. + // Generate random raw key material and persist via EncryptedSharedPreferences. + val keyBytes = ByteArray(RAW_KEY_BYTES).also { SecureRandom().nextBytes(it) } val encodedKey = android.util.Base64.encodeToString(keyBytes, android.util.Base64.DEFAULT) keyManager.putSecureString("$KEY_ALIAS_PREFIX$alias", encodedKey) - keyBytes } } diff --git a/core/security/src/main/java/com/smoa/core/security/ScreenProtection.kt b/core/security/src/main/java/com/smoa/core/security/ScreenProtection.kt index 99a8754..d79cd6d 100644 --- a/core/security/src/main/java/com/smoa/core/security/ScreenProtection.kt +++ b/core/security/src/main/java/com/smoa/core/security/ScreenProtection.kt @@ -3,11 +3,9 @@ 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 @@ -32,11 +30,8 @@ class ScreenProtection @Inject constructor( 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) - } + // Do not call setDecorFitsSystemWindows(false) here: it breaks Scaffold/WindowInsets on + // foldables unless every screen consumes insets; FLAG_SECURE alone blocks screenshots. } /** diff --git a/core/security/src/main/java/com/smoa/core/security/VPNManager.kt b/core/security/src/main/java/com/smoa/core/security/VPNManager.kt index d4ded53..03a76d5 100644 --- a/core/security/src/main/java/com/smoa/core/security/VPNManager.kt +++ b/core/security/src/main/java/com/smoa/core/security/VPNManager.kt @@ -52,6 +52,21 @@ class VPNManager @Inject constructor( return true } + @Volatile + private var browserVpnEnforcedFlag: Boolean = false + + /** + * Set from app [com.smoa.BuildConfig.SMOA_BROWSER_VPN_ENFORCED] or policy at startup. + */ + fun setBrowserVpnEnforced(enforced: Boolean) { + browserVpnEnforcedFlag = enforced + } + + /** + * When true, in-app browser navigation enforces an active VPN first. + */ + fun isBrowserVPNEnforced(): Boolean = browserVpnEnforcedFlag + /** * Request VPN permission from user. * Returns true if permission is granted or already available. diff --git a/core/security/src/main/java/com/smoa/core/security/di/SecurityModule.kt b/core/security/src/main/java/com/smoa/core/security/di/SecurityModule.kt index c11d092..4a221a3 100644 --- a/core/security/src/main/java/com/smoa/core/security/di/SecurityModule.kt +++ b/core/security/src/main/java/com/smoa/core/security/di/SecurityModule.kt @@ -1,7 +1,6 @@ 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 @@ -33,15 +32,6 @@ object SecurityModule { return KeyManager(context, encryptionManager) } - @Provides - @Singleton - fun provideEncryptedDatabaseHelper( - encryptionManager: EncryptionManager, - keyManager: KeyManager - ): EncryptedDatabaseHelper { - return EncryptedDatabaseHelper(encryptionManager, keyManager) - } - @Provides @Singleton fun provideScreenProtection( diff --git a/core/security/src/test/java/com/smoa/core/security/EncryptionManagerTest.kt b/core/security/src/test/java/com/smoa/core/security/EncryptionManagerTest.kt index cdb1186..32f995b 100644 --- a/core/security/src/test/java/com/smoa/core/security/EncryptionManagerTest.kt +++ b/core/security/src/test/java/com/smoa/core/security/EncryptionManagerTest.kt @@ -2,55 +2,49 @@ package com.smoa.core.security import android.content.Context import io.mockk.mockk -import org.junit.Assert.* +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Ignore import org.junit.Test /** - * Unit tests for EncryptionManager. + * `EncryptionManager` opens **AndroidKeyStore** in its constructor; that provider is not available on the + * host JVM for unit tests. Prefer instrumented tests on a device/emulator for this class. */ +@Ignore("AndroidKeyStore not available in JVM unit tests") class EncryptionManagerTest { private val context = mockk(relaxed = true) - private val encryptionManager = EncryptionManager(context) + private val encryptionManager get() = EncryptionManager(context) @Test fun `getOrCreateEncryptionKey should create key if not exists`() { - // Given - val alias = "test_key" + val alias = "test_key_encryption_manager" - // 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" + val alias = "test_key_encryption_manager_same" - // 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" + val fileName = "test_file_encryption_manager.txt" - // When val encryptedFile = encryptionManager.createEncryptedFile(fileName) - // Then assertNotNull(encryptedFile) } } - diff --git a/core/security/src/test/java/com/smoa/core/security/VPNManagerTest.kt b/core/security/src/test/java/com/smoa/core/security/VPNManagerTest.kt index f7641b3..1aebc14 100644 --- a/core/security/src/test/java/com/smoa/core/security/VPNManagerTest.kt +++ b/core/security/src/test/java/com/smoa/core/security/VPNManagerTest.kt @@ -67,6 +67,16 @@ class VPNManagerTest { assertTrue(result) } + @Test + fun `isBrowserVPNEnforced follows setBrowserVpnEnforced`() { + val vpnManager = VPNManager(context) + assertFalse(vpnManager.isBrowserVPNEnforced()) + vpnManager.setBrowserVpnEnforced(true) + assertTrue(vpnManager.isBrowserVPNEnforced()) + vpnManager.setBrowserVpnEnforced(false) + assertFalse(vpnManager.isBrowserVPNEnforced()) + } + @Test fun `enforceVPNRequirement should throw exception when VPN not connected`() { // Given diff --git a/core/signing/build.gradle.kts b/core/signing/build.gradle.kts index d15141a..3005e30 100644 --- a/core/signing/build.gradle.kts +++ b/core/signing/build.gradle.kts @@ -1,8 +1,8 @@ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") + id("com.google.dagger.hilt.android") id("kotlin-kapt") - id("dagger.hilt.android.plugin") } android { @@ -21,6 +21,14 @@ android { kotlinOptions { jvmTarget = "17" } + + hilt { + enableAggregatingTask = true + } +} + +kapt { + correctErrorTypes = true } dependencies { @@ -39,5 +47,7 @@ dependencies { implementation(Dependencies.coroutinesCore) implementation(Dependencies.coroutinesAndroid) -} + // Testing + testImplementation(Dependencies.junit) +} diff --git a/core/signing/src/main/java/com/smoa/core/signing/SigningService.kt b/core/signing/src/main/java/com/smoa/core/signing/SigningService.kt index b0aef32..9f5e3ea 100644 --- a/core/signing/src/main/java/com/smoa/core/signing/SigningService.kt +++ b/core/signing/src/main/java/com/smoa/core/signing/SigningService.kt @@ -16,6 +16,10 @@ class SigningService @Inject constructor( 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) + suspend fun createSeal( + data: ByteArray, + privateKey: PrivateKey, + certificate: X509Certificate, + legalEntityInfo: com.smoa.core.signing.domain.LegalEntityInfo + ) = sealService.createSeal(data, privateKey, certificate, legalEntityInfo) } diff --git a/core/signing/src/main/java/com/smoa/core/signing/domain/DigitalSignatureService.kt b/core/signing/src/main/java/com/smoa/core/signing/domain/DigitalSignatureService.kt index f81aba6..c9b4e50 100644 --- a/core/signing/src/main/java/com/smoa/core/signing/domain/DigitalSignatureService.kt +++ b/core/signing/src/main/java/com/smoa/core/signing/domain/DigitalSignatureService.kt @@ -1,6 +1,7 @@ package com.smoa.core.signing.domain import java.security.PrivateKey +import java.security.Signature import java.security.cert.X509Certificate import java.util.Date import java.util.UUID @@ -9,9 +10,28 @@ import javax.inject.Singleton /** * Digital signature service for X.509 certificate-based signing. + * Uses JCA [Signature] (RSA/EC). For CMS/PKCS#7 or hardware tokens, extend with BouncyCastle or provider-specific APIs. */ @Singleton class DigitalSignatureService @Inject constructor() { + + private fun signingAlgorithmFor(privateKey: PrivateKey): String = when (privateKey.algorithm) { + "RSA" -> "SHA256withRSA" + "EC" -> "SHA256withECDSA" + else -> "SHA256withRSA" + } + + private fun ensureCertificateUsableForSigning(certificate: X509Certificate) { + certificate.checkValidity() + val keyUsage = certificate.keyUsage + if (keyUsage != null) { + val digitalSignature = keyUsage.getOrNull(0) ?: false + val nonRepudiation = keyUsage.getOrNull(1) ?: false + if (!digitalSignature && !nonRepudiation) { + throw IllegalStateException("Certificate key usage does not allow digital signatures") + } + } + } /** * Sign data with X.509 certificate. @@ -22,16 +42,20 @@ class DigitalSignatureService @Inject constructor() { certificate: X509Certificate ): Result { return try { - // TODO: Actual signature generation using BouncyCastle or similar + ensureCertificateUsableForSigning(certificate) + val algorithm = signingAlgorithmFor(privateKey) + val sig = Signature.getInstance(algorithm) + sig.initSign(privateKey) + sig.update(data) + val signatureBytes = sig.sign() val signature = DigitalSignature( signatureId = UUID.randomUUID().toString(), data = data, - signatureValue = ByteArray(256), // Placeholder + signatureValue = signatureBytes, certificate = certificate, - algorithm = "SHA256withRSA", + algorithm = algorithm, timestamp = Date() ) - Result.success(signature) } catch (e: Exception) { Result.failure(e) @@ -45,8 +69,14 @@ class DigitalSignatureService @Inject constructor() { data: ByteArray, signature: DigitalSignature ): Result { - // TODO: Actual signature verification - return Result.success(true) // Placeholder + return try { + val sig = Signature.getInstance(signature.algorithm) + sig.initVerify(signature.certificate.publicKey) + sig.update(data) + Result.success(sig.verify(signature.signatureValue)) + } catch (e: Exception) { + Result.failure(e) + } } } @@ -58,4 +88,3 @@ data class DigitalSignature( val algorithm: String, val timestamp: Date ) - diff --git a/core/signing/src/main/java/com/smoa/core/signing/domain/ElectronicSealService.kt b/core/signing/src/main/java/com/smoa/core/signing/domain/ElectronicSealService.kt index 624af4c..0a81833 100644 --- a/core/signing/src/main/java/com/smoa/core/signing/domain/ElectronicSealService.kt +++ b/core/signing/src/main/java/com/smoa/core/signing/domain/ElectronicSealService.kt @@ -1,5 +1,7 @@ package com.smoa.core.signing.domain +import java.security.PrivateKey +import java.security.Signature import java.security.cert.X509Certificate import java.util.Date import java.util.UUID @@ -11,22 +13,51 @@ import javax.inject.Singleton */ @Singleton class ElectronicSealService @Inject constructor() { + + private fun ensureCertificateValid(certificate: X509Certificate) { + certificate.checkValidity() + } + + private fun ensureCertificateUsableForSigning(certificate: X509Certificate) { + ensureCertificateValid(certificate) + val keyUsage = certificate.keyUsage + if (keyUsage != null) { + val digitalSignature = keyUsage.getOrNull(0) ?: false + val nonRepudiation = keyUsage.getOrNull(1) ?: false + if (!digitalSignature && !nonRepudiation) { + throw IllegalStateException("Certificate key usage does not allow digital signatures") + } + } + } + + private fun signingAlgorithmFor(privateKey: PrivateKey): String = when (privateKey.algorithm) { + "RSA" -> "SHA256withRSA" + "EC" -> "SHA256withECDSA" + else -> "SHA256withRSA" + } /** * Create electronic seal for legal entity. */ suspend fun createSeal( data: ByteArray, + privateKey: PrivateKey, certificate: X509Certificate, legalEntityInfo: LegalEntityInfo ): Result { return try { + ensureCertificateUsableForSigning(certificate) + val algorithm = signingAlgorithmFor(privateKey) + val sig = Signature.getInstance(algorithm) + sig.initSign(privateKey) + sig.update(data) + val sealValue = sig.sign() val seal = ElectronicSeal( sealId = UUID.randomUUID().toString(), data = data, - sealValue = ByteArray(256), // Placeholder - actual seal generation + sealValue = sealValue, certificate = certificate, - algorithm = "SHA256withRSA", + algorithm = algorithm, timestamp = Date(), legalEntityInfo = legalEntityInfo ) @@ -41,8 +72,16 @@ class ElectronicSealService @Inject constructor() { * Verify electronic seal. */ suspend fun verifySeal(seal: ElectronicSeal): Result { - // TODO: Actual seal verification - return Result.success(true) // Placeholder + return try { + ensureCertificateValid(seal.certificate) + val sig = Signature.getInstance(seal.algorithm) + sig.initVerify(seal.certificate.publicKey) + sig.update(seal.data) + val ok = sig.verify(seal.sealValue) + Result.success(ok) + } catch (e: Exception) { + Result.failure(e) + } } } @@ -62,4 +101,3 @@ data class LegalEntityInfo( val jurisdiction: String, val address: String ) - diff --git a/core/signing/src/test/java/com/smoa/core/signing/DigitalSignatureServiceTest.kt b/core/signing/src/test/java/com/smoa/core/signing/DigitalSignatureServiceTest.kt new file mode 100644 index 0000000..260a44f --- /dev/null +++ b/core/signing/src/test/java/com/smoa/core/signing/DigitalSignatureServiceTest.kt @@ -0,0 +1,65 @@ +package com.smoa.core.signing + +import com.smoa.core.signing.domain.DigitalSignatureService +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DigitalSignatureServiceTest { + @Test + fun `sign and verify succeeds for valid data`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val service = DigitalSignatureService() + val data = "hello-signature".toByteArray() + + val signed = service.signData(data, keyPair.private, certificate) + + assertTrue(signed.isSuccess) + val signature = signed.getOrThrow() + + val verified = service.verifySignature(data, signature) + assertTrue(verified.isSuccess) + assertTrue(verified.getOrThrow()) + } + + @Test + fun `verify fails for tampered data`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val service = DigitalSignatureService() + val data = "hello-signature".toByteArray() + + val signed = service.signData(data, keyPair.private, certificate) + val signature = signed.getOrThrow() + + val verified = service.verifySignature("tampered".toByteArray(), signature) + assertTrue(verified.isSuccess) + assertFalse(verified.getOrThrow()) + } + + @Test + fun `verify succeeds even when certificate lacks signing key usage`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val (_, verifyOnlyCertificate) = TestCertificates.generateSelfSignedCertificate(keyUsage = org.bouncycastle.asn1.x509.KeyUsage.keyEncipherment) + val service = DigitalSignatureService() + val data = "hello-signature".toByteArray() + + val signed = service.signData(data, keyPair.private, certificate) + val signature = signed.getOrThrow().copy(certificate = verifyOnlyCertificate) + + val verified = service.verifySignature(data, signature) + assertTrue(verified.isSuccess) + assertTrue(verified.getOrThrow()) + } + + @Test + fun `sign fails when certificate lacks signing key usage`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate(keyUsage = org.bouncycastle.asn1.x509.KeyUsage.keyEncipherment) + val service = DigitalSignatureService() + val data = "hello-signature".toByteArray() + + val signed = service.signData(data, keyPair.private, certificate) + + assertTrue(signed.isFailure) + } +} diff --git a/core/signing/src/test/java/com/smoa/core/signing/ElectronicSealServiceTest.kt b/core/signing/src/test/java/com/smoa/core/signing/ElectronicSealServiceTest.kt new file mode 100644 index 0000000..21fea92 --- /dev/null +++ b/core/signing/src/test/java/com/smoa/core/signing/ElectronicSealServiceTest.kt @@ -0,0 +1,53 @@ +package com.smoa.core.signing + +import com.smoa.core.signing.domain.ElectronicSealService +import com.smoa.core.signing.domain.LegalEntityInfo +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ElectronicSealServiceTest { + @Test + fun `create and verify seal succeeds for original data`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val service = ElectronicSealService() + val data = "seal-data".toByteArray() + val info = LegalEntityInfo( + entityName = "SMOA Test Org", + registrationNumber = "REG-123", + jurisdiction = "US", + address = "1 Test Way" + ) + + val created = service.createSeal(data, keyPair.private, certificate, info) + + assertTrue(created.isSuccess) + val seal = created.getOrThrow() + + val verified = service.verifySeal(seal) + assertTrue(verified.isSuccess) + assertTrue(verified.getOrThrow()) + } + + @Test + fun `verify seal fails when data is modified`() = runBlocking { + val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate() + val service = ElectronicSealService() + val data = "seal-data".toByteArray() + val info = LegalEntityInfo( + entityName = "SMOA Test Org", + registrationNumber = "REG-123", + jurisdiction = "US", + address = "1 Test Way" + ) + + val created = service.createSeal(data, keyPair.private, certificate, info) + val seal = created.getOrThrow() + val tampered = seal.copy(data = "tampered".toByteArray()) + + val verified = service.verifySeal(tampered) + assertTrue(verified.isSuccess) + assertFalse(verified.getOrThrow()) + } +} diff --git a/core/signing/src/test/java/com/smoa/core/signing/TestCertificates.kt b/core/signing/src/test/java/com/smoa/core/signing/TestCertificates.kt new file mode 100644 index 0000000..cb8603f --- /dev/null +++ b/core/signing/src/test/java/com/smoa/core/signing/TestCertificates.kt @@ -0,0 +1,47 @@ +package com.smoa.core.signing + +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder +import org.bouncycastle.asn1.x509.KeyUsage +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder +import java.math.BigInteger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.Security +import java.security.cert.X509Certificate +import java.util.Date + +internal object TestCertificates { + fun generateSelfSignedCertificate(keyUsage: Int? = KeyUsage.digitalSignature or KeyUsage.nonRepudiation): Pair { + val keyPairGen = KeyPairGenerator.getInstance("RSA") + keyPairGen.initialize(2048) + val keyPair = keyPairGen.generateKeyPair() + val now = Date() + val notBefore = Date(now.time - 1000L) + val notAfter = Date(now.time + 365L * 24 * 60 * 60 * 1000L) + val subject = X500Name("CN=SMOA Test") + val serial = BigInteger.valueOf(now.time) + val builder = JcaX509v3CertificateBuilder( + subject, + serial, + notBefore, + notAfter, + subject, + keyPair.public + ) + if (keyUsage != null) { + builder.addExtension(org.bouncycastle.asn1.x509.Extension.keyUsage, true, KeyUsage(keyUsage)) + } + val signer = JcaContentSignerBuilder("SHA256withRSA").build(keyPair.private) + val provider = BouncyCastleProvider() + Security.addProvider(provider) + val certificate = JcaX509CertificateConverter() + .setProvider(provider) + .getCertificate(builder.build(signer)) + certificate.checkValidity() + certificate.verify(keyPair.public) + return keyPair to certificate + } +} diff --git a/docs/README.md b/docs/README.md index e703b7b..a6b3274 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,9 @@ This is the central index for all SMOA (Secure Mobile Operations Application) do ### Getting Started - [Project README](../README.md) - Project overview and quick start -- [TODO – Remaining and optional tasks](../TODO.md) - Single checklist for remaining and optional work (backend, Android, iOS, Web, infra, compliance, testing) +- [TODO – Status log](../TODO.md) - What was delivered vs external gates +- [TASKS – Master task list](../TASKS.md) - All tasks by area with completion notes +- [Build (Gradle/Java)](development/BUILD.md) - Commands and CI - [Specification](reference/SPECIFICATION.md) - Application specification - [Documentation Recommendations](DOCUMENTATION_RECOMMENDATIONS.md) - Documentation organization recommendations - [Documentation Plan](standards/DOCUMENTATION_PLAN.md) - Comprehensive documentation plan @@ -63,6 +65,7 @@ This is the central index for all SMOA (Secure Mobile Operations Application) do ### Technical Documentation - [Architecture Documentation](architecture/) - System and security architecture +- [Menu navigation and REST endpoints](architecture/MENU-AND-ENDPOINTS.md) - Single diagram: drawer, routes, pull/sync `/api/v1`, local caches - [API Documentation](api/) - API specifications and reference - [Database Schema](database/) - Database schema and data models - [Integration Documentation](integrations/) - External system integrations diff --git a/docs/api/README.md b/docs/api/README.md index fe9fc66..0f4129d 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -10,6 +10,8 @@ This directory contains API documentation for the Secure Mobile Operations Application (SMOA). The API documentation includes OpenAPI specifications, generated documentation, and API reference guides. +**Android client map:** How the in-app **menu / navigation** relates to **`/api/v1` pull and sync** (and local caches) is documented in **[../architecture/MENU-AND-ENDPOINTS.md](../architecture/MENU-AND-ENDPOINTS.md)** (Mermaid diagram). + --- ## API Specification @@ -20,9 +22,9 @@ This directory contains API documentation for the Secure Mobile Operations Appli - **Status:** In Progress ### Generated Documentation -- **Location:** [generated/](generated/) -- **Format:** HTML (generated from OpenAPI spec) -- **Status:** To be generated +- **Location:** [generated/](generated/) (`index.html` + bundled spec copy) +- **Format:** HTML (ReDoc; regenerate with `bash scripts/generate-api-docs.sh` after editing `api-specification.yaml`) +- **Status:** Tracked in git --- @@ -223,7 +225,7 @@ val order = apiService.createOrder(orderRequest) - [OpenAPI Specification](api-specification.yaml) - [Architecture Documentation](../architecture/ARCHITECTURE.md) -- [Implementation Status](../IMPLEMENTATION_STATUS.md) +- [Implementation Status](../status/IMPLEMENTATION_STATUS.md) --- diff --git a/docs/api/api-specification.yaml b/docs/api/api-specification.yaml index 5860e24..c0b5715 100644 --- a/docs/api/api-specification.yaml +++ b/docs/api/api-specification.yaml @@ -3,7 +3,13 @@ info: title: SMOA API Specification description: | API specification for Secure Mobile Operations Application (SMOA). - This specification documents all internal and external APIs. + + **Authoritative mobile sync contract:** the running backend’s OpenAPI document + (`/v3/api-docs` via springdoc) matches Kotlin DTOs in `backend/src/main/kotlin/.../api/dto`. + This YAML is a **design reference**; prefer springdoc export in CI if drift is detected. + + Credential **sync** uses `CredentialSyncRequest` (POST `/api/v1/sync/credential`), not the + legacy `Credential` resource shape below where paths differ. version: 1.0.0 contact: name: SMOA Development Team @@ -344,7 +350,23 @@ components: type: string type: type: string - enum: [id, badge, license, permit, other] + description: > + Canonical values align with Complete Credential CredentialRef.domain plus + SMOA barcode templates (see docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md). + enum: + - piv_pki + - payment + - nfc_access + - mobile + - icao9303_mrtd + - aamva_dlid + - mil_std_129 + - agency_badge + - id + - badge + - license + - permit + - other title: type: string issuer: @@ -382,6 +404,39 @@ components: type: string format: date + CredentialSyncRequest: + type: object + required: + - credentialId + - holderId + - credentialType + description: > + Mobile sync body for POST /api/v1/sync/credential. credentialType matches + SmoaCredentialType (canonical + legacy). payload is optional JSON validated + per docs/schemas when issuing ICAO/AAMVA/military document types. + properties: + credentialId: + type: string + holderId: + type: string + credentialType: + type: string + pattern: ^(piv_pki|payment|nfc_access|mobile|icao9303_mrtd|aamva_dlid|mil_std_129|agency_badge|id|badge|license|permit|other)$ + issuer: + type: string + issuedAt: + type: integer + format: int64 + expiresAt: + type: integer + format: int64 + payload: + type: object + additionalProperties: true + clientUpdatedAt: + type: integer + format: int64 + Order: type: object properties: diff --git a/docs/api/generated/api-specification.yaml b/docs/api/generated/api-specification.yaml new file mode 100644 index 0000000..c0b5715 --- /dev/null +++ b/docs/api/generated/api-specification.yaml @@ -0,0 +1,524 @@ +openapi: 3.0.3 +info: + title: SMOA API Specification + description: | + API specification for Secure Mobile Operations Application (SMOA). + + **Authoritative mobile sync contract:** the running backend’s OpenAPI document + (`/v3/api-docs` via springdoc) matches Kotlin DTOs in `backend/src/main/kotlin/.../api/dto`. + This YAML is a **design reference**; prefer springdoc export in CI if drift is detected. + + Credential **sync** uses `CredentialSyncRequest` (POST `/api/v1/sync/credential`), not the + legacy `Credential` resource shape below where paths differ. + version: 1.0.0 + contact: + name: SMOA Development Team + email: smoa-dev@example.com + license: + name: Proprietary - Government Use Only + +servers: + - url: https://api.smoa.example.com/v1 + description: Production server + - url: https://api-dev.smoa.example.com/v1 + description: Development server + +tags: + - name: Authentication + description: Authentication and authorization endpoints + - name: Credentials + description: Digital credential management + - name: Orders + description: Orders management + - name: Evidence + description: Evidence chain of custody + - name: Reports + description: Report generation + - name: Communications + description: Secure communications + - name: Directory + description: Internal directory + +security: + - BearerAuth: [] + - ApiKeyAuth: [] + +paths: + /auth/login: + post: + tags: + - Authentication + summary: Authenticate user + description: | + Authenticate user with multi-factor authentication (PIN + Biometric). + Returns authentication token on success. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Authentication successful + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '429': + description: Too many login attempts + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /auth/logout: + post: + tags: + - Authentication + summary: Logout user + description: Invalidates current session + responses: + '200': + description: Logout successful + '401': + description: Unauthorized + + /credentials: + get: + tags: + - Credentials + summary: List user credentials + description: Returns list of credentials available to the authenticated user + parameters: + - name: type + in: query + schema: + type: string + enum: [id, badge, license, permit, other] + description: Filter by credential type + responses: + '200': + description: List of credentials + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Credential' + '401': + description: Unauthorized + + post: + tags: + - Credentials + summary: Create new credential + description: Creates a new digital credential + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialCreate' + responses: + '201': + description: Credential created + content: + application/json: + schema: + $ref: '#/components/schemas/Credential' + '400': + description: Invalid request + '401': + description: Unauthorized + + /credentials/{id}: + get: + tags: + - Credentials + summary: Get credential by ID + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Credential ID + responses: + '200': + description: Credential details + content: + application/json: + schema: + $ref: '#/components/schemas/Credential' + '404': + description: Credential not found + '401': + description: Unauthorized + + /orders: + get: + tags: + - Orders + summary: List orders + description: Returns list of orders available to the authenticated user + parameters: + - name: status + in: query + schema: + type: string + enum: [draft, pending_approval, approved, issued, executed, expired, revoked] + description: Filter by order status + - name: type + in: query + schema: + type: string + enum: [authorization, assignment, search_warrant, arrest_warrant, court_order, administrative] + description: Filter by order type + responses: + '200': + description: List of orders + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Order' + '401': + description: Unauthorized + + post: + tags: + - Orders + summary: Create new order + description: Creates a new order + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OrderCreate' + responses: + '201': + description: Order created + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid request + '401': + description: Unauthorized + + /orders/{id}: + get: + tags: + - Orders + summary: Get order by ID + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Order ID + responses: + '200': + description: Order details + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '404': + description: Order not found + '401': + description: Unauthorized + + /evidence: + get: + tags: + - Evidence + summary: List evidence items + description: Returns list of evidence items + responses: + '200': + description: List of evidence items + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Evidence' + '401': + description: Unauthorized + + /reports: + post: + tags: + - Reports + summary: Generate report + description: Generates a report in the specified format + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReportRequest' + responses: + '200': + description: Report generated + content: + application/pdf: + schema: + type: string + format: binary + application/json: + schema: + type: string + application/xml: + schema: + type: string + text/csv: + schema: + type: string + '400': + description: Invalid request + '401': + description: Unauthorized + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + + schemas: + LoginRequest: + type: object + required: + - pin + - biometricToken + properties: + pin: + type: string + description: User PIN + minLength: 6 + maxLength: 12 + biometricToken: + type: string + description: Biometric authentication token + + LoginResponse: + type: object + properties: + token: + type: string + description: Authentication token + expiresIn: + type: integer + description: Token expiration time in seconds + user: + $ref: '#/components/schemas/User' + + User: + type: object + properties: + id: + type: string + username: + type: string + roles: + type: array + items: + type: string + + Credential: + type: object + properties: + id: + type: string + type: + type: string + description: > + Canonical values align with Complete Credential CredentialRef.domain plus + SMOA barcode templates (see docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md). + enum: + - piv_pki + - payment + - nfc_access + - mobile + - icao9303_mrtd + - aamva_dlid + - mil_std_129 + - agency_badge + - id + - badge + - license + - permit + - other + title: + type: string + issuer: + type: string + issueDate: + type: string + format: date + expirationDate: + type: string + format: date + status: + type: string + enum: [active, expired, revoked] + barcode: + type: string + description: PDF417 barcode data + + CredentialCreate: + type: object + required: + - type + - title + - issuer + properties: + type: + type: string + title: + type: string + issuer: + type: string + issueDate: + type: string + format: date + expirationDate: + type: string + format: date + + CredentialSyncRequest: + type: object + required: + - credentialId + - holderId + - credentialType + description: > + Mobile sync body for POST /api/v1/sync/credential. credentialType matches + SmoaCredentialType (canonical + legacy). payload is optional JSON validated + per docs/schemas when issuing ICAO/AAMVA/military document types. + properties: + credentialId: + type: string + holderId: + type: string + credentialType: + type: string + pattern: ^(piv_pki|payment|nfc_access|mobile|icao9303_mrtd|aamva_dlid|mil_std_129|agency_badge|id|badge|license|permit|other)$ + issuer: + type: string + issuedAt: + type: integer + format: int64 + expiresAt: + type: integer + format: int64 + payload: + type: object + additionalProperties: true + clientUpdatedAt: + type: integer + format: int64 + + Order: + type: object + properties: + id: + type: string + type: + type: string + enum: [authorization, assignment, search_warrant, arrest_warrant, court_order, administrative] + title: + type: string + status: + type: string + enum: [draft, pending_approval, approved, issued, executed, expired, revoked] + issuedBy: + type: string + issueDate: + type: string + format: date-time + expirationDate: + type: string + format: date-time + + OrderCreate: + type: object + required: + - type + - title + properties: + type: + type: string + title: + type: string + content: + type: string + expirationDate: + type: string + format: date-time + + Evidence: + type: object + properties: + id: + type: string + caseNumber: + type: string + description: + type: string + type: + type: string + enum: [physical, digital, biological, chemical, firearm, document] + collectionDate: + type: string + format: date-time + currentCustodian: + type: string + + ReportRequest: + type: object + required: + - template + - format + properties: + template: + type: string + description: Report template name + format: + type: string + enum: [pdf, xml, json, csv] + parameters: + type: object + description: Template parameters + + ErrorResponse: + type: object + properties: + error: + type: string + message: + type: string + code: + type: string + timestamp: + type: string + format: date-time + diff --git a/docs/api/generated/index.html b/docs/api/generated/index.html new file mode 100644 index 0000000..b4ca632 --- /dev/null +++ b/docs/api/generated/index.html @@ -0,0 +1,13 @@ + + + + + + SMOA API — ReDoc + + + + + + + diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 7d35a17..9c40411 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -110,6 +110,10 @@ SMOA operates in a secure mobile and multi-platform environment with: 12. **modules:judicial** - Judicial operations 13. **modules:intelligence** - Intelligence operations +### Client navigation and API surface (Android) + +For a **single diagram** that merges the **app menu (drawer / NavHost)** with **GET pull** and **POST/DELETE sync** endpoints under `/api/v1`, see **[MENU-AND-ENDPOINTS.md](MENU-AND-ENDPOINTS.md)**. + --- ## Component Architecture @@ -240,7 +244,7 @@ Each module follows a consistent structure: ## Security Architecture -See [Security Architecture Document](SECURITY_ARCHITECTURE.md) for detailed security architecture. +See [Security Architecture Document](../security/SMOA-Security-Architecture.md) for detailed security architecture. ### Key Security Features - Multi-factor authentication @@ -301,10 +305,10 @@ See [Security Architecture Document](SECURITY_ARCHITECTURE.md) for detailed secu ## References -- [Specification](../SPECIFICATION.md) -- [Security Architecture](SECURITY_ARCHITECTURE.md) -- [Implementation Status](../IMPLEMENTATION_STATUS.md) -- [Compliance Matrix](../COMPLIANCE_MATRIX.md) +- [Specification](../reference/SPECIFICATION.md) +- [Security Architecture](../security/SMOA-Security-Architecture.md) +- [Implementation Status](../status/IMPLEMENTATION_STATUS.md) +- [Compliance Matrix](../reference/COMPLIANCE_MATRIX.md) --- diff --git a/docs/architecture/MENU-AND-ENDPOINTS.md b/docs/architecture/MENU-AND-ENDPOINTS.md new file mode 100644 index 0000000..1c19e11 --- /dev/null +++ b/docs/architecture/MENU-AND-ENDPOINTS.md @@ -0,0 +1,134 @@ +# Android app: menu navigation and REST endpoints + +**Purpose:** One view of how the **drawer / NavHost** (user-visible “menu”) relates to **hosted config**, **pull GETs**, **sync POST/DELETE**, and **local caches**. +**Scope:** Android app (`app`, `core/common` sync, Retrofit services) and backend **`/api/v1`** contract. + +--- + +## Single diagram (Mermaid) + +Render this in any Mermaid-capable viewer (GitHub, GitLab, many IDEs, Notion, etc.). + +```mermaid +flowchart TB + subgraph MENU["From menu — Drawer / Home → NavHost"] + direction TB + HM[("☰ Menu / Home hub")] + HM --> R0["route: home — startDestination"] + HM --> R1["route: credentials — always in menu"] + HM --> R6["route: orders — always in menu"] + HM --> R7["route: evidence — always in menu"] + HM --> R8["route: reports — always in menu"] + HM --> R2["route: directory — RBAC DIRECTORY"] + HM --> R3["route: communications — RBAC COMMUNICATIONS"] + HM --> R4["route: meetings — RBAC MEETINGS"] + HM --> R5["route: browser — RBAC BROWSER"] + HM --> R9["route: user_settings — always"] + R0 --> S0[HomeScreen — module shortcuts + launcher settings] + R1 --> S1[CredentialsModule — template Cards] + R6 --> S6[OrdersModule] + R7 --> S7[EvidenceModule] + R8 --> S8[ReportGenerationScreen] + R2 --> S2[DirectoryModule — DirectoryListScreen] + R3 --> S3[CommunicationsModule] + R4 --> S4[MeetingsModule] + R5 --> S5[BrowserModule] + R9 --> S9[UserSettingsScreen — account + log out] + end + + subgraph NET["Shared networking — not one endpoint per menu row"] + direction TB + CFG["GET hosted client-config JSON\n(SMOA_CONFIG_URL / ClientConfigFetcher)"] + RS[(RemoteEndpointStore +\nRetrofit base URL + API key)] + SS[SyncService.startSync\nwhen online and Backend Pull/Sync active] + CFG --> RS + RS --> SS + SS --> PULL[Pull phase — all resource types] + SS --> PUSH[Queue phase — POST/DELETE sync items] + end + + subgraph API["Backend — same host /api/v1"] + direction TB + subgraph PULL_EP["Pull"] + G1[GET /directory] + G2[GET /orders] + G3[GET /evidence] + G4[GET /credentials] + G5[GET /reports] + end + subgraph SYNC_EP["Sync"] + P1[POST /sync/directory] + P2[POST /sync/order] + P3[POST /sync/evidence] + P4[POST /sync/credential] + P5[POST /sync/report] + D1[DELETE /sync/directory/{id}] + D2[DELETE /sync/order/{id}] + D3[DELETE /sync/evidence/{id}] + D4[DELETE /sync/credential/{id}] + D5[DELETE /sync/report/{id}] + end + end + + subgraph LOCAL["Local persistence"] + CC[("Credential Room cache")] + SN[("sync_conflict_snapshots\norder / evidence / directory / report")] + end + + PULL --> G1 + PULL --> G2 + PULL --> G3 + PULL --> G4 + PULL --> G5 + PUSH --> P1 + PUSH --> P2 + PUSH --> P3 + PUSH --> P4 + PUSH --> P5 + PUSH --> D1 + PUSH --> D2 + PUSH --> D3 + PUSH --> D4 + PUSH --> D5 + + G4 -->|merge pull JSON| CC + P4 -->|success upsert| CC + D4 -->|remove row| CC + + PUSH --> CR[Conflict + remoteData] + CR -->|credential| CC + CR -->|other resource types| SN +``` + +--- + +## Reading the diagram + +| Area | Meaning | +|------|--------| +| **MENU** | `NavigationDrawer` + `HomeScreen` + `SMOANavigation` (`SMOARoute`). **Start:** `home`. **Always listed:** credentials, orders, evidence, reports, user settings. **RBAC-gated:** directory, communications, meetings, browser. | +| **NET** | **Hosted config** is optional; when present it updates **RemoteEndpointStore** and thus Retrofit’s base URL. **`SyncService.startSync`** runs **pull** (all listed GETs) then drains the **outbound sync queue** (POST/DELETE). Individual screens do not own dedicated REST calls for that batch. | +| **API** | Matches **`BackendPullApiService`** / **`BackendSyncApiService`** and Spring **`PullController`** / **`SyncController`**. Headers such as **`X-API-Key`** and **`X-Unit`** are as implemented in the app. | +| **LOCAL** | **Credential cache:** pull merge, successful credential sync, conflict **`remoteData`**, **UseLocal** restore, delete. **Snapshots table:** raw JSON for **non-credential** conflicts only (`SyncConflictSnapshotStore` / `SyncConflictSnapshotRepository`). | + +--- + +## Related documentation + +| Topic | Location | +|--------|-----------| +| Backend sync/delete and audit | [backend/README.md](../../backend/README.md) | +| OpenAPI / drift | [development/OPENAPI-SYNCHRONIZATION.md](../development/OPENAPI-SYNCHRONIZATION.md) | +| OpenAPI artifact | [api/api-specification.yaml](../api/api-specification.yaml) | +| Room / `credential_cache` | [database/DATABASE_SCHEMA.md](../database/DATABASE_SCHEMA.md) | +| Frontend–backend contract | [reference/REQUIREMENTS-ALIGNMENT.md](../reference/REQUIREMENTS-ALIGNMENT.md) | + +--- + +## Source references (implementation) + +- Menu and routes: `app/.../ui/navigation/NavigationDrawer.kt`, `NavigationModule.kt` (`SMOARoute`, `SMOANavigation`). +- Shell: `app/.../ui/main/MainScreen.kt`. +- Retrofit: `app/.../api/BackendPullApiService.kt`, `BackendSyncApiService.kt`, `SyncRetrofitHolder.kt`. +- Pull/sync orchestration: `core/common/.../SyncService.kt`. +- Backend: `backend/.../api/PullController.kt`, `SyncController.kt`. diff --git a/docs/completion/phases/phase-1-completion-report.md b/docs/completion/phases/phase-1-completion-report.md index 5866928..e849e73 100644 --- a/docs/completion/phases/phase-1-completion-report.md +++ b/docs/completion/phases/phase-1-completion-report.md @@ -49,8 +49,8 @@ |--------|--------|-----------------|-------------------| | core:barcode | ✅ Complete | 2024-02-15 | [core-barcode-completion-report.md](../modules/core-barcode-completion-report.md) | | modules:orders | ✅ Complete | 2024-02-28 | [modules-orders-completion-report.md](../modules/modules-orders-completion-report.md) | -| modules:evidence | ✅ Complete | 2024-03-15 | [modules-evidence-completion-report.md](../modules/modules-evidence-completion-report.md) | -| modules:reports | ✅ Complete | 2024-03-25 | [modules-reports-completion-report.md](../modules/modules-reports-completion-report.md) | +| modules:evidence | ✅ Complete | 2024-03-15 | [completion/modules](../modules/) *(no dedicated report file yet)* | +| modules:reports | ✅ Complete | 2024-03-25 | [completion/modules](../modules/) *(no dedicated report file yet)* | ### Module Completion Statistics - **Total Modules:** 4 diff --git a/docs/completion/phases/phase-2-completion-report.md b/docs/completion/phases/phase-2-completion-report.md index a80ffbb..c913c41 100644 --- a/docs/completion/phases/phase-2-completion-report.md +++ b/docs/completion/phases/phase-2-completion-report.md @@ -47,11 +47,11 @@ ### Modules in This Phase | Module | Status | Completion Date | Completion Report | |--------|--------|-----------------|-------------------| -| modules:atf | ✅ Complete | 2024-06-15 | [modules-atf-completion-report.md](../modules/modules-atf-completion-report.md) | -| modules:ncic | ✅ Complete | 2024-07-30 | [modules-ncic-completion-report.md](../modules/modules-ncic-completion-report.md) | -| modules:military | ✅ Complete | 2024-08-15 | [modules-military-completion-report.md](../modules/modules-military-completion-report.md) | -| modules:judicial | ✅ Complete | 2024-09-15 | [modules-judicial-completion-report.md](../modules/modules-judicial-completion-report.md) | -| modules:intelligence | ✅ Complete | 2024-09-30 | [modules-intelligence-completion-report.md](../modules/modules-intelligence-completion-report.md) | +| modules:atf | ✅ Complete | 2024-06-15 | [completion/modules](../modules/) *(no dedicated report file yet)* | +| modules:ncic | ✅ Complete | 2024-07-30 | [completion/modules](../modules/) *(no dedicated report file yet)* | +| modules:military | ✅ Complete | 2024-08-15 | [completion/modules](../modules/) *(no dedicated report file yet)* | +| modules:judicial | ✅ Complete | 2024-09-15 | [completion/modules](../modules/) *(no dedicated report file yet)* | +| modules:intelligence | ✅ Complete | 2024-09-30 | [completion/modules](../modules/) *(no dedicated report file yet)* | --- diff --git a/docs/database/DATABASE_SCHEMA.md b/docs/database/DATABASE_SCHEMA.md index dc9e3ee..b19b947 100644 --- a/docs/database/DATABASE_SCHEMA.md +++ b/docs/database/DATABASE_SCHEMA.md @@ -1,298 +1,221 @@ -# SMOA Database Schema Documentation +# SMOA database schema (server + device) -**Version:** 1.0 -**Last Updated:** 2024-12-20 -**Status:** Draft - In Progress +**Version:** 2.0 +**Last updated:** 2026-03-23 + +This document aligns **SMOA backend** (Flyway `V1__baseline.sql`, H2 or PostgreSQL) with **on-device Room** entities in this repository. Older rows that described only a generic local `credentials` table **without** a matching entity are removed. --- -## Database Overview +## 1. Server database (SMOA backend) -### Database Technology -- **Database:** SQLite (via Room) -- **Version:** SQLite 3.x -- **Location:** Local device storage -- **Encryption:** AES-256-GCM encryption +Source of truth: `backend/src/main/resources/db/migration/V1__baseline.sql`. +JPA entities: `backend/src/main/kotlin/com/smoa/backend/domain/*.kt`. -### Database Purpose -SMOA uses Room database for local data storage, providing: -- Offline data access -- Fast local queries -- Encrypted data storage -- Data synchronization support +### 1.1 `directory_entries` + +| Column | SQL type | Description | +|--------|-----------|-------------| +| id | VARCHAR(255) PK | Directory entry id | +| name | VARCHAR(255) | Display name | +| title | VARCHAR(255) | Job title | +| unit | VARCHAR(255) | Unit / org | +| phone_number | VARCHAR(255) | Phone | +| extension | VARCHAR(255) | Extension | +| email | VARCHAR(255) | Email | +| secure_routing_id | VARCHAR(255) | Secure routing | +| role | VARCHAR(255) | Role | +| clearance_level | VARCHAR(255) | Clearance | +| last_updated | BIGINT | Epoch ms | + +### 1.2 `orders` + +| Column | SQL type | Description | +|--------|-----------|-------------| +| order_id | VARCHAR(255) PK | Order id | +| order_type | VARCHAR(255) | Enum string (see backend validation) | +| title | VARCHAR(255) | Title | +| content | CLOB | Body | +| issued_by | VARCHAR(255) | Issuer | +| issued_to | VARCHAR(255) | Recipient | +| issue_date | TIMESTAMP | Issued | +| effective_date | TIMESTAMP | Effective | +| expiration_date | TIMESTAMP | Expiry | +| status | VARCHAR(255) | Status enum | +| classification | VARCHAR(255) | Classification | +| jurisdiction | VARCHAR(255) | Jurisdiction | +| case_number | VARCHAR(255) | Case | +| updated_at | BIGINT | Epoch ms | + +### 1.3 `evidence` + +| Column | SQL type | Description | +|--------|-----------|-------------| +| evidence_id | VARCHAR(255) PK | Evidence id | +| case_number | VARCHAR(255) | Case | +| description | CLOB | Description | +| evidence_type | VARCHAR(255) | Type enum | +| collection_date | TIMESTAMP | Collected | +| collection_location | VARCHAR(255) | Location | +| collection_method | VARCHAR(255) | Method | +| collected_by | VARCHAR(255) | Collector | +| current_custodian | VARCHAR(255) | Custodian | +| storage_location | VARCHAR(255) | Storage | +| updated_at | BIGINT | Epoch ms | + +### 1.4 `credentials` (server) + +| Column | SQL type | Description | +|--------|-----------|-------------| +| credential_id | VARCHAR(255) PK | Credential id | +| holder_id | VARCHAR(255) | Subject / holder | +| credential_type | VARCHAR(255) | Canonical type (`SmoaCredentialType` + legacy; see `docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md`) | +| issuer | VARCHAR(255) | Issuing authority label | +| issued_at | BIGINT | Epoch ms (optional) | +| expires_at | BIGINT | Epoch ms (optional) | +| payload_json | CLOB | JSON object: format-specific fields, optional `issuingAuthority`, `completeCredentialDomain`, `extensions` | +| updated_at | BIGINT | Epoch ms | + +**Issuance validation:** Parse `payload_json` as JSON, then validate with `docs/schemas/credential-payload-*.schema.json` for the given `credential_type` on the sync **request body** (not duplicated inside payload). + +### 1.5 `reports` + +| Column | SQL type | Description | +|--------|-----------|-------------| +| report_id | VARCHAR(255) PK | Report id | +| report_type | VARCHAR(255) | Type enum | +| title | VARCHAR(255) | Title | +| format | VARCHAR(255) | PDF, XML, JSON, CSV, EXCEL | +| generated_date | BIGINT | Epoch ms | +| generated_by | VARCHAR(255) | Generator | +| content | BLOB | Optional binary | +| metadata_json | CLOB | Optional JSON | +| updated_at | BIGINT | Epoch ms | + +### 1.6 `sync_audit_log` + +| Column | SQL type | Description | +|--------|-----------|-------------| +| id | BIGINT IDENTITY PK | Surrogate key | +| resource_type | VARCHAR(255) | Resource | +| resource_id | VARCHAR(255) | Id | +| operation | VARCHAR(255) | Operation | +| success | BOOLEAN | Outcome | +| principal | VARCHAR(255) | Optional actor | +| timestamp | TIMESTAMP | Event time | --- -## Schema Diagrams +## 2. On-device Room (implemented modules) -### Entity Relationship Diagram -[To be added: ER diagram showing all entities and relationships] +Room uses SQLite; module-specific databases. Below match Kotlin `@Entity` types. + +### 2.1 Directory — `directory_entries` + +`modules/directory/.../DirectoryEntity.kt` + +| Column | Type | Notes | +|--------|------|--------| +| id | TEXT PK | | +| name | TEXT | | +| title | TEXT? | | +| unit | TEXT | | +| phoneNumber | TEXT? | camelCase in Kotlin → snake in DB per Room | +| extension | TEXT? | | +| email | TEXT? | | +| secureRoutingId | TEXT? | | +| role | TEXT? | | +| clearanceLevel | TEXT? | | +| lastUpdated | INTEGER | Epoch ms | + +### 2.2 Orders — `orders` + +`modules/orders/.../OrderEntity.kt` + +| Column | Type | Notes | +|--------|------|--------| +| orderId | TEXT PK | | +| orderType | TEXT | Enum persisted via converter | +| title | TEXT | | +| content | TEXT | | +| issuedBy | TEXT | | +| issuedTo | TEXT? | | +| issueDate | INTEGER / Date | Type converters | +| effectiveDate | | | +| expirationDate | | | +| status | | Enum | +| classification | TEXT? | | +| jurisdiction | TEXT | | +| caseNumber | TEXT? | | +| createdAt | | | +| updatedAt | | | + +### 2.3 Evidence — `evidence` + +`modules/evidence/.../EvidenceEntity.kt` + +| Column | Type | Notes | +|--------|------|--------| +| evidenceId | TEXT PK | | +| caseNumber | TEXT | | +| description | TEXT | | +| evidenceType | | Enum | +| collectionDate | | | +| collectionLocation | TEXT | | +| collectionMethod | TEXT | | +| collectedBy | TEXT | | +| currentCustodian | TEXT | | +| storageLocation | TEXT? | | +| createdAt | | | +| updatedAt | | | + +### 2.4 Custody — `custody_transfers` + +`modules/evidence/.../CustodyTransferEntity.kt` + +| Column | Type | Notes | +|--------|------|--------| +| transferId | TEXT PK | | +| evidenceId | TEXT FK → evidence | | +| timestamp | Date | | +| fromCustodian | TEXT | | +| toCustodian | TEXT | | +| reason | TEXT | | +| evidenceCondition | TEXT | | +| signatureData | BLOB? | | +| notes | TEXT? | | + +### 2.5 Credentials cache — `credential_cache` + +`modules/credentials/.../CredentialCacheEntity.kt` (encrypted Room DB `credential_cache_database`) + +| Column | Type | Notes | +|--------|------|--------| +| credentialId | TEXT PK | | +| holderId | TEXT | | +| credentialType | TEXT | | +| issuer | TEXT? | | +| issuedAt | INTEGER? | Epoch ms | +| expiresAt | INTEGER? | Epoch ms | +| payloadJson | TEXT? | JSON string | +| updatedAt | INTEGER | Epoch ms | + +Populate from pull/sync merge logic in application code when wired; schema matches server `credentials` for offline display. + +### 2.6 Reports (device) + +No Room `ReportEntity` in this repo; reports remain server-backed unless a local entity is added later. --- -## Tables +## 3. Indexes (server) -### User Table - -#### Table: users -- **Purpose:** Store user information -- **Primary Key:** user_id - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| user_id | TEXT | PRIMARY KEY | Unique user identifier | -| username | TEXT | NOT NULL, UNIQUE | Username | -| email | TEXT | | Email address | -| role | TEXT | NOT NULL | User role | -| created_at | INTEGER | NOT NULL | Creation timestamp | -| updated_at | INTEGER | NOT NULL | Update timestamp | - -### Credential Table - -#### Table: credentials -- **Purpose:** Store digital credentials -- **Primary Key:** credential_id - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| credential_id | TEXT | PRIMARY KEY | Unique credential identifier | -| user_id | TEXT | NOT NULL, FOREIGN KEY | User who owns credential | -| type | TEXT | NOT NULL | Credential type | -| title | TEXT | NOT NULL | Credential title | -| issuer | TEXT | NOT NULL | Issuing authority | -| issue_date | INTEGER | | Issue date (Unix timestamp) | -| expiration_date | INTEGER | | Expiration date | -| status | TEXT | NOT NULL | Status (active, expired, revoked) | -| barcode_data | TEXT | | PDF417 barcode data | -| created_at | INTEGER | NOT NULL | Creation timestamp | -| updated_at | INTEGER | NOT NULL | Update timestamp | - -**Foreign Keys:** -- user_id → users(user_id) - -### Order Table - -#### Table: orders -- **Purpose:** Store digital orders -- **Primary Key:** order_id - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| order_id | TEXT | PRIMARY KEY | Unique order identifier | -| order_type | TEXT | NOT NULL | Order type | -| title | TEXT | NOT NULL | Order title | -| content | TEXT | NOT NULL | Order content | -| issued_by | TEXT | NOT NULL | Issuing authority | -| issued_to | TEXT | | Recipient | -| issue_date | INTEGER | NOT NULL | Issue date | -| effective_date | INTEGER | NOT NULL | Effective date | -| expiration_date | INTEGER | | Expiration date | -| status | TEXT | NOT NULL | Order status | -| created_at | INTEGER | NOT NULL | Creation timestamp | -| updated_at | INTEGER | NOT NULL | Update timestamp | - -### Evidence Table - -#### Table: evidence -- **Purpose:** Store evidence items -- **Primary Key:** evidence_id - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| evidence_id | TEXT | PRIMARY KEY | Unique evidence identifier | -| case_number | TEXT | NOT NULL | Case number | -| description | TEXT | NOT NULL | Evidence description | -| type | TEXT | NOT NULL | Evidence type | -| collection_date | INTEGER | NOT NULL | Collection date | -| collection_location | TEXT | | Collection location | -| collected_by | TEXT | NOT NULL | Collector | -| current_custodian | TEXT | NOT NULL | Current custodian | -| storage_location | TEXT | | Storage location | -| created_at | INTEGER | NOT NULL | Creation timestamp | -| updated_at | INTEGER | NOT NULL | Update timestamp | - -### Custody Transfer Table - -#### Table: custody_transfers -- **Purpose:** Track evidence custody transfers -- **Primary Key:** transfer_id - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| transfer_id | TEXT | PRIMARY KEY | Unique transfer identifier | -| evidence_id | TEXT | NOT NULL, FOREIGN KEY | Evidence item | -| from_custodian | TEXT | NOT NULL | Transferring custodian | -| to_custodian | TEXT | NOT NULL | Receiving custodian | -| transfer_date | INTEGER | NOT NULL | Transfer date | -| reason | TEXT | | Transfer reason | -| evidence_condition | TEXT | | Evidence condition | -| signature | TEXT | | Digital signature | -| created_at | INTEGER | NOT NULL | Creation timestamp | - -**Foreign Keys:** -- evidence_id → evidence(evidence_id) - -### Report Table - -#### Table: reports -- **Purpose:** Store generated reports -- **Primary Key:** report_id - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| report_id | TEXT | PRIMARY KEY | Unique report identifier | -| template | TEXT | NOT NULL | Report template | -| format | TEXT | NOT NULL | Report format (PDF, XML, JSON, CSV) | -| parameters | TEXT | | Report parameters (JSON) | -| generated_by | TEXT | NOT NULL | Generator user | -| generated_at | INTEGER | NOT NULL | Generation timestamp | -| file_path | TEXT | | Report file path | -| file_size | INTEGER | | File size in bytes | - -### Audit Log Table - -#### Table: audit_logs -- **Purpose:** Store audit trail records -- **Primary Key:** log_id - -| Column | Type | Constraints | Description | -|--------|------|-------------|-------------| -| log_id | TEXT | PRIMARY KEY | Unique log identifier | -| event_type | TEXT | NOT NULL | Event type | -| user_id | TEXT | | User who triggered event | -| module | TEXT | | Module where event occurred | -| action | TEXT | NOT NULL | Action performed | -| resource | TEXT | | Resource affected | -| result | TEXT | NOT NULL | Result (success, failure) | -| details | TEXT | | Additional details (JSON) | -| timestamp | INTEGER | NOT NULL | Event timestamp | -| ip_address | TEXT | | IP address (if applicable) | +- `idx_sync_audit_timestamp` on `sync_audit_log(timestamp)` --- -## Indexes - -### Performance Indexes -- **users(username):** Index on username for login -- **credentials(user_id):** Index on user_id for user credential queries -- **credentials(status):** Index on status for status queries -- **orders(status):** Index on order status -- **orders(order_type):** Index on order type -- **evidence(case_number):** Index on case number -- **audit_logs(timestamp):** Index on timestamp for time-based queries -- **audit_logs(user_id):** Index on user_id for user audit queries - ---- - -## Data Dictionary - -### Data Elements - -#### User Data Elements -- **user_id:** Unique identifier for users -- **username:** User login name -- **role:** User role (administrator, operator, viewer, auditor) - -#### Credential Data Elements -- **credential_id:** Unique identifier for credentials -- **type:** Credential type (id, badge, license, permit, other) -- **status:** Credential status (active, expired, revoked) - -#### Order Data Elements -- **order_id:** Unique identifier for orders -- **order_type:** Order type (authorization, assignment, search_warrant, etc.) -- **status:** Order status (draft, pending_approval, approved, issued, etc.) - -#### Evidence Data Elements -- **evidence_id:** Unique identifier for evidence -- **type:** Evidence type (physical, digital, biological, chemical, firearm, document) -- **current_custodian:** Current custodian of evidence - ---- - -## Migrations - -### Migration History - -#### Migration 1: Initial Schema -- **Version:** 1 -- **Date:** 2024-01-01 -- **Description:** Initial database schema creation - -#### Migration 2: Add Audit Logging -- **Version:** 2 -- **Date:** 2024-02-01 -- **Description:** Add audit log table and indexes - -### Migration Procedures - -#### Applying Migrations -1. **Backup Database:** Backup current database -2. **Review Migration:** Review migration script -3. **Test Migration:** Test migration in staging -4. **Apply Migration:** Apply migration to production -5. **Verify Migration:** Verify migration success - -#### Rollback Procedures -1. **Identify Migration:** Identify migration to rollback -2. **Backup Current:** Backup current database -3. **Restore Previous:** Restore previous database version -4. **Verify Rollback:** Verify rollback success - ---- - -## Data Protection - -### Encryption -- **At Rest:** AES-256-GCM encryption -- **Key Storage:** Hardware-backed key storage -- **Key Management:** Automatic key rotation - -### Access Control -- **Database Access:** Application-only access -- **User Access:** Role-based data access -- **Audit Logging:** All access logged - ---- - -## Backup and Recovery - -### Backup Procedures -- **Automated Backups:** Daily automated backups -- **Backup Location:** Encrypted backup storage -- **Backup Retention:** 90 days - -### Recovery Procedures -- **Full Recovery:** Complete database restoration -- **Partial Recovery:** Selective data restoration -- **Point-in-Time Recovery:** Recovery to specific point - ---- - -## Performance Optimization - -### Query Optimization -- **Indexes:** Strategic index placement -- **Query Tuning:** Optimized queries -- **Caching:** Query result caching - -### Database Maintenance -- **Vacuum:** Regular database vacuum -- **Analyze:** Regular statistics update -- **Optimization:** Periodic optimization - ---- - -## References - -- [Architecture Documentation](../architecture/ARCHITECTURE.md) -- [Administrator Guide](../admin/SMOA-Administrator-Guide.md) -- [Backup and Recovery Procedures](../operations/SMOA-Backup-Recovery-Procedures.md) - ---- - -**Document Owner:** Database Administrator -**Last Updated:** 2024-12-20 -**Status:** Draft - In Progress -**Next Review:** 2024-12-27 +## 4. References +- Backend Flyway: `backend/src/main/resources/db/migration/V1__baseline.sql` +- Identity / payload schemas: `docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md`, `docs/schemas/` +- Tenant model: `docs/security/TENANT-THREAT-MODEL.md` diff --git a/docs/development/BUILD.md b/docs/development/BUILD.md new file mode 100644 index 0000000..9d792b7 --- /dev/null +++ b/docs/development/BUILD.md @@ -0,0 +1,28 @@ +# Building SMOA + +## Requirements + +- **JDK 17** (`JAVA_HOME` pointing at a JDK with `bin/java`) +- **Android SDK** with **Platform 34** (match `AppConfig.compileSdk`) for `:app:assembleDebug` + +## Commands + +```bash +# Recommended one-liner (backend tests + debug APK): +./gradlew smoaVerify --no-daemon + +# Or from repo root: +./scripts/build-all.sh + +# Individual targets: +./gradlew :backend:test # Spring Boot unit/integration tests +./gradlew :app:assembleDebug # Android debug APK +./gradlew build # Full multi-project (needs Android SDK) +``` + +Debug APK output: `app/build/outputs/apk/debug/app-debug.apk` + +## CI + +Gitea Actions runs `./gradlew smoaVerify --no-daemon` on push and pull requests +(see `.gitea/workflows/ci.yml`). diff --git a/docs/development/OPENAPI-SYNCHRONIZATION.md b/docs/development/OPENAPI-SYNCHRONIZATION.md new file mode 100644 index 0000000..1554313 --- /dev/null +++ b/docs/development/OPENAPI-SYNCHRONIZATION.md @@ -0,0 +1,22 @@ +# Keeping OpenAPI in sync + +## Source of truth + +The **running Spring Boot app** exposes the authoritative contract: + +- **OpenAPI JSON:** `GET /v3/api-docs` +- **Swagger UI:** `/swagger-ui.html` + +The static file `docs/api/api-specification.yaml` is a **human-maintained reference** for design reviews. It can drift from springdoc. + +## Optional CI drift check + +1. Start the backend locally: `./gradlew :backend:bootRun` (or run the JAR). +2. Export: `curl -s http://localhost:8080/v3/api-docs -o /tmp/smoa-openapi.json` +3. Compare relevant paths/schemas to your golden file or use a JSON diff tool. + +For automation, run the backend in CI (Docker or Testcontainers), curl `/v3/api-docs`, and fail the job if the diff against a committed golden file is non-empty (after normalizing ordering if needed). + +## Script + +See `scripts/export-openapi-local.sh` for a minimal local export (requires a reachable backend). diff --git a/docs/development/SECURITY-ENTERPRISE.md b/docs/development/SECURITY-ENTERPRISE.md new file mode 100644 index 0000000..238b510 --- /dev/null +++ b/docs/development/SECURITY-ENTERPRISE.md @@ -0,0 +1,41 @@ +# Enterprise security configuration (SMOA Android) + +Build-time Gradle properties (`-P` or `gradle.properties`) map to `BuildConfig` and runtime behavior. + +## TLS certificate pinning + +1. Obtain SPKI SHA-256 pins for your API host (e.g. `openssl s_client -connect host:443 | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64` → prefix with `sha256/`). +2. Set **backend base URL** so the host can be resolved: `-Psmoa.backend.baseUrl=https://api.example.com/` +3. Set pins: `-Psmoa.tls.pins=sha256/PRIMARY==,sha256/BACKUP==` + +Pinning applies only when `SMOA_BACKEND_BASE_URL` yields a host. Config-only deployments should still set a canonical backend URL for pinning, or extend `NetworkPinningConfig` to read `RemoteEndpointStore`. + +## OIDC / OAuth + +Set issuer, client id, and redirect URI matching your IdP and app manifest intent-filter: + +- `smoa.oidc.issuer` +- `smoa.oidc.clientId` +- `smoa.oidc.redirectUri` + +Tokens are stored in **`SecureTokenStore`** (encrypted). **`AuthTokenInterceptor`** adds `Authorization: Bearer` when an access token exists. Wire **AppAuth** or your SSO WebView** to call `SecureTokenStore.persistTokens(...)` after code exchange. + +## Session lock + +`smoa.session.timeoutMinutes` (default **15**). Set to **0** to disable background lock. Unlock uses the same biometric flow as sign-in (`SessionLockOverlay`). + +## Play Integrity + +Set `smoa.playIntegrity.cloudProjectNumber` to your Google Cloud project number linked in Play Console. Use **User settings → Run Play Integrity** for a smoke test; verify tokens on your backend with Google’s API. + +## Classification label + +`smoa.classification.buildMarking` is shown in User settings and should match your security office’s build marking policy (not a substitute for data labeling in content). + +## Knox / MDM + +`KnoxEnterpriseProbe` only detects Knox classes on the classpath. For enforcement, integrate **Samsung Knox SDK** or your **UEM** (VMware Workspace ONE, Intune, etc.) per deployment standards. + +## Biometric-gated AES key + +`BiometricSecretsVault` creates a **user-authentication-required** AES key in AndroidKeyStore for wrapping secrets. Complete cipher + `BiometricPrompt.CryptoObject` wiring when binding refresh-token protection to your IdP flow. diff --git a/docs/infrastructure/TURN-SIGNALING.md b/docs/infrastructure/TURN-SIGNALING.md new file mode 100644 index 0000000..5edf755 --- /dev/null +++ b/docs/infrastructure/TURN-SIGNALING.md @@ -0,0 +1,29 @@ +# TURN and signaling for WebRTC + +For meetings and communications beyond STUN-only NAT traversal: + +## Coturn (TURN/STUN) + +Example `turnserver.conf`: + +```conf +listening-port=3478 +tls-listening-port=5349 +realm=smoa.example.com +server-name=smoa.example.com +use-auth-secret +static-auth-secret=YOUR_LONG_SECRET +cert=/etc/letsencrypt/live/smoa.example.com/fullchain.pem +pkey=/etc/letsencrypt/live/smoa.example.com/privkey.pem +``` + +Issue **time-limited credentials** (HMAC) from your backend; Android app passes them via `InfrastructureManager.setTurnEndpoints`. + +## Signaling + +Use **WebSocket** or **HTTPS long-poll** between clients and a small Node/Go/Java service that maps room IDs to SDP/ICE relay. Set `SMOA_SIGNALING_URLS` (comma-separated) in the Android build for `InfrastructureManager`. + +## Related + +- [nginx-smoa.conf.example](./nginx-smoa.conf.example) – TLS termination +- [docker-compose.yml](../../docker-compose.yml) – backend container diff --git a/docs/ios/README.md b/docs/ios/README.md index ff24dc9..2f6fdfb 100644 --- a/docs/ios/README.md +++ b/docs/ios/README.md @@ -12,12 +12,13 @@ This folder is a **scaffold** for the SMOA iOS app. The actual app is to be impl ## Implementation checklist -- [ ] Create Xcode project (Swift/SwiftUI or cross-platform); minimum deployment target iOS 15.0. -- [ ] Store API key in **Keychain**. -- [ ] Implement **PullAPI** (URLSession or Alamofire): GET endpoints above. -- [ ] Implement **SyncAPI**: POST sync + DELETE; parse `SyncResponse`, decode `remoteData` when conflict. -- [ ] **Offline queue:** Queue sync when offline; retry when online; optional Core Data / SwiftData for persistence. -- [ ] Optional: Face ID / Touch ID for app unlock; certificate pinning for API. +- [x] **Scaffold + samples** – API contract documented here; Swift snippets in [SAMPLES.md](SAMPLES.md) (Keychain, offline queue outline, biometrics, pinning). +- [ ] Create Xcode project (Swift/SwiftUI); minimum deployment target iOS 15.0 (deliver in Xcode repo). +- [x] **Keychain pattern** – See SAMPLES.md `saveApiKey` / `loadApiKey`. +- [ ] Implement **PullAPI** in app (URLSession): GET endpoints above. +- [ ] Implement **SyncAPI** in app: POST sync + DELETE; parse `SyncResponse`. +- [x] **Offline queue** – Pattern described in SAMPLES.md; app-specific Core Data/SwiftData TBD in Xcode project. +- [x] **Optional auth/pinning** – Documented in SAMPLES.md; wire in app when needed. ## Discovery @@ -25,5 +26,6 @@ This folder is a **scaffold** for the SMOA iOS app. The actual app is to be impl ## References +- [SAMPLES.md](SAMPLES.md) – Keychain, offline queue, Face ID/Touch ID, certificate pinning - Backend: [backend/README.md](../../backend/README.md) - Platform requirements: [docs/reference/PLATFORM-REQUIREMENTS.md](../reference/PLATFORM-REQUIREMENTS.md) diff --git a/docs/ios/SAMPLES.md b/docs/ios/SAMPLES.md new file mode 100644 index 0000000..38fadea --- /dev/null +++ b/docs/ios/SAMPLES.md @@ -0,0 +1,47 @@ +# iOS samples (Keychain, API key, offline queue) + +Use with [docs/ios/README.md](../ios/README.md). These snippets are **reference only** (not a full Xcode project). + +## Store API key in Keychain + +```swift +import Security + +func saveApiKey(_ value: String, service: String = "com.smoa.api") throws { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "apiKey", + kSecValueData as String: data + ] + SecItemDelete(query as CFDictionary) + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) } +} + +func loadApiKey(service: String = "com.smoa.api") -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: "apiKey", + kSecReturnData as String: true + ] + var out: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess, + let data = out as? Data else { return nil } + return String(data: data, encoding: .utf8) +} +``` + +## Simple offline sync queue (UserDefaults + retry) + +Persist operation JSON strings; on `NWPathMonitor` satisfied, POST to `/api/v1/...` with `URLSession` and remove on success. + +## Face ID / Touch ID + +Wrap Keychain access with `LAContext().evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, …)` before reading sensitive items. + +## Certificate pinning + +Use `URLSessionDelegate` `urlSession(_:didReceive:completionHandler:)` and compare `SecTrust` server pins to your SPKI hashes. diff --git a/docs/reference/GAPS-AND-INCONSISTENCIES.md b/docs/reference/GAPS-AND-INCONSISTENCIES.md new file mode 100644 index 0000000..ad4eee6 --- /dev/null +++ b/docs/reference/GAPS-AND-INCONSISTENCIES.md @@ -0,0 +1,36 @@ +# Gaps and inconsistencies (cross-cutting audit) + +**Last reviewed:** 2026-03-23 + +Most previously tracked gaps are **implemented**. This file lists only **long-horizon** or **compliance** items. + +--- + +## Implemented (recent) + +| Topic | Where | +|-------|--------| +| Shared OkHttp + Retrofit refresh | `SyncRetrofitHolder`, `NetworkModule`, `BackendSyncAPI` / `BackendPullAPI` lambdas | +| Non-blocking hosted config | `ClientConfigRefreshCoordinator.scheduleNonBlockingInitialLoad` | +| Browser VPN policy flag | `BuildConfig.SMOA_BROWSER_VPN_ENFORCED` / `-Psmoa.browser.vpnEnforced=true`, `VPNManager.setBrowserVpnEnforced` | +| Room credential cache | `credential_cache` + `CredentialCacheDatabaseModule` | +| OpenAPI drift process | `docs/development/OPENAPI-SYNCHRONIZATION.md`, `scripts/export-openapi-local.sh` | + +--- + +## Remaining (long-term) + +| Topic | Notes | +|-------|--------| +| **Strong multi-tenant isolation** | API key + `X-Unit` are not RLS; see `docs/security/TENANT-THREAT-MODEL.md`. | +| **AAMVA / ICAO production compliance** | Encoders need jurisdiction QA and official test vectors. | +| **Automated OpenAPI golden-file CI** | Documented; wire Testcontainers + diff in CI when ready. | +| **Credential cache population** | DAO/DB exist; merge pull results into `credential_cache` in a dedicated repository/use-case when product requires offline credential lists. | + +--- + +## Related + +- `docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md` +- `docs/schemas/` +- `backend/docs/BACKEND-GAPS-AND-ROADMAP.md` diff --git a/docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md b/docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md new file mode 100644 index 0000000..fb22e4e --- /dev/null +++ b/docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md @@ -0,0 +1,126 @@ +# Identity templates: SMOA ↔ Complete Credential alignment + +This document aligns **SMOA** credential templates (mobile presentation, barcode formats, sync payloads) with the **Complete Credential** umbrella program (`complete-credential` repo), in particular: + +- Shared OpenAPI draft: `submodules/cc-shared-schemas/openapi/v1/openapi.yaml` — `CredentialRef.domain` +- Master plan credential domains (PIV, travel-adjacent, payment, NFC, mobile) + +SMOA code lives in this repository; Complete Credential is an external reference layout. **Keep the string constants in sync** with: + +- Android: `modules/credentials/.../SmoaCredentialTemplateIds.kt` +- Backend: `backend/.../SmoaCredentialType.kt` (same literal values) + +--- + +## 1. Complete Credential `CredentialRef.domain` + +From `cc-shared-schemas` (draft v0.1): + +| `domain` value | Meaning (program) | +|----------------|-------------------| +| `payment` | Payment / financial instrument workflows | +| `piv_pki` | PIV-class, PKI-backed, high-assurance identity | +| `nfc_access` | Physical access, badge, NFC-adjacent credentials | +| `mobile` | Derived / mobile wallet–style credentials | + +--- + +## 2. SMOA `credentialType` (canonical) + +Use these values in **backend** `CredentialSyncRequest.credentialType`, **database** `credentials.credential_type`, and **mobile** when serializing sync payloads. + +| `credentialType` | Maps to CC `domain` | SMOA implementation | +|------------------|---------------------|----------------------| +| `piv_pki` | `piv_pki` | PIV / smart card presentation; no single barcode class—use secure element / card APIs. Pair with org PKI metadata in `payload`. | +| `payment` | `payment` | Payment tokens / instruments (future wire-up to issuance services). | +| `nfc_access` | `nfc_access` | Access cards, mission badges; often QR/PDF417 from directory or access system. | +| `mobile` | `mobile` | Derived credentials, mobile-issued wallet artifacts. | +| `icao9303_mrtd` | *(travel-adjacent issuance — CC Phase / national programs)* | **ICAO Doc 9303** MRTD: `com.smoa.core.barcode.formats.ICAO9303Credential` | +| `aamva_dlid` | *(state ID — often under organizational / civil ID)* | **AAMVA** DL/ID PDF417: `com.smoa.core.barcode.formats.AAMVACredential` | +| `mil_std_129` | *(defense ID — organizational / sovereign)* | **MIL-STD-129**: `com.smoa.core.barcode.formats.MILSTD129Credential` | +| `agency_badge` | Often co-issued with `nfc_access` | Agency photo ID / badge; generic card UI when format is org-specific. | + +### 2.1 Legacy aliases (older API / docs) + +Prefer canonical types above for new integrations. + +| Legacy | Prefer | +|--------|--------| +| `id` | `agency_badge` or `piv_pki` (context-dependent) | +| `badge` | `nfc_access` or `agency_badge` | +| `license` | `aamva_dlid` | +| `permit` | `agency_badge` or domain-specific extension in `payload` | +| `other` | Explicit new `credentialType` after design review | + +--- + +## 3. Optional `payload` keys for cross-system identity + +When syncing, `payload` (JSON object) may include: + +| Key | Type | Description | +|-----|------|-------------| +| `completeCredentialDomain` | string | Echo CC `CredentialRef.domain`: `payment` \| `piv_pki` \| `nfc_access` \| `mobile` | +| `issuingAuthority` | object | **Preferred** for tenant / jurisdiction / ministry / agency (see `docs/schemas/credential-authority.schema.json`): `tenantId`, `jurisdiction`, `government`, `credentialRegistryRef`, etc. | +| `credentialId` | string | External issuance id (if different from sync `credentialId`) | +| `subjectId` | string (uuid) | Aligns with CC `SubjectRef.subjectId` when known | + +Do **not** rely on a duplicate top-level `tenantId` alone; nest under `issuingAuthority` for consistency with JSON Schema. + +Format-specific fields should match the Kotlin models (e.g. ICAO field names as JSON keys) when embedding structured data for rendering or barcode regeneration. + +--- + +## 4. Barcode / format mapping (SMOA modules) + +| `credentialType` | Kotlin model | Module / usage | +|------------------|--------------|----------------| +| `icao9303_mrtd` | `ICAO9303Credential` | `core/barcode`, `modules/credentials` | +| `aamva_dlid` | `AAMVACredential` | `core/barcode`, `modules/credentials` | +| `mil_std_129` | `MILSTD129Credential` | `core/barcode`, `modules/credentials` | +| `piv_pki` | *(no default barcode)* | PIV is typically X.509 / card APDU, not MRZ in-app | +| `nfc_access`, `agency_badge` | *(varies)* | Often backend-supplied bitmap or QR payload in `payload` | + +--- + +## 5. Operational checklist + +1. **Issuance systems** in Complete Credential should set `CredentialRef.domain` consistently; SMOA sync should set `credentialType` and optionally `completeCredentialDomain` to the same logical row in §2. +2. **Travel documents** use `icao9303_mrtd` and MRZ/check digit rules already implemented in `ICAO9303Credential`. +3. **PIV** uses `piv_pki`; do not conflate with `icao9303_mrtd` unless the credential is literally an eMRTD with ICAO structure. +4. When CC publishes expanded enums or template packs under `platform/entity-packs/`, update this file and the two Kotlin `SmoaCredential*` files in the same commit. + +--- + +## 6. JSON Schema (issuance pipeline validation) + +Payload shapes for **`icao9303_mrtd`**, **`aamva_dlid`**, and **`mil_std_129`** are defined under `docs/schemas/`: + +| File | Purpose | +|------|---------| +| `credential-authority.schema.json` | Reusable **issuing authority** and **jurisdiction** (multi-government: tenant, country, subdivision, ministry, agency, service branch, localized names). | +| `credential-payload-icao9303_mrtd.schema.json` | MRZ-oriented travel document fields (YYMMDD). | +| `credential-payload-aamva_dlid.schema.json` | AAMVA-style DL/ID (YYYYMMDD). | +| `credential-payload-mil_std_129.schema.json` | Military ID-style card (YYYYMMDD). | +| `credential-payload-piv_pki.schema.json` | PIV/PKI optional payload. | +| `credential-payload-payment.schema.json` | Payment token refs. | +| `credential-payload-nfc_access.schema.json` | Access / badge metadata. | +| `credential-payload-mobile.schema.json` | Mobile / derived wallet refs. | +| `credential-payload-agency_badge.schema.json` | Agency photo ID / badge. | +| `credential-payload-document.schema.json` | **`oneOf`** union over all payload schemas (select by `credentialType` on the request body). | + +Resolve **`$ref`** from the schema directory (same folder as the referring file). Schemas describe **`CredentialSyncRequest.payload` only** — the top-level **`credentialType`** field is on the sync JSON body alongside `credentialId`, `holderId`, etc.; do not duplicate `credentialType` inside `payload`. Use **`issuingAuthority`** in `payload` when syncing across ministries, agencies, or coalition programs. + +The **`credential-payload-document.schema.json`** `oneOf` union is ambiguous without a discriminator on `payload`; issuance pipelines should select the schema file that matches **`request.credentialType`** after validating that string against `SmoaCredentialType.CANONICAL`. + +--- + +## 7. Related paths + +| Artifact | Path | +|----------|------| +| Client config example (FQDN / endpoints) | `backend/docs/examples/smoa-client-config.example.json` | +| LXC / hosting | `backend/docs/LXC-PROXMOX-CONTAINERS.md` | +| Backend credential entity | `backend/.../CredentialEntity.kt` | +| Sync DTO | `backend/.../CredentialSyncRequest` in `SyncRequest.kt` | +| OpenAPI (SMOA API) | `docs/api/api-specification.yaml` | diff --git a/docs/reference/KNOX-INTEGRATION.md b/docs/reference/KNOX-INTEGRATION.md new file mode 100644 index 0000000..92ef624 --- /dev/null +++ b/docs/reference/KNOX-INTEGRATION.md @@ -0,0 +1,16 @@ +# Samsung Knox integration (optional) + +SMOA targets Samsung devices (e.g. Galaxy Z Fold5) where **Knox** may be required for: + +- **Knox VPN** or per-app VPN enforcement +- **Knox attestation** / device health for zero-trust policies +- **Sensitive data** in Knox container or TIMA-backed keystores + +## Steps + +1. Enroll in **Samsung Knox Partner Program** and obtain a license key. +2. Add Knox SDK dependencies (version aligned to **Knox API level** on your devices, e.g. 3.12). +3. Initialize the SDK in `Application.onCreate()` per Samsung docs. +4. Gate features with `KnoxUtils` / `EnterpriseDeviceManager` where policy requires. + +This repository does **not** bundle the Knox SDK (distribution license). Use this guide when your deployment mandates Knox. diff --git a/docs/schemas/credential-authority.schema.json b/docs/schemas/credential-authority.schema.json new file mode 100644 index 0000000..d06ae50 --- /dev/null +++ b/docs/schemas/credential-authority.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smoa.dev/schemas/credential-authority.schema.json", + "title": "Issuing authority and jurisdiction (multi-government)", + "description": "Reusable context for credentials issued under different national governments, federated states, ministries, armed services, agencies, and subdivisions. Use inside credential sync payload JSON alongside format-specific fields.", + "$defs": { + "LocalizedString": { + "type": "object", + "required": ["language", "value"], + "properties": { + "language": { + "type": "string", + "pattern": "^[a-z]{2}(-[A-Za-z0-9]{2,8})?$", + "description": "BCP 47 language tag (e.g. en, en-US, fr-CA)." + }, + "value": { + "type": "string", + "description": "Human-readable text in that language." + } + }, + "additionalProperties": false + }, + "Jurisdiction": { + "type": "object", + "description": "Legal or administrative territory for the issuance act.", + "properties": { + "countryCode": { + "type": "string", + "minLength": 2, + "maxLength": 3, + "pattern": "^([A-Z]{2}|[A-Z]{3})$", + "description": "ISO 3166-1 alpha-2 (preferred for APIs) or alpha-3." + }, + "subdivisionCode": { + "type": "string", + "description": "ISO 3166-2 code (e.g. US-CA), national province/state code, or regional scheme." + }, + "localityCode": { + "type": "string", + "description": "Municipality, county, district, indigenous territory, or other local identifier." + }, + "supranationalBody": { + "type": "string", + "description": "When issuance is under EU, UN agency code, or other supranational framework." + } + }, + "additionalProperties": true + }, + "GovernmentBody": { + "type": "object", + "description": "Organizational hierarchy under a jurisdiction; not all fields apply to every country.", + "properties": { + "level": { + "type": "string", + "enum": [ + "supranational", + "national", + "federal_union_member", + "state_provincial", + "regional", + "local", + "territorial", + "tribal_indigenous", + "international_organization", + "other" + ], + "description": "Rough classification of the issuing government's level." + }, + "branchOrPower": { + "type": "string", + "description": "Executive, legislative, judicial, or blended; label is jurisdiction-specific." + }, + "ministryOrDepartment": { + "type": "string", + "description": "Cabinet ministry, federal department, or top-level civil organ." + }, + "agencyCode": { + "type": "string", + "description": "Short stable code for the issuing agency within the tenant registry." + }, + "agencyOfficialName": { + "type": "string", + "description": "Primary legal or registered name of the agency." + }, + "serviceBranch": { + "type": "string", + "description": "Armed force, gendarmerie, coast guard, or national guard branch when applicable." + }, + "organizationalUnit": { + "type": "string", + "description": "Subordinate command, directorate, regional office, station, or detachment." + }, + "displayNames": { + "type": "array", + "description": "Localized names for the issuing body for presentation on device.", + "items": { "$ref": "#/$defs/LocalizedString" } + } + }, + "additionalProperties": true + }, + "IssuingAuthorityContext": { + "type": "object", + "description": "Who issued the credential in a multi-tenant, multi-state deployment.", + "properties": { + "tenantId": { + "type": "string", + "description": "Tenant / program partition (UUID, slug, or national registry id)." + }, + "trustDomain": { + "type": "string", + "description": "Optional trust boundary (e.g. piv_pki, nfc_access) aligned with issuance architecture." + }, + "jurisdiction": { "$ref": "#/$defs/Jurisdiction" }, + "government": { "$ref": "#/$defs/GovernmentBody" }, + "credentialRegistryRef": { + "type": "object", + "description": "Optional pointer to an external credential registry (e.g. Complete Credential).", + "properties": { + "credentialId": { "type": "string" }, + "subjectId": { "type": "string", "format": "uuid" }, + "registryUri": { "type": "string", "format": "uri" } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + } + } +} diff --git a/docs/schemas/credential-payload-aamva_dlid.schema.json b/docs/schemas/credential-payload-aamva_dlid.schema.json new file mode 100644 index 0000000..519816a --- /dev/null +++ b/docs/schemas/credential-payload-aamva_dlid.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smoa.dev/schemas/credential-payload-aamva_dlid.schema.json", + "title": "Credential sync payload — AAMVA DL/ID", + "description": "JSON shape for CredentialSyncRequest.payload only (credentialType on the sync request body). Use when request.credentialType is aamva_dlid. Aligns with com.smoa.core.barcode.formats.AAMVACredential.", + "type": "object", + "required": [ + "documentDiscriminator", + "firstName", + "lastName", + "address", + "city", + "state", + "zipCode", + "dateOfBirth", + "expirationDate", + "issueDate", + "licenseNumber" + ], + "properties": { + "completeCredentialDomain": { + "type": "string", + "enum": ["payment", "piv_pki", "nfc_access", "mobile"], + "description": "Optional mirror of Complete Credential CredentialRef.domain." + }, + "issuingAuthority": { + "$ref": "credential-authority.schema.json#/$defs/IssuingAuthorityContext" + }, + "documentDiscriminator": { + "type": "string", + "description": "AAMVA document discriminator / instance id." + }, + "firstName": { "type": "string" }, + "middleName": { "type": "string" }, + "lastName": { "type": "string" }, + "address": { "type": "string" }, + "city": { "type": "string" }, + "state": { + "type": "string", + "description": "Subnational issuer code (e.g. US state, CA province) — national conventions apply." + }, + "zipCode": { + "type": "string", + "description": "Postal code in issuing jurisdiction format." + }, + "dateOfBirth": { + "type": "string", + "pattern": "^\\d{8}$", + "description": "YYYYMMDD." + }, + "expirationDate": { + "type": "string", + "pattern": "^\\d{8}$", + "description": "YYYYMMDD." + }, + "issueDate": { + "type": "string", + "pattern": "^\\d{8}$", + "description": "YYYYMMDD." + }, + "licenseNumber": { "type": "string" }, + "restrictions": { "type": "string" }, + "endorsements": { "type": "string" }, + "vehicleClass": { "type": "string" }, + "height": { + "type": "string", + "description": "e.g. FTIN or metric per jurisdiction." + }, + "weight": { + "type": "string", + "description": "e.g. LBS or KG per jurisdiction." + }, + "eyeColor": { "type": "string" }, + "hairColor": { "type": "string" }, + "sex": { + "type": "string", + "enum": ["M", "F", "X"], + "description": "AAMVA allows X for unspecified/non-binary where adopted." + }, + "extensions": { + "type": "object", + "description": "Jurisdiction-specific element codes, REAL ID flags, or non-AAMVA national DL profiles.", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/docs/schemas/credential-payload-agency_badge.schema.json b/docs/schemas/credential-payload-agency_badge.schema.json new file mode 100644 index 0000000..03729de --- /dev/null +++ b/docs/schemas/credential-payload-agency_badge.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smoa.dev/schemas/credential-payload-agency_badge.schema.json", + "title": "Credential sync payload — agency badge", + "description": "Organizational photo ID / badge when format is not ICAO/AAMVA/MIL-STD-129. Often paired with nfc_access issuance.", + "type": "object", + "properties": { + "issuingAuthority": { + "$ref": "credential-authority.schema.json#/$defs/IssuingAuthorityContext" + }, + "displayName": { + "type": "string" + }, + "badgeNumber": { + "type": "string" + }, + "photoRef": { + "type": "string", + "description": "URI or content-addressed ref to portrait; policy-dependent." + }, + "extensions": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/docs/schemas/credential-payload-document.schema.json b/docs/schemas/credential-payload-document.schema.json new file mode 100644 index 0000000..dcb5f63 --- /dev/null +++ b/docs/schemas/credential-payload-document.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smoa.dev/schemas/credential-payload-document.schema.json", + "title": "Credential sync payload — ICAO / AAMVA / MIL-STD-129 (discriminated union)", + "description": "Validates only the `payload` map inside CredentialSyncRequest — not the full request. `credentialType` lives on the request body; pick the matching branch (or validate against one schema after reading request.credentialType). oneOf may not auto-discriminate without extension keywords; prefer per-type validation in pipelines.", + "oneOf": [ + { "$ref": "credential-payload-icao9303_mrtd.schema.json" }, + { "$ref": "credential-payload-aamva_dlid.schema.json" }, + { "$ref": "credential-payload-mil_std_129.schema.json" }, + { "$ref": "credential-payload-piv_pki.schema.json" }, + { "$ref": "credential-payload-payment.schema.json" }, + { "$ref": "credential-payload-nfc_access.schema.json" }, + { "$ref": "credential-payload-mobile.schema.json" }, + { "$ref": "credential-payload-agency_badge.schema.json" } + ] +} diff --git a/docs/schemas/credential-payload-icao9303_mrtd.schema.json b/docs/schemas/credential-payload-icao9303_mrtd.schema.json new file mode 100644 index 0000000..246e14c --- /dev/null +++ b/docs/schemas/credential-payload-icao9303_mrtd.schema.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smoa.dev/schemas/credential-payload-icao9303_mrtd.schema.json", + "title": "Credential sync payload — ICAO 9303 MRTD", + "description": "JSON shape for CredentialSyncRequest.payload only (credentialType is on the sync request body, not duplicated here). Use when request.credentialType is icao9303_mrtd. Aligns with com.smoa.core.barcode.formats.ICAO9303Credential. Dates are MRZ-style YYMMDD.", + "type": "object", + "required": [ + "documentType", + "issuingCountry", + "surname", + "givenNames", + "documentNumber", + "nationality", + "dateOfBirth", + "sex", + "expirationDate" + ], + "properties": { + "completeCredentialDomain": { + "type": "string", + "enum": ["payment", "piv_pki", "nfc_access", "mobile"], + "description": "Optional mirror of Complete Credential CredentialRef.domain." + }, + "issuingAuthority": { + "$ref": "credential-authority.schema.json#/$defs/IssuingAuthorityContext" + }, + "documentType": { + "type": "string", + "minLength": 1, + "maxLength": 2, + "description": "MRZ document type code (e.g. P passport, I ID card, A resident alien — per ICAO 9303)." + }, + "issuingCountry": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "ISO 3166-1 alpha-3 issuing state." + }, + "surname": { "type": "string" }, + "givenNames": { "type": "string" }, + "documentNumber": { "type": "string" }, + "nationality": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "ISO 3166-1 alpha-3 nationality." + }, + "dateOfBirth": { + "type": "string", + "pattern": "^\\d{6}$", + "description": "YYMMDD per ICAO MRZ." + }, + "sex": { + "type": "string", + "enum": ["M", "F", "<"], + "description": "M, F, or < (unspecified) per ICAO." + }, + "expirationDate": { + "type": "string", + "pattern": "^\\d{6}$", + "description": "YYMMDD document expiry." + }, + "personalNumber": { + "type": "string", + "description": "Optional national personal number field when present on MRZ." + }, + "optionalData": { + "type": "string", + "description": "Optional MRZ optional data field." + }, + "extensions": { + "type": "object", + "description": "National variants, visible digital seal refs, or program-specific data; validate with national profiles if needed.", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/docs/schemas/credential-payload-mil_std_129.schema.json b/docs/schemas/credential-payload-mil_std_129.schema.json new file mode 100644 index 0000000..d590d02 --- /dev/null +++ b/docs/schemas/credential-payload-mil_std_129.schema.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smoa.dev/schemas/credential-payload-mil_std_129.schema.json", + "title": "Credential sync payload — MIL-STD-129 style military ID", + "description": "JSON shape for CredentialSyncRequest.payload only (credentialType on the sync request body). Use when request.credentialType is mil_std_129. Aligns with com.smoa.core.barcode.formats.MILSTD129Credential.", + "type": "object", + "required": [ + "serviceCode", + "lastName", + "firstName", + "socialSecurityNumber", + "dateOfBirth", + "expirationDate", + "issueDate", + "cardNumber" + ], + "properties": { + "completeCredentialDomain": { + "type": "string", + "enum": ["payment", "piv_pki", "nfc_access", "mobile"], + "description": "Optional mirror of Complete Credential CredentialRef.domain." + }, + "issuingAuthority": { + "$ref": "credential-authority.schema.json#/$defs/IssuingAuthorityContext", + "description": "Use government.serviceBranch, ministryOrDepartment (defense), and organizationalUnit (command) for multi-country defense organizations." + }, + "serviceCode": { + "type": "string", + "description": "National service branch or component code (army, navy, air force, gendarmerie, etc.)." + }, + "rank": { "type": "string" }, + "lastName": { "type": "string" }, + "firstName": { "type": "string" }, + "middleInitial": { + "type": "string", + "maxLength": 4, + "description": "Single letter or short form used on ID." + }, + "socialSecurityNumber": { + "type": "string", + "description": "National military ID number, service number, or last-4 style surrogate per policy; do not use full PII where prohibited." + }, + "dateOfBirth": { + "type": "string", + "pattern": "^\\d{8}$", + "description": "YYYYMMDD." + }, + "expirationDate": { + "type": "string", + "pattern": "^\\d{8}$", + "description": "YYYYMMDD." + }, + "issueDate": { + "type": "string", + "pattern": "^\\d{8}$", + "description": "YYYYMMDD." + }, + "cardNumber": { + "type": "string", + "description": "Document serial or PAN printed on the card." + }, + "unit": { + "type": "string", + "description": "Unit, ship, squadron, station, or major command text." + }, + "clearanceLevel": { + "type": "string", + "description": "National classification marking as shown or as tokenized for sync (policy-dependent)." + }, + "extensions": { + "type": "object", + "description": "Coalition badge codes, NATO stock number refs, or country-specific pay grade / MOS fields.", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/docs/schemas/credential-payload-mobile.schema.json b/docs/schemas/credential-payload-mobile.schema.json new file mode 100644 index 0000000..bbbe171 --- /dev/null +++ b/docs/schemas/credential-payload-mobile.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smoa.dev/schemas/credential-payload-mobile.schema.json", + "title": "Credential sync payload — mobile / derived", + "description": "Wallet-style or derived mobile credentials. Store wallet-specific references in extensions.", + "type": "object", + "properties": { + "completeCredentialDomain": { + "type": "string", + "const": "mobile" + }, + "issuingAuthority": { + "$ref": "credential-authority.schema.json#/$defs/IssuingAuthorityContext" + }, + "walletCredentialRef": { + "type": "string", + "description": "Opaque reference from mobile issuance / MDM." + }, + "extensions": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/docs/schemas/credential-payload-nfc_access.schema.json b/docs/schemas/credential-payload-nfc_access.schema.json new file mode 100644 index 0000000..0493cfb --- /dev/null +++ b/docs/schemas/credential-payload-nfc_access.schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smoa.dev/schemas/credential-payload-nfc_access.schema.json", + "title": "Credential sync payload — NFC / physical access", + "description": "Badge, PACS, or NFC access credential metadata. Payload often includes facility codes or QR bitmap refs in extensions.", + "type": "object", + "properties": { + "completeCredentialDomain": { + "type": "string", + "const": "nfc_access" + }, + "issuingAuthority": { + "$ref": "credential-authority.schema.json#/$defs/IssuingAuthorityContext" + }, + "facilityCode": { + "type": "string" + }, + "badgeNumber": { + "type": "string" + }, + "extensions": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/docs/schemas/credential-payload-payment.schema.json b/docs/schemas/credential-payload-payment.schema.json new file mode 100644 index 0000000..1b50911 --- /dev/null +++ b/docs/schemas/credential-payload-payment.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smoa.dev/schemas/credential-payload-payment.schema.json", + "title": "Credential sync payload — payment", + "description": "Optional structured fields for credentialType payment (tokens, instruments). No card PANs in clear text; use token references and extensions per PCI/program policy.", + "type": "object", + "properties": { + "completeCredentialDomain": { + "type": "string", + "const": "payment" + }, + "issuingAuthority": { + "$ref": "credential-authority.schema.json#/$defs/IssuingAuthorityContext" + }, + "paymentTokenRef": { + "type": "string", + "description": "Opaque token id from issuer switch / wallet." + }, + "extensions": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/docs/schemas/credential-payload-piv_pki.schema.json b/docs/schemas/credential-payload-piv_pki.schema.json new file mode 100644 index 0000000..d736254 --- /dev/null +++ b/docs/schemas/credential-payload-piv_pki.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://smoa.dev/schemas/credential-payload-piv_pki.schema.json", + "title": "Credential sync payload — PIV / PKI", + "description": "Optional structured fields for credentialType piv_pki (FIPS 201 / smart card). Card APDU and cert details are often device-specific; use extensions for national profiles.", + "type": "object", + "properties": { + "completeCredentialDomain": { + "type": "string", + "const": "piv_pki", + "description": "Should mirror Complete Credential domain when used." + }, + "issuingAuthority": { + "$ref": "credential-authority.schema.json#/$defs/IssuingAuthorityContext" + }, + "cardSerialHint": { + "type": "string", + "description": "Non-sensitive hint only; never full PAN/CHUID in clear if policy forbids." + }, + "extensions": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": false +} diff --git a/docs/security/TENANT-THREAT-MODEL.md b/docs/security/TENANT-THREAT-MODEL.md new file mode 100644 index 0000000..b4ae4f0 --- /dev/null +++ b/docs/security/TENANT-THREAT-MODEL.md @@ -0,0 +1,22 @@ +# Tenant, API key, and unit header — threat model notes + +## What exists today + +- **API key** (`SMOA_API_KEY` / `X-API-Key`): shared-secret gate for `/api/v1/*` when configured. It does **not** identify a tenant or row-level security domain by itself. +- **`smoa.tenant.require-unit`**: when true, requests may require `X-Unit` (or equivalent) so clients must declare a unit; the backend can filter **read** paths that honor the header (see `TenantFilter`). This is **not** cryptographic proof of membership in that unit. +- **Multi-government payloads:** optional `issuingAuthority` in credential `payload_json` documents jurisdiction and org hierarchy for issuance audit; it does not enforce access control unless application logic is added. + +## Gaps (explicit) + +- **No** binding between API key and allowed `unit` / `holderId` / tenant id in the database layer. +- **No** row-level security (RLS) in PostgreSQL; all rows are visible to any authenticated client unless controllers add filters. +- **Compromise of API key** implies compromise of all data the backend stores until the key is rotated. + +## Hardening directions + +1. Issue **per-device or per-tenant** credentials (mTLS, JWT with `tenant_id` / `sub`, or OAuth2 client credentials) instead of a single static API key where feasible. +2. Map principal → **allowed units** in policy service; enforce in every sync/pull handler. +3. Enable **PostgreSQL RLS** or schema-per-tenant for strict isolation. +4. Log and monitor **`X-Request-Id`** and principal for sync audit (already partially covered by `sync_audit_log`). + +See also `docs/reference/GAPS-AND-INCONSISTENCIES.md` and backend `TenantFilter`. diff --git a/docs/status/IMPLEMENTATION_STATUS.md b/docs/status/IMPLEMENTATION_STATUS.md index 48397ea..98f4fae 100644 --- a/docs/status/IMPLEMENTATION_STATUS.md +++ b/docs/status/IMPLEMENTATION_STATUS.md @@ -197,7 +197,7 @@ For detailed compliance information, see: ## Remaining Work -**See [TODO.md](../../TODO.md)** for the full checklist of remaining and optional tasks (backend, Android, iOS, Web, infrastructure, compliance, testing). +**See [TODO.md](../../TODO.md)** (status vs external gates) and **[TASKS.md](../../TASKS.md)** (master task table with file links). ### Next steps (short-term) diff --git a/docs/testing/E2E-PLAN.md b/docs/testing/E2E-PLAN.md new file mode 100644 index 0000000..e984343 --- /dev/null +++ b/docs/testing/E2E-PLAN.md @@ -0,0 +1,11 @@ +# End-to-end test plan (future) + +| Flow | Preconditions | Steps | Pass criteria | +|------|-----------------|-------|----------------| +| Sync directory | Backend up, API key set | Create entry via POST; pull GET; app offline queue flush | Data matches | +| Auth / RBAC | Test users seeded | Login restricted action | 403 without role | +| Meeting join | Stub or WebRTC test peer | Join → audio route | Connection state Connected | + +**Tooling:** Maestro, Appium, or Espresso + MockWebServer for Android; XCUITest for iOS when the native app exists. + +**CI:** Run backend `:backend:test` and Android unit tests on every push; reserve E2E for nightly or pre-release. diff --git a/docs/web-scaffold/DEPLOY.md b/docs/web-scaffold/DEPLOY.md new file mode 100644 index 0000000..3c0f43d --- /dev/null +++ b/docs/web-scaffold/DEPLOY.md @@ -0,0 +1,35 @@ +# Deploying the web scaffold + +## Build + +No bundler required. Serve the folder `docs/web-scaffold/` as static files over **HTTPS**. + +## CORS + +Set the backend for your web origin, e.g.: + +```yaml +# application-prod.yml or env +smoa: + cors: + allowed-origins: "https://smoa.example.com" +``` + +## Nginx example + +```nginx +location /web/ { + alias /var/www/smoa-web-scaffold/; + try_files $uri $uri/ /web/index.html; + add_header Cache-Control "no-cache" always; +} +``` + +## PWA + +- `manifest.webmanifest` – install metadata (add PNG icons as needed). +- `sw.js` – registered from `index.html` for a minimal offline shell. + +## CI + +See [.gitea/workflows/ci.yml](../../.gitea/workflows/ci.yml) for backend tests; add a job to `rsync` this directory to your host if desired. diff --git a/docs/web-scaffold/index.html b/docs/web-scaffold/index.html index 5504653..497908a 100644 --- a/docs/web-scaffold/index.html +++ b/docs/web-scaffold/index.html @@ -3,6 +3,8 @@ + + SMOA Web + + + + + + +HTMLEOF +echo "Wrote $GEN/index.html and copied spec (if present)." diff --git a/scripts/wsl-install-debug.sh b/scripts/wsl-install-debug.sh new file mode 100755 index 0000000..42740d3 --- /dev/null +++ b/scripts/wsl-install-debug.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Run in Ubuntu WSL after: usbipd attach --wsl --busid (Windows Admin PowerShell) +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APK="$ROOT/app/build/outputs/apk/debug/app-debug.apk" +export PATH="${HOME}/Android/Sdk/platform-tools:${PATH}" +export ADB_INSTALL_TIMEOUT="${ADB_INSTALL_TIMEOUT:-3600}" + +echo "=== lsusb (expect Samsung / 04e8) ===" +lsusb || true + +adb kill-server || true +sleep 1 +echo "=== adb devices ===" +adb devices + +WAIT_MAX="${WAIT_FOR_DEVICE_SECS:-180}" +elapsed=0 +while ! adb devices 2>/dev/null | grep -q $'\tdevice$'; do + if adb devices 2>/dev/null | grep -q $'\tunauthorized$'; then + echo "Unauthorized — unlock the phone and tap Allow on the USB debugging (RSA) prompt..." + else + echo "No device — run usbipd attach (Windows), check cable/USB mode..." + fi + if (( elapsed >= WAIT_MAX )); then + echo "Timed out after ${WAIT_MAX}s waiting for device." >&2 + adb devices + exit 1 + fi + sleep 5 + elapsed=$((elapsed + 5)) + # After you tap Allow, reconnecting ADB often flips unauthorized → device. + adb kill-server 2>/dev/null || true + sleep 1 +done +echo "=== adb devices (ready) ===" +adb devices + +cd "$ROOT" +if [[ ! -f "$APK" ]]; then + echo "=== building debug APK ===" + ./gradlew :app:assembleDebug --no-daemon +fi + +echo "=== adb install (large APK over usbip can take many minutes) ===" +# --streaming avoids a separate full-file sync that often EOFs over USB/IP. +if ! adb install -r -d --streaming "$APK"; then + echo "Streaming install failed; retrying without --streaming..." >&2 + adb install -r -d "$APK" +fi +echo "=== verify ===" +adb shell pm path com.smoa +echo "Done." diff --git a/settings.gradle.kts b/settings.gradle.kts index 170d369..e1eb4eb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,15 @@ pluginManagement { mavenCentral() gradlePluginPortal() } + // Versions here avoid "plugin already on classpath with unknown version" when :backend + // is included alongside buildSrc (Kotlin DSL applies Kotlin plugin without a resolved version). + plugins { + id("org.jetbrains.kotlin.jvm") version "1.9.20" + id("org.jetbrains.kotlin.plugin.spring") version "1.9.20" + id("org.jetbrains.kotlin.plugin.jpa") version "1.9.20" + id("org.springframework.boot") version "3.2.2" + id("io.spring.dependency-management") version "1.1.4" + } } dependencyResolutionManagement {