Fix first time login authorization header overwrite bug (#520)

This commit is contained in:
Leendert de Borst
2025-10-17 14:39:19 +02:00
parent 37acd87c44
commit ddf34a2d30
10 changed files with 906 additions and 150 deletions

View File

@@ -16,6 +16,11 @@ import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReadableType
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.turbomodule.core.interfaces.TurboModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.aliasvault.app.vaultstore.VaultStore
import net.aliasvault.app.vaultstore.keystoreprovider.AndroidKeystoreProvider
import net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider
@@ -23,11 +28,6 @@ import net.aliasvault.app.webapi.WebApiService
import net.aliasvault.nativevaultmanager.NativeVaultManagerSpec
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
/**
* The native vault manager that manages the vault store and all input/output operations on it.
@@ -986,7 +986,7 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
body: String?,
headers: String,
requiresAuth: Boolean,
promise: Promise
promise: Promise,
) {
try {
// TODO: Implement when WebApiService is complete
@@ -1004,7 +1004,7 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
endpoint = endpoint,
body = body,
headers = headersMap,
requiresAuth = requiresAuth
requiresAuth = requiresAuth,
)
// Build response JSON
@@ -1042,8 +1042,7 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
@ReactMethod
override fun setUsername(username: String, promise: Promise) {
try {
// TODO: Implement username storage in VaultStore (similar to iOS)
// vaultStore.setUsername(username)
vaultStore.setUsername(username)
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error setting username", e)
@@ -1058,10 +1057,8 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
@ReactMethod
override fun getUsername(promise: Promise) {
try {
// TODO: Implement username retrieval from VaultStore (similar to iOS)
// val username = vaultStore.getUsername()
// promise.resolve(username)
promise.resolve(null)
val username = vaultStore.getUsername()
promise.resolve(username)
} catch (e: Exception) {
Log.e(TAG, "Error getting username", e)
promise.reject("ERR_GET_USERNAME", "Failed to get username: ${e.message}", e)
@@ -1075,8 +1072,7 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
@ReactMethod
override fun clearUsername(promise: Promise) {
try {
// TODO: Implement username clearing in VaultStore (similar to iOS)
// vaultStore.clearUsername()
vaultStore.clearUsername()
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error clearing username", e)
@@ -1094,8 +1090,7 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
@ReactMethod
override fun setOfflineMode(isOffline: Boolean, promise: Promise) {
try {
// TODO: Implement offline mode storage in VaultStore (similar to iOS)
// vaultStore.setOfflineMode(isOffline)
vaultStore.setOfflineMode(isOffline)
promise.resolve(null)
} catch (e: Exception) {
Log.e(TAG, "Error setting offline mode", e)
@@ -1110,10 +1105,8 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
@ReactMethod
override fun getOfflineMode(promise: Promise) {
try {
// TODO: Implement offline mode retrieval from VaultStore (similar to iOS)
// val isOffline = vaultStore.getOfflineMode()
// promise.resolve(isOffline)
promise.resolve(false)
val isOffline = vaultStore.getOfflineMode()
promise.resolve(isOffline)
} catch (e: Exception) {
Log.e(TAG, "Error getting offline mode", e)
promise.reject("ERR_GET_OFFLINE_MODE", "Failed to get offline mode: ${e.message}", e)
@@ -1123,30 +1116,52 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
// MARK: - Vault Sync and Mutate
/**
* Sync vault with server
* Returns true if new vault was downloaded
* @param promise The promise to resolve
* Check if a new vault version is available on the server
* @param promise The promise to resolve with object containing isNewVersionAvailable and newRevision
*/
@ReactMethod
override fun syncVault(promise: Promise) {
override fun isNewVaultVersionAvailable(promise: Promise) {
CoroutineScope(Dispatchers.IO).launch {
try {
// TODO: Implement vault sync in VaultStore (similar to iOS)
// This should:
// 1. Call WebApiService to get Auth/status
// 2. Compare vault revisions
// 3. Download vault if server has newer version
// 4. Store encrypted vault and update revision
// 5. Return true if new vault downloaded, false otherwise
//
// val hasNewVault = vaultStore.syncVault(webApiService)
val result = vaultStore.isNewVaultVersionAvailable(webApiService)
val resultMap = Arguments.createMap()
resultMap.putBoolean("isNewVersionAvailable", result["isNewVersionAvailable"] as Boolean)
val newRevision = result["newRevision"] as? Int
if (newRevision != null) {
resultMap.putInt("newRevision", newRevision)
} else {
resultMap.putNull("newRevision")
}
withContext(Dispatchers.Main) {
promise.resolve(false)
promise.resolve(resultMap)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Log.e(TAG, "Error syncing vault", e)
promise.reject("ERR_SYNC_VAULT", "Failed to sync vault: ${e.message}", e)
Log.e(TAG, "Error checking vault version", e)
promise.reject("ERR_CHECK_VAULT_VERSION", "Failed to check vault version: ${e.message}", e)
}
}
}
}
/**
* Download and store the vault from the server
* @param newRevision The new revision number to download
* @param promise The promise to resolve
*/
@ReactMethod
override fun downloadVault(newRevision: Double, promise: Promise) {
CoroutineScope(Dispatchers.IO).launch {
try {
val success = vaultStore.downloadVault(webApiService, newRevision.toInt())
withContext(Dispatchers.Main) {
promise.resolve(success)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Log.e(TAG, "Error downloading vault", e)
promise.reject("ERR_DOWNLOAD_VAULT", "Failed to download vault: ${e.message}", e)
}
}
}
@@ -1160,16 +1175,9 @@ class NativeVaultManager(reactContext: ReactApplicationContext) :
override fun mutateVault(promise: Promise) {
CoroutineScope(Dispatchers.IO).launch {
try {
// TODO: Implement vault mutate in VaultStore (similar to iOS)
// This should:
// 1. Prepare vault for upload (assemble metadata)
// 2. POST vault to server
// 3. Update local revision number
// 4. Clear offline mode on success
//
// vaultStore.mutateVault(webApiService)
val success = vaultStore.mutateVault(webApiService)
withContext(Dispatchers.Main) {
promise.resolve(null)
promise.resolve(success)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {

View File

@@ -1026,4 +1026,486 @@ class VaultStore(
val invisible = "[\\uFEFF\\u200B\\u00A0\\u202A-\\u202E\\u2060\\u180E]"
return this.replace(Regex("^($invisible)+|($invisible)+$"), "").trim()
}
// MARK: - Username Management
/**
* Set the username.
* @param username The username to store
*/
fun setUsername(username: String) {
storageProvider.setUsername(username)
}
/**
* Get the username.
* @return The username or null if not set
*/
fun getUsername(): String? {
return storageProvider.getUsername()
}
/**
* Clear the username.
*/
fun clearUsername() {
storageProvider.clearUsername()
}
// MARK: - Offline Mode Management
/**
* Set offline mode flag.
* @param isOffline Whether the app is in offline mode
*/
fun setOfflineMode(isOffline: Boolean) {
storageProvider.setOfflineMode(isOffline)
}
/**
* Get offline mode flag.
* @return True if app is in offline mode, false otherwise
*/
fun getOfflineMode(): Boolean {
return storageProvider.getOfflineMode()
}
// MARK: - Vault Sync Methods
/**
* Check if a new vault version is available on the server.
* Returns a map with isNewVersionAvailable and newRevision keys.
*
* @param webApiService The WebApiService to use for the request
* @return Map with "isNewVersionAvailable" (Boolean) and "newRevision" (Int?) keys
*/
suspend fun isNewVaultVersionAvailable(webApiService: net.aliasvault.app.webapi.WebApiService): Map<String, Any?> {
val status = fetchAndValidateStatus(webApiService)
setOfflineMode(false)
val currentRevision = getVaultRevisionNumber()
return if (status.vaultRevision > currentRevision) {
mapOf(
"isNewVersionAvailable" to true,
"newRevision" to status.vaultRevision,
)
} else {
mapOf(
"isNewVersionAvailable" to false,
"newRevision" to null,
)
}
}
/**
* Download and store the vault from the server.
* This method assumes a version check has already been performed.
*
* @param webApiService The WebApiService to use for the request
* @param newRevision The new revision number to download
* @return True if successful
*/
suspend fun downloadVault(webApiService: net.aliasvault.app.webapi.WebApiService, newRevision: Int): Boolean {
try {
downloadAndStoreVault(webApiService, newRevision)
setOfflineMode(false)
return true
} catch (e: Exception) {
Log.e(TAG, "Error downloading vault", e)
throw e
}
}
/**
* Fetch and validate server status.
*/
private suspend fun fetchAndValidateStatus(webApiService: net.aliasvault.app.webapi.WebApiService): StatusResponse {
val statusResponse = try {
webApiService.executeRequest(
method = "GET",
endpoint = "Auth/status",
body = null,
headers = emptyMap(),
requiresAuth = true,
)
} catch (e: Exception) {
throw Exception("Network error: ${e.message}", e)
}
// Check response status
if (statusResponse.statusCode != 200) {
if (statusResponse.statusCode == 401) {
throw Exception("Session expired")
}
setOfflineMode(true)
throw Exception("Server unavailable: ${statusResponse.statusCode}")
}
val status = try {
val json = org.json.JSONObject(statusResponse.body)
StatusResponse(
clientVersionSupported = json.getBoolean("clientVersionSupported"),
serverVersion = json.getString("serverVersion"),
vaultRevision = json.getInt("vaultRevision"),
srpSalt = json.getString("srpSalt"),
)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode status response", e)
throw Exception("Failed to decode status response: ${e.message}")
}
if (!status.clientVersionSupported) {
throw Exception("Client version not supported")
}
validateSrpSalt(status.srpSalt)
return status
}
/**
* Validate SRP salt hasn't changed (password change detection).
*/
private fun validateSrpSalt(srpSalt: String) {
val keyDerivationParams = storageProvider.getKeyDerivationParams()
if (keyDerivationParams.isEmpty()) {
return
}
try {
val json = org.json.JSONObject(keyDerivationParams)
val salt = json.optString("salt", "")
if (srpSalt.isNotEmpty() && srpSalt != salt) {
throw Exception("Password changed")
}
} catch (e: Exception) {
if (e.message == "Password changed") throw e
// Ignore parsing errors
}
}
/**
* Download vault from server and store it locally.
*/
private suspend fun downloadAndStoreVault(webApiService: net.aliasvault.app.webapi.WebApiService, newRevision: Int) {
val vaultResponse = try {
webApiService.executeRequest(
method = "GET",
endpoint = "Vault",
body = null,
headers = emptyMap(),
requiresAuth = true,
)
} catch (e: Exception) {
throw Exception("Network error: ${e.message}", e)
}
if (vaultResponse.statusCode != 200) {
if (vaultResponse.statusCode == 401) {
throw Exception("Session expired")
}
throw Exception("Server unavailable: ${vaultResponse.statusCode}")
}
val vault = parseVaultResponse(vaultResponse.body)
validateVaultStatus(vault.status)
storeEncryptedDatabase(vault.vault.blob)
setVaultRevisionNumber(newRevision)
if (isVaultUnlocked()) {
unlockVault()
}
}
/**
* Parse vault response from JSON.
*/
private fun parseVaultResponse(body: String): VaultResponse {
return try {
val json = org.json.JSONObject(body)
val vaultJson = json.getJSONObject("vault")
val emailList = mutableListOf<String>()
val emailArray = vaultJson.getJSONArray("emailAddressList")
for (i in 0 until emailArray.length()) {
emailList.add(emailArray.getString(i))
}
val privateList = mutableListOf<String>()
val privateArray = vaultJson.getJSONArray("privateEmailDomainList")
for (i in 0 until privateArray.length()) {
privateList.add(privateArray.getString(i))
}
val publicList = mutableListOf<String>()
val publicArray = vaultJson.getJSONArray("publicEmailDomainList")
for (i in 0 until publicArray.length()) {
publicList.add(publicArray.getString(i))
}
VaultResponse(
status = json.getInt("status"),
vault = VaultData(
username = vaultJson.getString("username"),
blob = vaultJson.getString("blob"),
version = vaultJson.getString("version"),
currentRevisionNumber = vaultJson.getInt("currentRevisionNumber"),
encryptionPublicKey = vaultJson.getString("encryptionPublicKey"),
credentialsCount = vaultJson.getInt("credentialsCount"),
emailAddressList = emailList,
privateEmailDomainList = privateList,
publicEmailDomainList = publicList,
createdAt = vaultJson.getString("createdAt"),
updatedAt = vaultJson.getString("updatedAt"),
),
)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode vault response", e)
throw Exception("Failed to decode vault response: ${e.message}")
}
}
/**
* Validate vault response status.
*/
private fun validateVaultStatus(status: Int) {
when (status) {
0 -> return
1 -> throw Exception("Vault merge required")
2 -> throw Exception("Vault outdated")
else -> throw Exception("Unknown vault status: $status")
}
}
// MARK: - Vault Mutate Methods
/**
* Prepare the vault for upload by assembling all metadata.
* Returns a VaultUpload object ready to be sent to the server.
*/
private fun prepareVault(): VaultUpload {
val currentRevision = getVaultRevisionNumber()
val encryptedDb = getEncryptedDatabase()
val username = getUsername()
?: throw Exception("Username not found")
if (!isVaultUnlocked()) {
throw Exception("Vault must be unlocked to prepare for upload")
}
// Get all credentials
val credentials = getAllCredentials()
// Get private email domains from metadata
val metadata = getVaultMetadataObject()
val privateEmailDomains = metadata?.privateEmailDomains ?: emptyList()
// Extract private email addresses from credentials
val privateEmailAddresses = credentials
.mapNotNull { it.alias?.email }
.filter { email ->
privateEmailDomains.any { domain ->
email.lowercase().endsWith("@${domain.lowercase()}")
}
}
.distinct()
// Get database version
val dbVersion = getDatabaseVersion()
// Get app version
val version = try {
val context = storageProvider as? net.aliasvault.app.vaultstore.storageprovider.AndroidStorageProvider
val pm = context?.javaClass?.getDeclaredField("context")?.get(context) as? android.content.Context
pm?.packageManager?.getPackageInfo(pm.packageName, 0)?.versionName ?: "0.0.0"
} catch (e: Exception) {
"0.0.0"
}
val baseVersion = version.split("-").firstOrNull() ?: "0.0.0"
val client = "android-$baseVersion"
// Format dates in ISO 8601 format
val dateFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US)
dateFormat.timeZone = java.util.TimeZone.getTimeZone("UTC")
val now = dateFormat.format(java.util.Date())
return VaultUpload(
blob = encryptedDb,
createdAt = now,
credentialsCount = credentials.size,
currentRevisionNumber = currentRevision,
emailAddressList = privateEmailAddresses,
privateEmailDomainList = emptyList(), // Empty on purpose
publicEmailDomainList = emptyList(), // Empty on purpose
encryptionPublicKey = "", // Empty on purpose
updatedAt = now,
username = username,
version = dbVersion,
client = client,
)
}
/**
* Execute a vault mutation operation.
* This method uploads the vault to the server and updates the local revision number.
*
* @param webApiService The WebApiService to use for the request
* @return True if successful
*/
suspend fun mutateVault(webApiService: net.aliasvault.app.webapi.WebApiService): Boolean {
try {
// Prepare vault for upload
val vault = prepareVault()
// Convert to JSON
val json = org.json.JSONObject()
json.put("blob", vault.blob)
json.put("createdAt", vault.createdAt)
json.put("credentialsCount", vault.credentialsCount)
json.put("currentRevisionNumber", vault.currentRevisionNumber)
json.put("emailAddressList", org.json.JSONArray(vault.emailAddressList))
json.put("privateEmailDomainList", org.json.JSONArray(vault.privateEmailDomainList))
json.put("publicEmailDomainList", org.json.JSONArray(vault.publicEmailDomainList))
json.put("encryptionPublicKey", vault.encryptionPublicKey)
json.put("updatedAt", vault.updatedAt)
json.put("username", vault.username)
json.put("version", vault.version)
json.put("client", vault.client)
// Upload to server
val response = webApiService.executeRequest(
method = "POST",
endpoint = "Vault",
body = json.toString(),
headers = mapOf("Content-Type" to "application/json"),
requiresAuth = true,
)
if (response.statusCode != 200) {
Log.e(TAG, "Server rejected vault upload with status ${response.statusCode}")
throw Exception("Server returned error: ${response.statusCode}")
}
// Parse response
val vaultResponse = try {
val responseJson = org.json.JSONObject(response.body)
VaultPostResponse(
status = responseJson.getInt("status"),
newRevisionNumber = responseJson.getInt("newRevisionNumber"),
)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse vault upload response", e)
throw Exception("Failed to parse vault upload response: ${e.message}")
}
// Check vault response status
when (vaultResponse.status) {
0 -> {
// Success - update local revision number
setVaultRevisionNumber(vaultResponse.newRevisionNumber)
setOfflineMode(false)
return true
}
1 -> throw Exception("Vault merge required")
2 -> throw Exception("Vault is outdated, please sync first")
else -> throw Exception("Failed to upload vault")
}
} catch (e: Exception) {
Log.e(TAG, "Error mutating vault", e)
throw e
}
}
/**
* Get the database version from the __EFMigrationsHistory table.
*/
private fun getDatabaseVersion(): String {
val query = "SELECT MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId DESC LIMIT 1"
val results = executeQuery(query, emptyArray())
if (results.isEmpty()) {
Log.d(TAG, "No migrations found in database, returning default version")
return "0.0.0"
}
val migrationId = results[0]["MigrationId"] as? String
if (migrationId == null) {
return "0.0.0"
}
// Extract version using regex - matches patterns like "_1.4.1-"
val versionRegex = Regex("_(\\d+\\.\\d+\\.\\d+)-")
val match = versionRegex.find(migrationId)
return if (match != null && match.groupValues.size > 1) {
match.groupValues[1]
} else {
Log.d(TAG, "Could not extract version from migration ID '$migrationId', returning default")
"0.0.0"
}
}
// MARK: - Data Models for Sync/Mutate
/**
* Status response from Auth/status endpoint.
*/
private data class StatusResponse(
val clientVersionSupported: Boolean,
val serverVersion: String,
val vaultRevision: Int,
val srpSalt: String,
)
/**
* Vault data from API.
*/
private data class VaultData(
val username: String,
val blob: String,
val version: String,
val currentRevisionNumber: Int,
val encryptionPublicKey: String,
val credentialsCount: Int,
val emailAddressList: List<String>,
val privateEmailDomainList: List<String>,
val publicEmailDomainList: List<String>,
val createdAt: String,
val updatedAt: String,
)
/**
* Vault response from Vault GET endpoint.
*/
private data class VaultResponse(
val status: Int,
val vault: VaultData,
)
/**
* Vault upload model that matches the API contract.
*/
private data class VaultUpload(
val blob: String,
val createdAt: String,
val credentialsCount: Int,
val currentRevisionNumber: Int,
val emailAddressList: List<String>,
val privateEmailDomainList: List<String>,
val publicEmailDomainList: List<String>,
val encryptionPublicKey: String,
val updatedAt: String,
val username: String,
val version: String,
val client: String,
)
/**
* Vault POST response from API.
*/
private data class VaultPostResponse(
val status: Int,
val newRevisionNumber: Int,
)
}

View File

@@ -1,9 +1,6 @@
package net.aliasvault.app.vaultstore.passkey
import android.util.Base64
import org.json.JSONObject
import java.nio.ByteBuffer
import java.security.KeyPair
import java.security.KeyPairGenerator
import java.security.MessageDigest
import java.security.SecureRandom
@@ -42,7 +39,7 @@ object PasskeyAuthenticator {
/** AliasVault AAGUID: a11a5vau-9f32-4b8c-8c5d-2f7d13e8c942 */
private val AAGUID = byteArrayOf(
0xa1.toByte(), 0x1a, 0x5f, 0xaa.toByte(), 0x9f.toByte(), 0x32, 0x4b, 0x8c.toByte(),
0x8c.toByte(), 0x5d, 0x2f, 0x7d, 0x13, 0xe8.toByte(), 0xc9.toByte(), 0x42
0x8c.toByte(), 0x5d, 0x2f, 0x7d, 0x13, 0xe8.toByte(), 0xc9.toByte(), 0x42,
)
// MARK: - Public API
@@ -62,7 +59,7 @@ object PasskeyAuthenticator {
userDisplayName: String?,
uvPerformed: Boolean = false,
enablePrf: Boolean = false,
prfInputs: PrfInputs? = null
prfInputs: PrfInputs? = null,
): PasskeyCreationResult {
// 1. Generate ES256 key pair
val keyPairGenerator = KeyPairGenerator.getInstance("EC")
@@ -90,7 +87,7 @@ object PasskeyAuthenticator {
// 6. Build attested credential data
val credIdLength = byteArrayOf(
((credentialId.size shr 8) and 0xFF).toByte(),
(credentialId.size and 0xFF).toByte()
(credentialId.size and 0xFF).toByte(),
)
val attestedCredData = AAGUID + credIdLength + credentialId + coseKey
@@ -130,7 +127,7 @@ object PasskeyAuthenticator {
userName = userName,
userDisplayName = userDisplayName,
prfSecret = prfSecret,
prfResults = prfResults
prfResults = prfResults,
)
}
@@ -148,7 +145,7 @@ object PasskeyAuthenticator {
userId: ByteArray?,
uvPerformed: Boolean = false,
prfInputs: PrfInputs? = null,
prfSecret: ByteArray? = null
prfSecret: ByteArray? = null,
): PasskeyAssertionResult {
// 1. RP ID hash
val md = MessageDigest.getInstance("SHA-256")
@@ -195,7 +192,7 @@ object PasskeyAuthenticator {
authenticatorData = authenticatorData,
signature = derSignature,
userHandle = userId,
prfResults = prfResults
prfResults = prfResults,
)
}
@@ -254,13 +251,13 @@ object PasskeyAuthenticator {
val yBytes = w.affineY.toByteArray().dropLeadingZeros().padTo32Bytes()
return byteArrayOf(
0xA5.toByte(), // map(5)
0x01, 0x02, // 1: 2 (kty: EC2)
0x03, 0x26, // 3: -7 (alg: ES256)
0x20, 0x01, // -1: 1 (crv: P-256)
0x21, 0x58, 0x20 // -2: bytes(32) for x
0xA5.toByte(), // map(5)
0x01, 0x02, // 1: 2 (kty: EC2)
0x03, 0x26, // 3: -7 (alg: ES256)
0x20, 0x01, // -1: 1 (crv: P-256)
0x21, 0x58, 0x20, // -2: bytes(32) for x
) + xBytes + byteArrayOf(
0x22, 0x58, 0x20 // -3: bytes(32) for y
0x22, 0x58, 0x20, // -3: bytes(32) for y
) + yBytes
}
@@ -270,12 +267,12 @@ object PasskeyAuthenticator {
*/
private fun buildAttestationObjectNone(authenticatorData: ByteArray): ByteArray {
return byteArrayOf(
0xA3.toByte() // map(3)
0xA3.toByte(), // map(3)
) +
cborText("fmt") +
cborText("none") +
cborText("attStmt") +
byteArrayOf(0xA0.toByte()) + // map(0) - empty attStmt
byteArrayOf(0xA0.toByte()) + // map(0) - empty attStmt
cborText("authData") +
cborBytes(authenticatorData)
}
@@ -291,7 +288,7 @@ object PasskeyAuthenticator {
else -> byteArrayOf(
0x79,
((bytes.size shr 8) and 0xFF).toByte(),
(bytes.size and 0xFF).toByte()
(bytes.size and 0xFF).toByte(),
) + bytes
}
}
@@ -306,7 +303,7 @@ object PasskeyAuthenticator {
else -> byteArrayOf(
0x59,
((bytes.size shr 8) and 0xFF).toByte(),
(bytes.size and 0xFF).toByte()
(bytes.size and 0xFF).toByte(),
) + bytes
}
}
@@ -354,14 +351,14 @@ object PasskeyAuthenticator {
data class PasskeyCreationResult(
val credentialId: ByteArray,
val attestationObject: ByteArray,
val publicKey: ByteArray, // JWK format
val publicKey: ByteArray, // JWK format
val privateKey: ByteArray, // JWK format
val rpId: String,
val userId: ByteArray?,
val userName: String?,
val userDisplayName: String?,
val prfSecret: ByteArray?,
val prfResults: PrfResults?
val prfResults: PrfResults?,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -409,7 +406,7 @@ object PasskeyAuthenticator {
val authenticatorData: ByteArray,
val signature: ByteArray,
val userHandle: ByteArray?,
val prfResults: PrfResults?
val prfResults: PrfResults?,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -441,7 +438,7 @@ object PasskeyAuthenticator {
data class PrfInputs(
val first: ByteArray?,
val second: ByteArray?
val second: ByteArray?,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -470,7 +467,7 @@ object PasskeyAuthenticator {
data class PrfResults(
val first: ByteArray,
val second: ByteArray?
val second: ByteArray?,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true

View File

@@ -1,9 +1,5 @@
package net.aliasvault.app.vaultstore.passkey
import android.util.Base64
import java.security.SecureRandom
import java.util.UUID
/**
* PasskeyHelper
* -------------------------
@@ -60,4 +56,16 @@ object PasskeyHelper {
}.uppercase()
}
/**
* Convert byte array to Base64URL encoding
* Base64URL uses - instead of + and _ instead of /, and omits padding
*/
@JvmStatic
fun bytesToBase64url(bytes: ByteArray): String {
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
return base64
.replace('+', '-')
.replace('/', '_')
.trimEnd('=')
}
}

View File

@@ -78,4 +78,35 @@ class AndroidStorageProvider(private val context: Context) : StorageProvider {
encryptedDatabaseFile.delete()
}
}
override fun setUsername(username: String) {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
sharedPreferences.edit {
putString("username", username)
}
}
override fun getUsername(): String? {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
return sharedPreferences.getString("username", null)
}
override fun clearUsername() {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
sharedPreferences.edit {
remove("username")
}
}
override fun setOfflineMode(isOffline: Boolean) {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
sharedPreferences.edit {
putBoolean("offline_mode", isOffline)
}
}
override fun getOfflineMode(): Boolean {
val sharedPreferences = context.getSharedPreferences("aliasvault", Context.MODE_PRIVATE)
return sharedPreferences.getBoolean("offline_mode", false)
}
}

View File

@@ -71,4 +71,33 @@ interface StorageProvider {
* Clear all data from the storage provider.
*/
fun clearStorage()
/**
* Set the username.
* @param username The username to store
*/
fun setUsername(username: String)
/**
* Get the username.
* @return The username or null if not set
*/
fun getUsername(): String?
/**
* Clear the username.
*/
fun clearUsername()
/**
* Set offline mode flag.
* @param isOffline Whether the app is in offline mode
*/
fun setOfflineMode(isOffline: Boolean)
/**
* Get offline mode flag.
* @return True if app is in offline mode, false otherwise
*/
fun getOfflineMode(): Boolean
}

View File

@@ -13,6 +13,8 @@ class TestStorageProvider : StorageProvider {
private var tempKeyDerivationParams = String()
private var tempAuthMethods = "[]"
private var tempAutoLockTimeout = defaultAutoLockTimeout
private var username: String? = null
private var offlineMode: Boolean = false
override fun getEncryptedDatabaseFile(): File = tempFile
@@ -59,4 +61,24 @@ class TestStorageProvider : StorageProvider {
tempAuthMethods = "[]"
tempAutoLockTimeout = defaultAutoLockTimeout
}
override fun setUsername(username: String) {
this.username = username
}
override fun getUsername(): String? {
return username
}
override fun clearUsername() {
username = null
}
override fun setOfflineMode(isOffline: Boolean) {
offlineMode = isOffline
}
override fun getOfflineMode(): Boolean {
return offlineMode
}
}

View File

@@ -1,7 +1,16 @@
package net.aliasvault.app.webapi
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
/**
* Response object from a WebAPI request containing status code, body, and headers
@@ -9,14 +18,12 @@ import android.util.Log
data class WebApiResponse(
val statusCode: Int,
val body: String,
val headers: Map<String, String>
val headers: Map<String, String>,
)
/**
* Native Kotlin WebAPI service for making HTTP requests to the AliasVault server.
* This service handles authentication, token refresh, and all HTTP operations.
*
* TODO: Implement all methods following the iOS Swift implementation pattern.
*/
class WebApiService(private val context: Context) {
companion object {
@@ -25,26 +32,25 @@ class WebApiService(private val context: Context) {
private const val ACCESS_TOKEN_KEY = "accessToken"
private const val REFRESH_TOKEN_KEY = "refreshToken"
private const val DEFAULT_API_URL = "https://app.aliasvault.net/api"
private const val SHARED_PREFS_NAME = "aliasvault"
}
private val sharedPreferences = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
// MARK: - Configuration Management
/**
* Set the API URL
*
* TODO: Implement using SharedPreferences to store the API URL
*/
fun setApiUrl(url: String) {
TODO("Implement setApiUrl: Store URL in SharedPreferences")
sharedPreferences.edit().putString(API_URL_KEY, url).apply()
}
/**
* Get the API URL
*
* TODO: Implement using SharedPreferences to retrieve the API URL
*/
fun getApiUrl(): String {
TODO("Implement getApiUrl: Retrieve URL from SharedPreferences or return DEFAULT_API_URL")
return sharedPreferences.getString(API_URL_KEY, DEFAULT_API_URL) ?: DEFAULT_API_URL
}
/**
@@ -60,128 +66,295 @@ class WebApiService(private val context: Context) {
/**
* Set both access and refresh tokens
*
* TODO: Implement using SharedPreferences to store both tokens
*/
fun setAuthTokens(accessToken: String, refreshToken: String) {
TODO("Implement setAuthTokens: Store both tokens in SharedPreferences")
sharedPreferences.edit()
.putString(ACCESS_TOKEN_KEY, accessToken)
.putString(REFRESH_TOKEN_KEY, refreshToken)
.commit() // Use commit() instead of apply() to ensure synchronous write
}
/**
* Get the access token
*
* TODO: Implement using SharedPreferences to retrieve the access token
*/
fun getAccessToken(): String? {
TODO("Implement getAccessToken: Retrieve access token from SharedPreferences")
return sharedPreferences.getString(ACCESS_TOKEN_KEY, null)
}
/**
* Get the refresh token
*
* TODO: Implement using SharedPreferences to retrieve the refresh token
*/
fun getRefreshToken(): String? {
TODO("Implement getRefreshToken: Retrieve refresh token from SharedPreferences")
private fun getRefreshToken(): String? {
return sharedPreferences.getString(REFRESH_TOKEN_KEY, null)
}
/**
* Clear both access and refresh tokens
*
* TODO: Implement using SharedPreferences to remove both tokens
*/
fun clearAuthTokens() {
TODO("Implement clearAuthTokens: Remove both tokens from SharedPreferences")
sharedPreferences.edit()
.remove(ACCESS_TOKEN_KEY)
.remove(REFRESH_TOKEN_KEY)
.apply()
}
// MARK: - HTTP Request Execution
/**
* Execute a WebAPI request with support for authentication and token refresh
*
* TODO: Implement using OkHttp or HttpURLConnection to execute HTTP requests.
* This should:
* 1. Add Authorization header if requiresAuth is true
* 2. Add X-AliasVault-Client header with app version
* 3. Execute the request
* 4. Handle 401 responses by calling refreshAccessToken() and retrying
* 5. Return WebApiResponse with statusCode, body, and headers
*
* Reference the iOS Swift implementation in VaultStoreKit/WebApiService.swift
*/
suspend fun executeRequest(
method: String,
endpoint: String,
body: String?,
headers: Map<String, String>,
requiresAuth: Boolean
): WebApiResponse {
TODO("Implement executeRequest: Use OkHttp/HttpURLConnection to execute HTTP request with auth support")
requiresAuth: Boolean,
): WebApiResponse = withContext(Dispatchers.IO) {
val requestHeaders = headers.toMutableMap()
// Add authorization header if authentication is required AND not already provided
if (requiresAuth && !requestHeaders.containsKey("Authorization")) {
getAccessToken()?.let { accessToken ->
requestHeaders["Authorization"] = "Bearer $accessToken"
Log.d(TAG, "Added Authorization header from stored token")
}
} else if (requiresAuth && requestHeaders.containsKey("Authorization")) {
Log.d(TAG, "Using provided Authorization header instead of stored token")
}
// Add client version header
requestHeaders["X-AliasVault-Client"] = getClientVersionHeader()
// Execute the request
val response = executeRawRequest(
method = method,
endpoint = endpoint,
body = body,
headers = requestHeaders,
)
// Handle 401 Unauthorized - attempt token refresh
if (response.statusCode == 401 && requiresAuth) {
Log.d(TAG, "Received 401, attempting token refresh")
val newToken = refreshAccessToken()
if (newToken != null) {
// Retry the request with the new token
val retryHeaders = headers.toMutableMap()
retryHeaders["Authorization"] = "Bearer $newToken"
retryHeaders["X-AliasVault-Client"] = getClientVersionHeader()
val retryResponse = executeRawRequest(
method = method,
endpoint = endpoint,
body = body,
headers = retryHeaders,
)
return@withContext retryResponse
} else {
Log.w(TAG, "Token refresh failed, returning 401")
// Token refresh failed, return 401 response
return@withContext response
}
}
response
}
/**
* Execute a raw HTTP request without token refresh logic
*
* TODO: Implement the actual HTTP request execution using OkHttp or HttpURLConnection.
* This should:
* 1. Build the full URL from baseUrl + endpoint
* 2. Create request with method, headers, and body
* 3. Execute synchronously or with coroutines
* 4. Parse response and return WebApiResponse
*
* Reference the iOS Swift implementation for the expected behavior.
*/
private suspend fun executeRawRequest(
method: String,
endpoint: String,
body: String?,
headers: Map<String, String>
): WebApiResponse {
TODO("Implement executeRawRequest: Execute HTTP request and return WebApiResponse")
headers: Map<String, String>,
): WebApiResponse = withContext(Dispatchers.IO) {
val baseUrl = getBaseUrl()
val urlString = "$baseUrl$endpoint"
Log.d(TAG, "Executing $method request to $urlString")
var connection: HttpURLConnection? = null
try {
val url = URL(urlString)
connection = url.openConnection() as HttpURLConnection
connection.requestMethod = method.uppercase()
connection.connectTimeout = 30000 // 30 seconds
connection.readTimeout = 30000 // 30 seconds
connection.doInput = true
// Set headers
for ((key, value) in headers) {
connection.setRequestProperty(key, value)
}
// Set body if present
if (body != null && (method.uppercase() == "POST" || method.uppercase() == "PUT" || method.uppercase() == "PATCH")) {
connection.doOutput = true
OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(body)
writer.flush()
}
}
// Get response code
val statusCode = connection.responseCode
// Read response body
val responseBody = try {
if (statusCode in 200..299) {
BufferedReader(InputStreamReader(connection.inputStream)).use { reader ->
reader.readText()
}
} else {
BufferedReader(InputStreamReader(connection.errorStream ?: connection.inputStream)).use { reader ->
reader.readText()
}
}
} catch (e: Exception) {
Log.e(TAG, "Error reading response body", e)
""
}
// Extract headers
val responseHeaders = mutableMapOf<String, String>()
for ((key, values) in connection.headerFields) {
if (key != null && values.isNotEmpty()) {
responseHeaders[key] = values[0]
}
}
Log.d(TAG, "Response status: $statusCode")
WebApiResponse(
statusCode = statusCode,
body = responseBody,
headers = responseHeaders,
)
} catch (e: Exception) {
Log.e(TAG, "Error executing request", e)
throw e
} finally {
connection?.disconnect()
}
}
/**
* Refresh the access token using the refresh token
*
* TODO: Implement token refresh logic:
* 1. Get current access and refresh tokens
* 2. Create JSON body with both tokens
* 3. POST to Auth/refresh endpoint
* 4. Parse response to get new tokens
* 5. Store new tokens using setAuthTokens()
* 6. Return new access token or null on failure
*
* Reference the iOS Swift implementation for the expected behavior.
*/
private suspend fun refreshAccessToken(): String? {
TODO("Implement refreshAccessToken: Refresh tokens and return new access token")
private suspend fun refreshAccessToken(): String? = withContext(Dispatchers.IO) {
val refreshToken = getRefreshToken()
val accessToken = getAccessToken()
if (refreshToken == null || accessToken == null) {
Log.w(TAG, "No tokens available for refresh")
return@withContext null
}
try {
// Prepare refresh request body
val refreshBody = JSONObject()
refreshBody.put("token", accessToken)
refreshBody.put("refreshToken", refreshToken)
val headers = mutableMapOf(
"Content-Type" to "application/json",
"X-Ignore-Failure" to "true",
)
headers["X-AliasVault-Client"] = getClientVersionHeader()
val response = executeRawRequest(
method = "POST",
endpoint = "Auth/refresh",
body = refreshBody.toString(),
headers = headers,
)
if (response.statusCode != 200) {
Log.w(TAG, "Token refresh failed with status ${response.statusCode}")
return@withContext null
}
// Parse the response JSON
val json = JSONObject(response.body)
val newToken = if (json.has("token")) json.getString("token") else null
val newRefreshToken = if (json.has("refreshToken")) json.getString("refreshToken") else null
if (newToken == null || newRefreshToken == null) {
Log.w(TAG, "Token refresh response missing tokens")
return@withContext null
}
// Update stored tokens
setAuthTokens(accessToken = newToken, refreshToken = newRefreshToken)
Log.d(TAG, "Token refresh successful")
newToken
} catch (e: Exception) {
Log.e(TAG, "Token refresh failed", e)
null
}
}
// MARK: - Helper Methods
/**
* Get the client version header value
*
* TODO: Implement to return "android-{version}" where version is from BuildConfig or PackageInfo
*/
private fun getClientVersionHeader(): String {
TODO("Implement getClientVersionHeader: Return android-{version} header value")
return try {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val version = packageInfo.versionName ?: "0.0.0"
val baseVersion = version.split("-").firstOrNull() ?: "0.0.0"
"android-$baseVersion"
} catch (e: PackageManager.NameNotFoundException) {
Log.e(TAG, "Error getting package version", e)
"android-0.0.0"
}
}
// MARK: - Token Revocation
/**
* Revoke tokens via WebAPI (called when logging out)
*
* TODO: Implement token revocation logic:
* 1. Get current access and refresh tokens
* 2. Create JSON body with both tokens
* 3. POST to Auth/revoke endpoint
* 4. Always clear tokens at the end, even if revoke fails
*
* Reference the iOS Swift implementation for the expected behavior.
*/
suspend fun revokeTokens() {
TODO("Implement revokeTokens: Revoke tokens via WebAPI and clear them from storage")
suspend fun revokeTokens() = withContext(Dispatchers.IO) {
try {
// Get tokens to revoke
val refreshToken = getRefreshToken()
val accessToken = getAccessToken()
if (refreshToken == null || accessToken == null) {
// No tokens to revoke
clearAuthTokens()
return@withContext
}
// Prepare revoke request body
val revokeBody = JSONObject()
revokeBody.put("token", accessToken)
revokeBody.put("refreshToken", refreshToken)
// Execute revoke request
val response = executeRequest(
method = "POST",
endpoint = "Auth/revoke",
body = revokeBody.toString(),
headers = mapOf("Content-Type" to "application/json"),
requiresAuth = false,
)
// Log if revoke failed, but always clear tokens
if (response.statusCode != 200) {
Log.w(TAG, "Token revoke failed with status ${response.statusCode}")
}
} catch (e: Exception) {
Log.e(TAG, "Token revoke error", e)
}
// Always clear tokens, even if revoke fails
clearAuthTokens()
}
}

View File

@@ -95,9 +95,14 @@ public class WebApiService {
) async throws -> WebApiResponse {
var requestHeaders = headers
// Add authorization header if authentication is required
if requiresAuth, let accessToken = getAccessToken() {
requestHeaders["Authorization"] = "Bearer \(accessToken)"
// Add authorization header if authentication is required AND not already provided
if requiresAuth {
if requestHeaders["Authorization"] == nil, let accessToken = getAccessToken() {
requestHeaders["Authorization"] = "Bearer \(accessToken)"
print("WebApiService: Added Authorization header from stored token")
} else if requestHeaders["Authorization"] != nil {
print("WebApiService: Using provided Authorization header instead of stored token")
}
}
// Add client version header

View File

@@ -71,6 +71,7 @@ export class WebApiService {
}
// Execute request through native layer with auth
// Note: Native layer handles 401 responses and token refresh automatically
const responseJson = await NativeVaultManager.executeWebApiRequest(
method,
endpoint,
@@ -81,7 +82,7 @@ export class WebApiService {
const response: NativeWebApiResponse = JSON.parse(responseJson);
// If native layer returns 401, the session is truly expired
// If native layer returns 401 session is truly expired
// The native layer has already tried to refresh the token, so this is a final failure
if (response.statusCode === 401) {
logoutEventEmitter.emit('auth.errors.sessionExpired');