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