Skip to content
This repository was archived by the owner on May 26, 2026. It is now read-only.

Commit 8838945

Browse files
authored
Move Account management and related utils to synctools (#383)
* Add AccountManager extensions and Android account utilities - Add `setAndVerifyUserData` extension function for reliable user data setting - Add `AndroidAccountUtils` with `createAccount` function for account creation with verified user data * Move SensitiveString and tests from DAVx5 to synctools * Fix some issues - Use `asString()` instead of `toString()` for `SensitiveString` in `createAccount` - Improve KDoc * Convert SensitiveString to value class and remove manual equals/hashCode
1 parent bd82aa6 commit 8838945

5 files changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.util
8+
9+
import android.accounts.Account
10+
import android.accounts.AccountManager
11+
import java.util.logging.Logger
12+
13+
/**
14+
* [AccountManager.setUserData] has been found to be unreliable at times. This extension function
15+
* checks whether the user data has actually been set and retries up to ten times before it gives up,
16+
* without throwing an Exception.
17+
*
18+
* Account user data should only be used to reference an own reliable storage.
19+
*/
20+
fun AccountManager.setAndVerifyUserData(account: Account, key: String, value: String?) {
21+
for (i in 1..10) {
22+
if (getUserData(account, key) == value)
23+
return /* already set / success */
24+
25+
setUserData(account, key, value)
26+
27+
// wait a bit because AccountManager access sometimes seems a bit asynchronous
28+
Thread.sleep(100)
29+
}
30+
31+
val logger = Logger.getLogger(javaClass.name)
32+
logger.warning("AccountManager failed to set $account user data $key := $value")
33+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.util
8+
9+
import android.accounts.Account
10+
import android.accounts.AccountManager
11+
import android.content.Context
12+
import android.os.Bundle
13+
14+
object AndroidAccountUtils {
15+
16+
/**
17+
* Creates a system account and makes sure the user data are set correctly.
18+
*
19+
* @param context operating context
20+
* @param account account to create
21+
* @param userData user data to set
22+
* @param password password to set
23+
*
24+
* @return whether the account has been created
25+
*/
26+
fun createAccount(context: Context, account: Account, userData: Map<String, String>, password: SensitiveString? = null): Boolean {
27+
val userDataBundle = Bundle(userData.size).apply {
28+
for ((key, value) in userData)
29+
putString(key, value)
30+
}
31+
32+
// create account
33+
val manager = AccountManager.get(context)
34+
if (!manager.addAccountExplicitly(account, password?.asString(), userDataBundle))
35+
return false
36+
37+
// Android seems to lose the initial user data sometimes, so make sure that the values are set
38+
for ((key, value) in userData)
39+
manager.setAndVerifyUserData(account, key, value)
40+
41+
return true
42+
}
43+
44+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
3+
*/
4+
5+
package at.bitfire.synctools.util
6+
7+
/**
8+
* Wrapper for passwords and other sensitive strings so that they're not directly [String]s,
9+
* so that they're less likely to be used in clear-text unintentionally, like being printed in logs
10+
* by [Any.toString].
11+
*
12+
* This class does not address the issue that clear-text passwords are stored in memory. This problem
13+
* could only be reduced if we would consequently store and process only encrypted passwords, except
14+
* some "providePassword" method that provides the clear-text password for a lambda function as
15+
* [CharArray] and wipes out the array values after usage.
16+
*
17+
* See also:
18+
*
19+
* - https://stackoverflow.com/a/8889285
20+
* - https://javaee.github.io/security-api/apidocs/javax/security/enterprise/credential/Password.html and
21+
* https://javaee.github.io/security-api/apidocs/javax/security/enterprise/credential/UsernamePasswordCredential.html
22+
*/
23+
@JvmInline
24+
value class SensitiveString private constructor(
25+
private val data: String
26+
) {
27+
28+
/**
29+
* Returns the sensitive string as a [CharArray].
30+
*
31+
* _Be careful when using it (for instance, don't print its content unintentionally)._
32+
*/
33+
fun asCharArray() = data.toCharArray()
34+
35+
/**
36+
* Returns the sensitive string as an immutable [String].
37+
*
38+
* _Be careful when using it (for instance, don't print it unintentionally)._
39+
*/
40+
fun asString() = data
41+
42+
/**
43+
* Overrides [toString] so that it doesn't expose the clear-text string (password).
44+
*/
45+
override fun toString() = "*****"
46+
47+
48+
companion object {
49+
50+
fun CharArray.toSensitiveString() =
51+
SensitiveString(this.concatToString())
52+
53+
fun CharSequence.toSensitiveString() =
54+
SensitiveString(this.toString())
55+
56+
}
57+
58+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* This file is part of bitfireAT/synctools which is released under GPLv3.
3+
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
4+
* SPDX-License-Identifier: GPL-3.0-or-later
5+
*/
6+
7+
package at.bitfire.synctools.util
8+
9+
import android.accounts.Account
10+
import android.accounts.AccountManager
11+
import android.content.Context
12+
import at.bitfire.synctools.util.SensitiveString.Companion.toSensitiveString
13+
import org.junit.Assert.assertEquals
14+
import org.junit.Assert.assertTrue
15+
import org.junit.Test
16+
import org.junit.runner.RunWith
17+
import org.robolectric.RobolectricTestRunner
18+
import org.robolectric.RuntimeEnvironment
19+
20+
@RunWith(RobolectricTestRunner::class)
21+
class AndroidAccountUtilsTest {
22+
23+
val context: Context = RuntimeEnvironment.getApplication()
24+
25+
@Test
26+
fun testCreateAccount() {
27+
val userData = mapOf(
28+
"int" to "1",
29+
"string" to "abc/\"-"
30+
)
31+
32+
val account = Account("testCreateAccount", javaClass.name)
33+
val manager = AccountManager.get(context)
34+
try {
35+
assertTrue(AndroidAccountUtils.createAccount(context, account, userData, "secret".toSensitiveString()))
36+
37+
// validate user data
38+
assertEquals("1", manager.getUserData(account, "int"))
39+
assertEquals("abc/\"-", manager.getUserData(account, "string"))
40+
assertEquals("secret", manager.getPassword(account))
41+
} finally {
42+
assertTrue(manager.removeAccountExplicitly(account))
43+
}
44+
}
45+
46+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
3+
*/
4+
5+
package at.bitfire.synctools.util
6+
7+
import at.bitfire.synctools.util.SensitiveString.Companion.toSensitiveString
8+
import org.junit.Assert
9+
import org.junit.Test
10+
11+
class SensitiveStringTest {
12+
13+
private data class UsernameAndPassword(
14+
val username: String,
15+
val password: SensitiveString
16+
)
17+
18+
@Test
19+
fun `equals (other object)`() {
20+
val password = "some-password".toSensitiveString()
21+
Assert.assertFalse(password == Any())
22+
}
23+
24+
@Test
25+
fun `equals (other password)`() {
26+
val password = "some-password".toSensitiveString()
27+
Assert.assertFalse(password == "other-password".toSensitiveString())
28+
}
29+
30+
@Test
31+
fun `equals (same password)`() {
32+
val password = "some-password".toSensitiveString()
33+
Assert.assertTrue(password == "some-password".toSensitiveString())
34+
}
35+
36+
@Test
37+
fun `toString in data class`() {
38+
val credentials = UsernameAndPassword(
39+
"some-user",
40+
"some-password".toSensitiveString()
41+
)
42+
43+
val logMessage = "Credentials: $credentials"
44+
Assert.assertEquals("Credentials: UsernameAndPassword(username=some-user, password=*****)", logMessage)
45+
}
46+
47+
}

0 commit comments

Comments
 (0)