Skip to content

Commit 96cdec7

Browse files
committed
feat(auth): passkey support
thanks @teidesu
1 parent 73567b6 commit 96cdec7

71 files changed

Lines changed: 2087 additions & 106 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/src/main/AndroidManifest.xml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
66
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
7+
<uses-permission android:name="android.permission.CREDENTIAL_MANAGER_SET_ORIGIN" />
78
<uses-permission android:name="android.permission.INTERNET" />
89
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
910
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
@@ -110,6 +111,9 @@
110111
<data android:scheme="http" android:host="telegram.dog" />
111112
<data android:scheme="https" android:host="www.telegram.dog" />
112113
<data android:scheme="http" android:host="www.telegram.dog" />
114+
<!-- KeePassDX discovers candidates for user-approved origin delegation -->
115+
<data android:scheme="https" android:host="www.keepassdx.com" />
116+
<data android:scheme="http" android:host="www.keepassdx.com" />
113117
<data android:scheme="tg" />
114118
</intent-filter>
115119
<intent-filter>

‎core/common/src/main/java/org/monogram/core/common/telegram/TelegramError.kt‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ data class TelegramError(
3939
/** Main-DC 401 session death requires re-login. AUTH_KEY_PERM_EMPTY is PFS, not logout. */
4040
val requiresReauth: Boolean
4141
get() = kind == Kind.Session &&
42-
type != "AUTH_KEY_UNSYNCHRONIZED" &&
43-
type != "AUTH_KEY_PERM_EMPTY"
42+
type != "AUTH_KEY_UNSYNCHRONIZED" &&
43+
type != "AUTH_KEY_PERM_EMPTY"
4444

4545
enum class Kind {
4646
Flood,
@@ -53,6 +53,7 @@ data class TelegramError(
5353
Privacy,
5454
SeeOther,
5555
NotFound,
56+
FreshAuthorization,
5657
NotAcceptable,
5758
Internal,
5859
Generic,
@@ -130,6 +131,7 @@ data class TelegramError(
130131

131132
fun classify(httpCode: Int, type: String): Kind {
132133
val t = type.uppercase()
134+
if (t == "FRESH_RESET_AUTHORISATION_FORBIDDEN") return Kind.FreshAuthorization
133135
if (t.contains("MIGRATE")) return Kind.SeeOther
134136
if (httpCode == 420 ||
135137
t.contains("FLOOD_WAIT") ||
@@ -213,7 +215,8 @@ data class TelegramError(
213215
* the real token is what makes logs point at the cause instead of a fake 401.
214216
*/
215217
fun sessionInvalidated(reason: String? = null): TelegramError {
216-
val token = reason?.trim()?.takeIf { SESSION_REASON.matches(it) } ?: SESSION_INVALIDATED_TYPE
218+
val token =
219+
reason?.trim()?.takeIf { SESSION_REASON.matches(it) } ?: SESSION_INVALIDATED_TYPE
217220
return parse("$SESSION_INVALIDATED_PREFIX $token")
218221
}
219222

‎core/common/src/test/java/org/monogram/core/common/telegram/TelegramErrorTest.kt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import org.junit.Assert.assertTrue
77
import org.junit.Test
88

99
class TelegramErrorTest {
10+
@Test
11+
fun freshAuthorizationRestrictionUsesCatalogEntry() {
12+
val error = TelegramError.parse("RPC 406: FRESH_RESET_AUTHORISATION_FORBIDDEN")
13+
assertTrue(error.recognized)
14+
assertEquals(TelegramError.Kind.FreshAuthorization, error.kind)
15+
assertFalse(error.requiresReauth)
16+
assertEquals(TelegramError.Kind.NotAcceptable, TelegramError.parse("RPC 406: UNKNOWN_RESTRICTION").kind)
17+
}
1018
@Test
1119
fun catalogIngestsOfficialDatabase() {
1220
assertEquals(227, TelegramError.CATALOG_LAYER)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.monogram.core.models
2+
3+
class PasskeyLoginOptions(val options: String, val dcId: Int, val authKeyId: Long)
4+
5+
class PasskeyCredential(
6+
val id: String,
7+
val rawId: String,
8+
val clientData: String,
9+
val authenticatorData: ByteArray = byteArrayOf(),
10+
val signature: ByteArray = byteArrayOf(),
11+
val userHandle: String = "",
12+
val attestationData: ByteArray = byteArrayOf(),
13+
)
14+
15+
data class Passkey(
16+
val id: String,
17+
val name: String,
18+
val date: Int,
19+
val softwareEmojiId: Long?,
20+
val lastUsageDate: Int?,
21+
)

‎core/ui/build.gradle.kts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ android {
2424
}
2525

2626
dependencies {
27+
api(libs.androidx.credentials)
28+
implementation(libs.androidx.credentials.play.services.auth)
2729
testImplementation(libs.junit)
2830
implementation(project(":core:common"))
2931
implementation(project(":core:models"))
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package org.monogram.core.ui.auth
2+
3+
import android.content.Context
4+
import android.os.Build
5+
import android.util.Base64
6+
import androidx.credentials.CreatePublicKeyCredentialRequest
7+
import androidx.credentials.CreatePublicKeyCredentialResponse
8+
import androidx.credentials.CredentialManager
9+
import androidx.credentials.GetCredentialRequest
10+
import androidx.credentials.GetPublicKeyCredentialOption
11+
import androidx.credentials.PublicKeyCredential
12+
import org.json.JSONObject
13+
import org.monogram.core.models.PasskeyCredential
14+
import java.security.MessageDigest
15+
16+
object PasskeyCredentials {
17+
class ClientDataMismatchException : Exception("Credential provider did not use the requested client data")
18+
19+
private const val RELYING_PARTY = "telegram.org"
20+
private const val ORIGIN = "https://telegram.org"
21+
22+
val supported: Boolean get() = Build.VERSION.SDK_INT >= 28
23+
24+
suspend fun request(
25+
context: Context,
26+
options: String,
27+
registration: Boolean
28+
): PasskeyCredential {
29+
check(supported)
30+
val operation = if (registration) Operation.Register else Operation.Login
31+
val request = CredentialRequest.parse(options, operation)
32+
val manager = CredentialManager.create(context)
33+
val response = when (operation) {
34+
Operation.Register -> register(context, manager, request)
35+
Operation.Login -> login(context, manager, request)
36+
}
37+
return CredentialResponse(response).toCredential(request)
38+
}
39+
40+
private suspend fun register(
41+
context: Context,
42+
manager: CredentialManager,
43+
request: CredentialRequest,
44+
): String {
45+
val response = manager.createCredential(
46+
context,
47+
CreatePublicKeyCredentialRequest(
48+
requestJson = request.publicKeyJson,
49+
clientDataHash = request.clientDataHash,
50+
origin = ORIGIN,
51+
),
52+
) as CreatePublicKeyCredentialResponse
53+
return response.registrationResponseJson
54+
}
55+
56+
private suspend fun login(
57+
context: Context,
58+
manager: CredentialManager,
59+
request: CredentialRequest,
60+
): String {
61+
val response = manager.getCredential(
62+
context,
63+
GetCredentialRequest.Builder()
64+
.addCredentialOption(
65+
GetPublicKeyCredentialOption(
66+
request.publicKeyJson,
67+
request.clientDataHash
68+
)
69+
)
70+
.setOrigin(ORIGIN)
71+
.build(),
72+
)
73+
return (response.credential as PublicKeyCredential).authenticationResponseJson
74+
}
75+
76+
private enum class Operation(val webAuthnType: String) {
77+
Register("webauthn.create"),
78+
Login("webauthn.get"),
79+
}
80+
81+
private class CredentialRequest(
82+
val operation: Operation,
83+
val publicKeyJson: String,
84+
val clientDataJson: String,
85+
) {
86+
val clientDataHash: ByteArray = MessageDigest.getInstance("SHA-256")
87+
.digest(clientDataJson.toByteArray(Charsets.UTF_8))
88+
89+
companion object {
90+
fun parse(options: String, operation: Operation): CredentialRequest {
91+
val publicKey = JSONObject(options).getJSONObject("publicKey")
92+
val relyingParty = when (operation) {
93+
Operation.Register -> publicKey.getJSONObject("rp").getString("id")
94+
Operation.Login -> publicKey.getString("rpId")
95+
}
96+
require(relyingParty == RELYING_PARTY)
97+
val clientData = JSONObject().apply {
98+
put("type", operation.webAuthnType)
99+
put("challenge", publicKey.getString("challenge"))
100+
put("origin", ORIGIN)
101+
}.toString()
102+
return CredentialRequest(operation, publicKey.toString(), clientData)
103+
}
104+
}
105+
}
106+
107+
private class CredentialResponse(json: String) {
108+
private val credential = JSONObject(json)
109+
private val response = credential.getJSONObject("response")
110+
111+
fun toCredential(request: CredentialRequest): PasskeyCredential {
112+
val id = credential.getString("id")
113+
val rawId = credential.getString("rawId")
114+
val returnedClientData = response.optString("clientDataJSON")
115+
if (returnedClientData.isNotEmpty() && returnedClientData != "<placeholder>") {
116+
val returnedBytes = Base64.decode(returnedClientData, Base64.URL_SAFE or Base64.NO_WRAP)
117+
if (!MessageDigest.isEqual(
118+
MessageDigest.getInstance("SHA-256").digest(returnedBytes),
119+
request.clientDataHash,
120+
)) throw ClientDataMismatchException()
121+
}
122+
return when (request.operation) {
123+
Operation.Register -> PasskeyCredential(
124+
id = id,
125+
rawId = rawId,
126+
clientData = request.clientDataJson,
127+
attestationData = bytes("attestationObject"),
128+
)
129+
130+
Operation.Login -> PasskeyCredential(
131+
id = id,
132+
rawId = rawId,
133+
clientData = request.clientDataJson,
134+
authenticatorData = bytes("authenticatorData"),
135+
signature = bytes("signature"),
136+
userHandle = bytes("userHandle").toString(Charsets.UTF_8),
137+
)
138+
}
139+
}
140+
141+
private fun bytes(name: String): ByteArray =
142+
Base64.decode(response.getString(name), Base64.URL_SAFE or Base64.NO_WRAP)
143+
}
144+
}

‎core/ui/src/main/java/org/monogram/core/ui/components/AppStatusBanner.kt‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,10 @@ fun AppStatusBanner(
135135
progress = error?.kind == TelegramError.Kind.FileReference,
136136
onRetry = onRetry.takeIf {
137137
error != null &&
138+
error.kind != TelegramError.Kind.FreshAuthorization &&
138139
(error.kind != TelegramError.Kind.Flood || floodSeconds <= 0)
139140
},
141+
maxLines = if (error?.kind == TelegramError.Kind.FreshAuthorization) Int.MAX_VALUE else 3,
140142
modifier = modifier,
141143
)
142144
if (error == null) {
@@ -152,6 +154,7 @@ fun AppStatusBanner(
152154
progress: Boolean,
153155
onRetry: (() -> Unit)? = null,
154156
modifier: Modifier = Modifier,
157+
maxLines: Int = 3,
155158
) {
156159
AnimatedVisibility(
157160
visible = !text.isNullOrBlank(),
@@ -184,7 +187,7 @@ fun AppStatusBanner(
184187
Text(
185188
text = text.orEmpty(),
186189
style = MaterialTheme.typography.bodyMedium,
187-
maxLines = 3,
190+
maxLines = maxLines,
188191
overflow = TextOverflow.Ellipsis,
189192
modifier = Modifier.weight(1f),
190193
)
@@ -207,6 +210,7 @@ fun AppStatusBanner(
207210

208211
@Composable
209212
fun TelegramError.toUiMessage(remainingFloodSeconds: Int? = null): String = when (kind) {
213+
TelegramError.Kind.FreshAuthorization -> stringResource(R.string.status_fresh_authorization)
210214
TelegramError.Kind.Flood -> if ((remainingFloodSeconds ?: retryAfterSeconds ?: argument ?: 0) > 0) {
211215
stringResource(
212216
R.string.status_flood_wait,

‎core/ui/src/main/java/org/monogram/core/ui/perf/RecompositionProbe.kt‎

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,6 @@ import androidx.compose.runtime.SideEffect
55
import org.monogram.core.common.DebugLog
66
import org.monogram.core.common.PerfLog
77

8-
/**
9-
* Dev-only recomposition counter for the compose perf harness
10-
* (`scripts/perf/compose-perf.sh`), the chat-list equivalent of the netcode spans.
11-
*
12-
* One `recomp <name> [detail]` line is emitted per composition that actually runs; skipped
13-
* compositions emit nothing, so counting the lines for a scenario gives restart counts and
14-
* lets a probe count be compared against the Studio Layout Inspector "Recompositions" column.
15-
*
16-
* [inline] on purpose: a non-inlined probe is itself a skippable composable call, so restarts
17-
* that repeat the same arguments would be silently skipped and under-counted. Inlined, the
18-
* `SideEffect` is registered in the caller's own group and every execution of the caller's body
19-
* is counted.
20-
*
21-
* The detail overloads take the raw id, and the primitive ones take it unboxed, so nothing is
22-
* formatted or boxed while the gate is off; these probes sit on the paths being optimised. The
23-
* nullable `Int?` overload exists for ids that arrive nullable (folder filters). Ids only ever reach
24-
* logcat in a build where someone enabled the gate deliberately.
25-
*/
268
@Composable
279
inline fun RecompositionProbe(name: String) {
2810
if (PerfLog.isEnabled() || DebugLog.enabled) {
@@ -76,4 +58,4 @@ inline fun <T> perfSpan(op: String, block: () -> T): T {
7658
if (!PerfLog.isEnabled()) return block()
7759
val started = PerfLog.nowMs()
7860
return block().also { PerfLog.mark(op, PerfLog.nowMs() - started) }
79-
}
61+
}

‎core/ui/src/main/res/values-es/strings.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,6 @@
274274
<string name="media_info_unknown">Desconocido</string>
275275
<string name="media_action_save_as">Guardar como…</string>
276276
<string name="media_copy_hdr_warning">Imagen copiada. Algunas apps pueden no ser compatibles con HDR</string>
277+
<string name="status_fresh_authorization">Por seguridad, esta acción no está disponible justo después de iniciar sesión. Inténtalo tras 24 horas o usa una sesión anterior</string>
278+
<string name="passkey_client_data_mismatch">El proveedor firmó otros datos de acceso. Revisa sus ajustes de aplicaciones privilegiadas para Monogram</string>
277279
</resources>

‎core/ui/src/main/res/values-hy/strings.xml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,6 @@
274274
<string name="media_info_unknown">Անհայտ</string>
275275
<string name="media_action_save_as">Պահպանել որպես…</string>
276276
<string name="media_copy_hdr_warning">Պատկերը պատճենվեց։ Որոշ հավելվածներ կարող են չաջակցել HDR-ին</string>
277+
<string name="status_fresh_authorization">Անվտանգության նկատառումներով այս գործողությունն անհասանելի է մուտքից անմիջապես հետո։ Փորձեք 24 ժամ անց կամ օգտագործեք ավելի հին նստաշրջան</string>
278+
<string name="passkey_client_data_mismatch">Հավատարմագրերի մատակարարը ստորագրել է մուտքի այլ տվյալներ։ Ստուգեք Monogram-ի արտոնյալ հավելվածների կարգավորումները</string>
277279
</resources>

0 commit comments

Comments
 (0)