From 8c228934a55bf04ab71838c4c68d1688890e4c37 Mon Sep 17 00:00:00 2001 From: HiLleywyn Date: Sat, 9 May 2026 05:28:46 +0000 Subject: [PATCH] Android: native Capacitor bridge to CredentialManager for passkeys Android System WebView does not back WebAuthn even though it exposes navigator.credentials, so the existing JS path fails inside the Capacitor wrapper and the user lands on the "Passkeys aren't available in this webview" error. Add a tempest-passkey-bridge Capacitor plugin that wraps androidx.credentials.CredentialManager.createCredential and getCredential, and route the web-side ceremony through it whenever Capacitor.isNativePlatform() is true. Browsers and the desktop shell keep using navigator.credentials. Render /.well-known/assetlinks.json on tempest-web at container start from ANDROID_PACKAGE_NAME and ANDROID_ASSETLINKS_SHA256, so the RP origin can authorize the Android app for passkey requests. The Android workflow now prints the signing cert SHA-256 from each assembled APK so the value can be pasted into the env. RAILWAY.md documents the env vars; scripts/android-cert-sha.sh extracts the SHA from any APK or keystore locally. iOS does not need a native bridge because WKWebView ships WebAuthn from iOS 16; the iOS plugin is a stub that reports unavailable so the JS path stays in charge. Anchor /android/ and /ios/ in apps/mobile/.gitignore so the auto-scaffolded Capacitor projects stay ignored but the plugin source under apps/mobile/plugins/passkey-bridge/{android,ios}/ is tracked. --- .github/workflows/android.yml | 24 ++++ RAILWAY.md | 11 ++ apps/mobile/.gitignore | 8 +- apps/mobile/capacitor.config.ts | 17 +-- apps/mobile/package.json | 3 +- .../TempestPasskeyBridge.podspec | 17 +++ .../passkey-bridge/android/build.gradle | 67 ++++++++++ .../android/src/main/AndroidManifest.xml | 2 + .../tempest/passkey/PasskeyBridgePlugin.kt | 123 ++++++++++++++++++ .../ios/Plugin/PasskeyBridgePlugin.swift | 32 +++++ .../plugins/passkey-bridge/package.json | 29 +++++ .../plugins/passkey-bridge/src/index.d.ts | 7 + .../plugins/passkey-bridge/src/index.js | 13 ++ apps/web/package.json | 1 + apps/web/src/auth/passkey.ts | 57 ++++++++ infra/Dockerfile.web | 2 + infra/nginx.conf | 10 ++ infra/web-entrypoint.sh | 52 ++++++++ pnpm-lock.yaml | 26 +++- pnpm-workspace.yaml | 1 + scripts/android-cert-sha.sh | 39 ++++++ 21 files changed, 522 insertions(+), 19 deletions(-) create mode 100644 apps/mobile/plugins/passkey-bridge/TempestPasskeyBridge.podspec create mode 100644 apps/mobile/plugins/passkey-bridge/android/build.gradle create mode 100644 apps/mobile/plugins/passkey-bridge/android/src/main/AndroidManifest.xml create mode 100644 apps/mobile/plugins/passkey-bridge/android/src/main/java/chat/tempest/passkey/PasskeyBridgePlugin.kt create mode 100644 apps/mobile/plugins/passkey-bridge/ios/Plugin/PasskeyBridgePlugin.swift create mode 100644 apps/mobile/plugins/passkey-bridge/package.json create mode 100644 apps/mobile/plugins/passkey-bridge/src/index.d.ts create mode 100644 apps/mobile/plugins/passkey-bridge/src/index.js create mode 100755 infra/web-entrypoint.sh create mode 100755 scripts/android-cert-sha.sh diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5e15291..a595ffc 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -129,6 +129,30 @@ jobs: find apps/mobile/android/app/build/outputs/apk -name "*.apk" -exec cp -v {} out/ \; ls -la out + - name: Print signing cert SHA-256 for asset links + # The Android Credential Manager only allows passkeys when the web + # origin publishes /.well-known/assetlinks.json with a fingerprint + # that matches the signing cert of the APK. Print the SHA-256 here + # so it can be pasted into the ANDROID_ASSETLINKS_SHA256 env var on + # the tempest-web service. Debug builds use a per-runner keystore, + # so this changes every run for build_type=debug. + run: | + APK=$(ls out/*.apk | head -n1) + if [ -z "$APK" ]; then + echo "::warning::no APK to inspect" + exit 0 + fi + SHA=$(keytool -printcert -jarfile "$APK" 2>/dev/null \ + | awk -F': ' '/SHA256:/{print $2; exit}') + if [ -z "$SHA" ]; then + echo "::warning::could not extract SHA-256 from $APK" + exit 0 + fi + echo "::notice::Android signing SHA-256 ($BUILD_TYPE): $SHA" + echo "Set ANDROID_ASSETLINKS_SHA256 on tempest-web to this value" + echo "(comma-separated if you need both debug and release):" + echo " $SHA" + - uses: actions/upload-artifact@v4 with: name: tempest-android-${{ env.BUILD_TYPE }} diff --git a/RAILWAY.md b/RAILWAY.md index f09288f..92cc5b6 100644 --- a/RAILWAY.md +++ b/RAILWAY.md @@ -209,6 +209,17 @@ VITE_GATEWAY_URL=wss://tempest-gateway-production-1234.up.railway.app/gateway After this service deploys, copy its public URL. Go back to `tempest-api` and fix `TEMPEST_RP_ID`, `TEMPEST_RP_ORIGIN`, and `TEMPEST_ALLOWED_ORIGINS` to match the **web** domain (not the api domain). +If you build the Android app, also set these on `tempest-web`: + +``` +ANDROID_PACKAGE_NAME=chat.tempest.app +ANDROID_ASSETLINKS_SHA256= +``` + +The web container renders `/.well-known/assetlinks.json` from these at start so Android Credential Manager will associate the app with this origin and let passkeys work in the Capacitor WebView. Get the SHA-256 from a built APK with `scripts/android-cert-sha.sh path/to/app.apk`, or from the Android workflow log (it prints the value as a `::notice::` after each build). To rotate or accept multiple keys (debug + release), pass them comma-separated. + +Without this env var the app still installs but `Sign in with passkey` fails: Credential Manager rejects the request because the RP origin has not authorized the app. + ## 6. Wire up the api and gateway URLs the web client uses The web client reads two build time variables to know where the api and gateway live: diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index f499549..8cb70dd 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -1,7 +1,9 @@ # Capacitor scaffolds the native projects on first sync. CI re-runs # `cap add` so we don't need to commit the generated source trees, and # committing them would break the workspace pnpm install on machines -# without Android / Xcode toolchains. -android/ -ios/ +# without Android / Xcode toolchains. The leading slash anchors these +# patterns to apps/mobile/ so plugin source under +# apps/mobile/plugins//{android,ios}/ is still tracked. +/android/ +/ios/ node_modules/ diff --git a/apps/mobile/capacitor.config.ts b/apps/mobile/capacitor.config.ts index 96da267..f36dcd1 100644 --- a/apps/mobile/capacitor.config.ts +++ b/apps/mobile/capacitor.config.ts @@ -3,17 +3,18 @@ import type { CapacitorConfig } from "@capacitor/cli"; // Capacitor wraps the existing apps/web Vite build into native iOS / Android // shells. There are two supported modes: // -// * server.url set (recommended for now): the native app loads the live -// web deployment as its initial page. WebAuthn / passkeys, Service -// Workers, and other origin-locked APIs use the real HTTPS origin and -// work the same as a browser visit. Set TEMPEST_WEB_URL in the build -// env (CI repo var or apps/mobile/.env.production) to enable this. +// * server.url set (recommended): the native app loads the live web +// deployment as its initial page. The bundled tempest-passkey-bridge +// plugin routes passkey ceremonies through Android Credential Manager +// so they work even though Android System WebView does not implement +// navigator.credentials. Set TEMPEST_WEB_URL in the build env (CI +// repo var or apps/mobile/.env.production) to enable this. // // * server.url unset (offline-first): the app uses the bundled // apps/web/dist as the initial page, served from https://localhost. -// Faster first paint, but WebAuthn refuses to run on the localhost -// origin so passkey login does not work. A native passkey bridge is -// needed to use this mode for auth - tracked as future work. +// Faster first paint. The native passkey bridge still works, but the +// RP origin in the WebAuthn challenge has to match a live HTTPS host +// so login is still effectively gated on a real backend. const liveWebUrl = process.env.TEMPEST_WEB_URL?.trim(); const config: CapacitorConfig = { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 04ab827..7634af2 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -23,7 +23,8 @@ "@capacitor/share": "^6.0.2", "@capacitor/status-bar": "^6.0.2", "@capacitor/keyboard": "^6.0.2", - "@capacitor/app": "^6.0.1" + "@capacitor/app": "^6.0.1", + "tempest-passkey-bridge": "workspace:*" }, "devDependencies": { "@capacitor/cli": "^6.1.2", diff --git a/apps/mobile/plugins/passkey-bridge/TempestPasskeyBridge.podspec b/apps/mobile/plugins/passkey-bridge/TempestPasskeyBridge.podspec new file mode 100644 index 0000000..f0f60f8 --- /dev/null +++ b/apps/mobile/plugins/passkey-bridge/TempestPasskeyBridge.podspec @@ -0,0 +1,17 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'TempestPasskeyBridge' + s.version = package['version'] + s.summary = package['description'] + s.license = 'Apache-2.0' + s.homepage = 'https://github.com/HiLleywyn/projecttempest' + s.author = 'HiLleywyn' + s.source = { :git => 'https://github.com/HiLleywyn/projecttempest.git', :tag => s.version.to_s } + s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}' + s.ios.deployment_target = '14.0' + s.dependency 'Capacitor' + s.swift_version = '5.1' +end diff --git a/apps/mobile/plugins/passkey-bridge/android/build.gradle b/apps/mobile/plugins/passkey-bridge/android/build.gradle new file mode 100644 index 0000000..be08c22 --- /dev/null +++ b/apps/mobile/plugins/passkey-bridge/android/build.gradle @@ -0,0 +1,67 @@ +ext { + junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2' + androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0' + androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.2.1' + androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.6.1' + androidxCredentialsVersion = project.hasProperty('androidxCredentialsVersion') ? rootProject.ext.androidxCredentialsVersion : '1.3.0' + kotlinxCoroutinesVersion = '1.8.1' +} + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.7.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25" + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace "chat.tempest.passkey" + compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34 + defaultConfig { + minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22 + targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34 + versionCode 1 + versionName "0.1.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + lintOptions { + abortOnError false + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } +} + +repositories { + google() + mavenCentral() +} + +dependencies { + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation project(':capacitor-android') + implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" + implementation "androidx.credentials:credentials:$androidxCredentialsVersion" + implementation "androidx.credentials:credentials-play-services-auth:$androidxCredentialsVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion" + testImplementation "junit:junit:$junitVersion" + androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" + androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" +} diff --git a/apps/mobile/plugins/passkey-bridge/android/src/main/AndroidManifest.xml b/apps/mobile/plugins/passkey-bridge/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/apps/mobile/plugins/passkey-bridge/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/mobile/plugins/passkey-bridge/android/src/main/java/chat/tempest/passkey/PasskeyBridgePlugin.kt b/apps/mobile/plugins/passkey-bridge/android/src/main/java/chat/tempest/passkey/PasskeyBridgePlugin.kt new file mode 100644 index 0000000..ef9a26b --- /dev/null +++ b/apps/mobile/plugins/passkey-bridge/android/src/main/java/chat/tempest/passkey/PasskeyBridgePlugin.kt @@ -0,0 +1,123 @@ +package chat.tempest.passkey + +import android.os.Build +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.NoCredentialException +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +@CapacitorPlugin(name = "PasskeyBridge") +class PasskeyBridgePlugin : Plugin() { + + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + override fun handleOnDestroy() { + scope.cancel() + super.handleOnDestroy() + } + + @PluginMethod + fun isAvailable(call: PluginCall) { + val ret = JSObject() + if (Build.VERSION.SDK_INT < 28) { + ret.put("available", false) + ret.put("reason", "android_sdk_too_old") + } else { + ret.put("available", true) + } + call.resolve(ret) + } + + @PluginMethod + fun create(call: PluginCall) { + val requestJson = call.getString("requestJson") + if (requestJson.isNullOrEmpty()) { + call.reject("requestJson is required") + return + } + val act = activity + if (act == null) { + call.reject("activity is null") + return + } + scope.launch { + try { + val cm = CredentialManager.create(act) + val req = CreatePublicKeyCredentialRequest(requestJson) + val resp = cm.createCredential(act, req) + val pk = resp as? CreatePublicKeyCredentialResponse + if (pk == null) { + call.reject("unexpected_credential_type", resp.javaClass.name) + return@launch + } + val out = JSObject() + out.put("responseJson", pk.registrationResponseJson) + call.resolve(out) + } catch (e: CreateCredentialException) { + call.reject(messageFor(e), e.type, e) + } catch (e: Exception) { + call.reject(e.message ?: "create_credential_failed", e) + } + } + } + + @PluginMethod + fun get(call: PluginCall) { + val requestJson = call.getString("requestJson") + if (requestJson.isNullOrEmpty()) { + call.reject("requestJson is required") + return + } + val act = activity + if (act == null) { + call.reject("activity is null") + return + } + scope.launch { + try { + val cm = CredentialManager.create(act) + val option = GetPublicKeyCredentialOption(requestJson) + val req = GetCredentialRequest(listOf(option)) + val resp = cm.getCredential(act, req) + val cred = resp.credential + val pk = cred as? PublicKeyCredential + if (pk == null) { + call.reject("unexpected_credential_type", cred.javaClass.name) + return@launch + } + val out = JSObject() + out.put("responseJson", pk.authenticationResponseJson) + call.resolve(out) + } catch (e: NoCredentialException) { + call.reject(messageFor(e), e.type, e) + } catch (e: GetCredentialException) { + call.reject(messageFor(e), e.type, e) + } catch (e: Exception) { + call.reject(e.message ?: "get_credential_failed", e) + } + } + } + + private fun messageFor(e: Throwable): String { + val direct = e.message + if (!direct.isNullOrBlank()) return direct + val cause = e.cause?.message + if (!cause.isNullOrBlank()) return cause + return e.javaClass.simpleName + } +} diff --git a/apps/mobile/plugins/passkey-bridge/ios/Plugin/PasskeyBridgePlugin.swift b/apps/mobile/plugins/passkey-bridge/ios/Plugin/PasskeyBridgePlugin.swift new file mode 100644 index 0000000..c24c8dc --- /dev/null +++ b/apps/mobile/plugins/passkey-bridge/ios/Plugin/PasskeyBridgePlugin.swift @@ -0,0 +1,32 @@ +import Foundation +import Capacitor + +// iOS WKWebView has shipped WebAuthn for the standard navigator.credentials +// API since iOS 16, so the JS path works without a native bridge. This file +// exists so cap sync ios doesn't fail; the bridge reports unavailable and +// the web layer falls through to navigator.credentials on iOS. +@objc(PasskeyBridgePlugin) +public class PasskeyBridgePlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "PasskeyBridgePlugin" + public let jsName = "PasskeyBridge" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "get", returnType: CAPPluginReturnPromise) + ] + + @objc public func isAvailable(_ call: CAPPluginCall) { + call.resolve([ + "available": false, + "reason": "ios_uses_webauthn_in_wkwebview" + ]) + } + + @objc public func create(_ call: CAPPluginCall) { + call.reject("ios_native_bridge_not_implemented") + } + + @objc public func get(_ call: CAPPluginCall) { + call.reject("ios_native_bridge_not_implemented") + } +} diff --git a/apps/mobile/plugins/passkey-bridge/package.json b/apps/mobile/plugins/passkey-bridge/package.json new file mode 100644 index 0000000..956544b --- /dev/null +++ b/apps/mobile/plugins/passkey-bridge/package.json @@ -0,0 +1,29 @@ +{ + "name": "tempest-passkey-bridge", + "version": "0.1.0", + "private": true, + "description": "Native Android Credential Manager bridge so passkeys work inside the Capacitor WebView. iOS side is a stub that reports unavailable; the web layer falls back to navigator.credentials.", + "main": "src/index.js", + "module": "src/index.js", + "types": "src/index.d.ts", + "type": "module", + "files": [ + "android/src/", + "android/build.gradle", + "android/src/main/AndroidManifest.xml", + "ios/Plugin/", + "src/", + "TempestPasskeyBridge.podspec" + ], + "capacitor": { + "ios": { + "src": "ios" + }, + "android": { + "src": "android" + } + }, + "peerDependencies": { + "@capacitor/core": "^6.1.0" + } +} diff --git a/apps/mobile/plugins/passkey-bridge/src/index.d.ts b/apps/mobile/plugins/passkey-bridge/src/index.d.ts new file mode 100644 index 0000000..2c9b9e0 --- /dev/null +++ b/apps/mobile/plugins/passkey-bridge/src/index.d.ts @@ -0,0 +1,7 @@ +export interface PasskeyBridgePlugin { + isAvailable(): Promise<{ available: boolean; reason?: string }>; + create(options: { requestJson: string }): Promise<{ responseJson: string }>; + get(options: { requestJson: string }): Promise<{ responseJson: string }>; +} + +export declare const PasskeyBridge: PasskeyBridgePlugin; diff --git a/apps/mobile/plugins/passkey-bridge/src/index.js b/apps/mobile/plugins/passkey-bridge/src/index.js new file mode 100644 index 0000000..749e4fc --- /dev/null +++ b/apps/mobile/plugins/passkey-bridge/src/index.js @@ -0,0 +1,13 @@ +import { registerPlugin } from "@capacitor/core"; + +export const PasskeyBridge = registerPlugin("PasskeyBridge", { + web: async () => ({ + isAvailable: async () => ({ available: false, reason: "not_native" }), + create: async () => { + throw new Error("PasskeyBridge.create is native-only"); + }, + get: async () => { + throw new Error("PasskeyBridge.get is native-only"); + }, + }), +}); diff --git a/apps/web/package.json b/apps/web/package.json index 0972466..ad5228a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,6 +11,7 @@ "lint": "eslint src --max-warnings 0" }, "dependencies": { + "@capacitor/core": "^6.1.2", "@tanstack/react-query": "^5.59.0", "@tanstack/react-router": "^1.85.0", "cbor-x": "^1.6.0", diff --git a/apps/web/src/auth/passkey.ts b/apps/web/src/auth/passkey.ts index eea5b03..993a225 100644 --- a/apps/web/src/auth/passkey.ts +++ b/apps/web/src/auth/passkey.ts @@ -1,5 +1,44 @@ // WebAuthn passkey ceremony helpers. Wraps the raw `navigator.credentials` // API and converts the JSON shape that webauthn-rs expects. +// +// On Capacitor / Android, navigator.credentials does not back WebAuthn even +// when the global is defined, so we route the ceremony through a native +// Capacitor plugin (tempest-passkey-bridge) that calls the Android +// CredentialManager API. The plugin returns a W3C JSON envelope that +// webauthn-rs deserializes the same way as the JS-produced one. + +import { Capacitor, registerPlugin } from "@capacitor/core"; + +interface PasskeyBridgePlugin { + isAvailable(): Promise<{ available: boolean; reason?: string }>; + create(options: { requestJson: string }): Promise<{ responseJson: string }>; + get(options: { requestJson: string }): Promise<{ responseJson: string }>; +} + +const PasskeyBridge = registerPlugin("PasskeyBridge", { + web: async () => ({ + isAvailable: async () => ({ available: false, reason: "not_native" }), + create: async () => { + throw new Error("PasskeyBridge.create is native-only"); + }, + get: async () => { + throw new Error("PasskeyBridge.get is native-only"); + }, + }), +}); + +let bridgeAvailable: boolean | undefined; +async function nativeBridgeAvailable(): Promise { + if (!Capacitor.isNativePlatform()) return false; + if (bridgeAvailable !== undefined) return bridgeAvailable; + try { + const r = await PasskeyBridge.isAvailable(); + bridgeAvailable = !!r.available; + } catch { + bridgeAvailable = false; + } + return bridgeAvailable; +} function b64urlToBytes(s: string): Uint8Array { const pad = "=".repeat((4 - (s.length % 4)) % 4); @@ -52,6 +91,9 @@ function ensureSupported(): void { } export async function createPasskey(challenge: any): Promise { + if (await nativeBridgeAvailable()) { + return createPasskeyNative(challenge); + } ensureSupported(); const cred = (await navigator.credentials.create(rewriteForCreate(challenge))) as PublicKeyCredential | null; if (!cred) throw new Error("passkey creation cancelled"); @@ -59,12 +101,27 @@ export async function createPasskey(challenge: any): Promise { } export async function getPasskey(challenge: any): Promise { + if (await nativeBridgeAvailable()) { + return getPasskeyNative(challenge); + } ensureSupported(); const cred = (await navigator.credentials.get(rewriteForGet(challenge))) as PublicKeyCredential | null; if (!cred) throw new Error("passkey assertion cancelled"); return serializeCredential(cred, "get"); } +async function createPasskeyNative(challenge: any): Promise { + const requestJson = JSON.stringify(challenge.publicKey); + const r = await PasskeyBridge.create({ requestJson }); + return JSON.parse(r.responseJson); +} + +async function getPasskeyNative(challenge: any): Promise { + const requestJson = JSON.stringify(challenge.publicKey); + const r = await PasskeyBridge.get({ requestJson }); + return JSON.parse(r.responseJson); +} + function serializeCredential(cred: PublicKeyCredential, kind: "create" | "get"): unknown { const r = cred.response as AuthenticatorAttestationResponse & AuthenticatorAssertionResponse; const base = { diff --git a/infra/Dockerfile.web b/infra/Dockerfile.web index 14c3f1f..370bc61 100644 --- a/infra/Dockerfile.web +++ b/infra/Dockerfile.web @@ -21,3 +21,5 @@ RUN pnpm --filter tempest-web build FROM nginx:1.27-alpine COPY --from=builder /app/apps/web/dist /usr/share/nginx/html COPY infra/nginx.conf /etc/nginx/conf.d/default.conf +COPY infra/web-entrypoint.sh /docker-entrypoint.d/40-render-assetlinks.sh +RUN chmod +x /docker-entrypoint.d/40-render-assetlinks.sh diff --git a/infra/nginx.conf b/infra/nginx.conf index 1c8c8d5..94d218a 100644 --- a/infra/nginx.conf +++ b/infra/nginx.conf @@ -19,4 +19,14 @@ server { expires 1y; add_header Cache-Control "public, immutable"; } + + # Digital Asset Links for Android app association. Rendered at container + # start by /docker-entrypoint.d/40-render-assetlinks.sh from the + # ANDROID_ASSETLINKS_SHA256 env var. Served as JSON, never rewritten to + # the SPA index. + location = /.well-known/assetlinks.json { + default_type application/json; + add_header Cache-Control "public, max-age=300" always; + try_files $uri =404; + } } diff --git a/infra/web-entrypoint.sh b/infra/web-entrypoint.sh new file mode 100755 index 0000000..0bdc09c --- /dev/null +++ b/infra/web-entrypoint.sh @@ -0,0 +1,52 @@ +#!/bin/sh +# Runs from /docker-entrypoint.d/ on the official nginx image. The image +# executes everything in that directory before starting nginx, so this +# script just renders /.well-known/assetlinks.json from env vars and +# exits. +# +# Inputs (env vars on the web service): +# ANDROID_PACKAGE_NAME defaults to chat.tempest.app +# ANDROID_ASSETLINKS_SHA256 comma-separated SHA-256 hex fingerprints +# of the signing certs that should be +# associated with the package. Both the +# debug and release keystores can be +# listed here. +# +# Without ANDROID_ASSETLINKS_SHA256 set the file is written with an empty +# fingerprints array; that is intentionally invalid so a misconfigured +# deploy fails closed instead of pretending the app is verified. + +set -eu + +ROOT="${WEB_ROOT:-/usr/share/nginx/html}" +WELLKNOWN="$ROOT/.well-known" +mkdir -p "$WELLKNOWN" + +PKG="${ANDROID_PACKAGE_NAME:-chat.tempest.app}" +RAW="${ANDROID_ASSETLINKS_SHA256:-}" + +FINGERPRINTS="" +if [ -n "$RAW" ]; then + IFS=',' + for sha in $RAW; do + trimmed=$(printf %s "$sha" | tr -d ' \t\n\r') + [ -n "$trimmed" ] || continue + if [ -n "$FINGERPRINTS" ]; then + FINGERPRINTS="$FINGERPRINTS,\"$trimmed\"" + else + FINGERPRINTS="\"$trimmed\"" + fi + done + unset IFS +fi + +cat > "$WELLKNOWN/assetlinks.json" <&2 + exit 1 + fi + keytool -printcert -jarfile "$APK" 2>/dev/null \ + | awk -F': ' '/SHA256:/{print $2; exit}' + exit 0 +fi + +if [ "$#" -eq 4 ]; then + KS="$1"; ALIAS="$2"; SP="$3"; KP="$4" + keytool -list -v -keystore "$KS" -alias "$ALIAS" \ + -storepass "$SP" -keypass "$KP" 2>/dev/null \ + | awk -F': ' '/SHA256:/{print $2; exit}' + exit 0 +fi + +cat <&2 +usage: + $0 path/to/app.apk + $0 path/to/keystore.jks alias storepass keypass +EOF +exit 2