From 7edb38d7675a6d965420b51a7ed6a6a69a53c950 Mon Sep 17 00:00:00 2001 From: Kenny Root Date: Tue, 27 Oct 2026 20:41:43 -0700 Subject: [PATCH] feat: add webauthn subdomain for app links In order to use the Android Credential Manager (WebAuthn) you need to publish the App Links for your app. This establishes webauthn.connectbot.org as a domain for the WebAuthn RP ID. Nothing actually lives at the website, but it needs that .well-known/assetlinks.json for Credential Manager. --- .github/workflows/ci.yml | 24 +++++++- docs/development.md | 6 +- public/brand.css | 13 +++++ scripts/check-webauthn.py | 29 ++++++++++ scripts/prepare-webauthn.py | 14 +++++ src/app/styles.css | 5 +- webauthn/.well-known/assetlinks.json | 16 ++++++ webauthn/404.html | 16 ++++++ webauthn/_headers | 2 + webauthn/index.html | 16 ++++++ webauthn/robots.txt | 2 + webauthn/style.css | 82 ++++++++++++++++++++++++++++ 12 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 public/brand.css create mode 100644 scripts/check-webauthn.py create mode 100644 scripts/prepare-webauthn.py create mode 100644 webauthn/.well-known/assetlinks.json create mode 100644 webauthn/404.html create mode 100644 webauthn/_headers create mode 100644 webauthn/index.html create mode 100644 webauthn/robots.txt create mode 100644 webauthn/style.css diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c405c18..31628403 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,6 @@ on: permissions: contents: read - deployments: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -16,6 +15,9 @@ concurrency: jobs: build: + permissions: + contents: read + deployments: write # Match the OS supported by the pinned Playwright version. runs-on: ubuntu-24.04 steps: @@ -70,3 +72,23 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: pages deploy ./out --project-name=connectbot-website gitHubToken: ${{ secrets.GITHUB_TOKEN }} + + webauthn: + permissions: + contents: read + deployments: write + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Validate WebAuthn association + run: python3 scripts/check-webauthn.py + - name: Package WebAuthn site + run: python3 scripts/prepare-webauthn.py + - name: Deploy WebAuthn association + if: github.ref == 'refs/heads/develop' && github.repository == 'connectbot/connectbot.github.io' && github.event_name == 'push' + uses: cloudflare/wrangler-action@953926a2e2182532811c01a25e53647d93bf07c0 # v4.1.3 + with: + apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: pages deploy ./local/webauthn --project-name=connectbot-webauthn --branch=main + gitHubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/development.md b/docs/development.md index 9eb830e1..4a54718f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -52,12 +52,14 @@ pnpm run analyze ## Deployment -The site is automatically deployed to GitHub Pages when changes are pushed to the `develop` branch. The CI workflow: +The site is automatically deployed to Cloudflare Pages when changes are pushed to the `develop` branch. The CI workflow: 1. Runs linting and type checking 2. Builds the static site -3. Deploys to GitHub Pages +3. Deploys to Cloudflare Pages You can preview the production build locally by serving the `./out` directory: ```bash npx serve ./out ``` + +See [WebAuthn hosting](webauthn.md) for the independent passkey association deployment. diff --git a/public/brand.css b/public/brand.css new file mode 100644 index 00000000..45b6125c --- /dev/null +++ b/public/brand.css @@ -0,0 +1,13 @@ +:root { + --site-background: #fafbfc; + --site-text: #1e293b; + --site-link-color: #1d4ed8; + --site-link-hover-color: #1e40af; +} + +.dark { + --site-background: #0f172a; + --site-text: #e2e8f0; + --site-link-color: #93c5fd; + --site-link-hover-color: #bfdbfe; +} diff --git a/scripts/check-webauthn.py b/scripts/check-webauthn.py new file mode 100644 index 00000000..5be64cc1 --- /dev/null +++ b/scripts/check-webauthn.py @@ -0,0 +1,29 @@ +"""Validate the release identities published by the WebAuthn RP domain.""" +import hashlib +import json +from pathlib import Path +import ssl + +ROOT = Path(__file__).resolve().parents[1] +CERTIFICATES = ( + 'connectbot-app-signing-original.pem', + 'connectbot-play-app-signing-intermediate.pem', + 'connectbot-app-signing-2026.pem', +) +fingerprints = [] +for certificate in CERTIFICATES: + pem = (ROOT / 'public/certificates' / certificate).read_text() + digest = hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest().upper() + fingerprints.append(':'.join(digest[i:i + 2] for i in range(0, 64, 2))) +expected = [{ + 'relation': ['delegate_permission/common.get_login_creds'], + 'target': { + 'namespace': 'android_app', + 'package_name': 'org.connectbot', + 'sha256_cert_fingerprints': fingerprints, + }, +}] +actual = json.loads((ROOT / 'webauthn/.well-known/assetlinks.json').read_text()) +if actual != expected: + raise SystemExit('WebAuthn association must match the three official release certificates') +print('WebAuthn association matches all three official release certificates') diff --git a/scripts/prepare-webauthn.py b/scripts/prepare-webauthn.py new file mode 100644 index 00000000..25e02211 --- /dev/null +++ b/scripts/prepare-webauthn.py @@ -0,0 +1,14 @@ +"""Package the static RP site with canonical shared brand assets.""" +from pathlib import Path +import shutil + +root = Path(__file__).resolve().parents[1] +out = root / 'local/webauthn' +if out.exists(): + shutil.rmtree(out) +shutil.copytree(root / 'webauthn', out) +for asset in ('brand.css', 'logo.png', 'fonts/logo-font.woff2'): + target = out / asset + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(root / 'public' / asset, target) +print(f'Prepared {out}') diff --git a/src/app/styles.css b/src/app/styles.css index 7ed0e795..ebcada3f 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -1,16 +1,13 @@ @import 'nextra-theme-docs/style.css'; +@import '../../public/brand.css'; :root { --site-header-height: var(--nextra-navbar-height); - --site-link-color: #1d4ed8; - --site-link-hover-color: #1e40af; --x-color-primary-400: #93c5fd; --x-color-primary-600: var(--site-link-color); } .dark { - --site-link-color: #93c5fd; - --site-link-hover-color: #bfdbfe; --x-color-primary-600: var(--site-link-color); } diff --git a/webauthn/.well-known/assetlinks.json b/webauthn/.well-known/assetlinks.json new file mode 100644 index 00000000..9a85e1f7 --- /dev/null +++ b/webauthn/.well-known/assetlinks.json @@ -0,0 +1,16 @@ +[ + { + "relation": [ + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "org.connectbot", + "sha256_cert_fingerprints": [ + "AA:43:74:6A:46:12:78:42:16:9F:81:76:41:2A:1F:4B:1B:1E:2F:8A:D9:E1:5A:BA:21:7C:38:22:DB:EB:2D:5F", + "2F:EF:9C:FE:29:BE:E1:93:20:61:A8:1B:FB:B8:B5:7C:5C:65:88:54:B8:2C:C9:7B:BF:26:52:65:DD:B2:27:7C", + "D2:49:13:B6:92:DB:31:A9:29:CD:30:AE:86:18:DF:97:71:88:82:B6:EA:A1:ED:6E:12:4B:C4:3D:02:8E:7F:BE" + ] + } + } +] diff --git a/webauthn/404.html b/webauthn/404.html new file mode 100644 index 00000000..231fcb45 --- /dev/null +++ b/webauthn/404.html @@ -0,0 +1,16 @@ + + + + + + Page not found | ConnectBot + + + + + +
ConnectBot
+

Page not found

The page you requested could not be found.

Visit ConnectBot →

+ + + diff --git a/webauthn/_headers b/webauthn/_headers new file mode 100644 index 00000000..50add204 --- /dev/null +++ b/webauthn/_headers @@ -0,0 +1,2 @@ +/.well-known/assetlinks.json + Content-Type: application/json diff --git a/webauthn/index.html b/webauthn/index.html new file mode 100644 index 00000000..aeeb1586 --- /dev/null +++ b/webauthn/index.html @@ -0,0 +1,16 @@ + + + + + + WebAuthn association | ConnectBot + + + + + +
ConnectBot
+

WebAuthn association

This domain publishes the Android app association for ConnectBot’s passkey support.

This site does not receive or store your private keys. Passkeys are managed by your selected credential provider.

Visit ConnectBot →

+ + + diff --git a/webauthn/robots.txt b/webauthn/robots.txt new file mode 100644 index 00000000..c2a49f4f --- /dev/null +++ b/webauthn/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/webauthn/style.css b/webauthn/style.css new file mode 100644 index 00000000..01ba5f71 --- /dev/null +++ b/webauthn/style.css @@ -0,0 +1,82 @@ +@font-face { + font-family: ConnectBot; + src: url('/fonts/logo-font.woff2') format('woff2'); + font-weight: 100; + font-display: optional; +} + +* { + box-sizing: border-box; +} +html { + color-scheme: dark; +} +body { + margin: 0; + background: var(--site-background); + color: var(--site-text); + font: + 1rem/1.65 Arial, + sans-serif; +} +header, +main, +footer { + max-width: 48rem; + margin: auto; + padding: 1.5rem; +} +header { + border-bottom: 1px solid #8884; +} +.brand { + display: inline-flex; + align-items: center; + gap: 0.75rem; + color: inherit; + text-decoration: none; + font: + 100 1.5rem/1 ConnectBot, + Arial, + sans-serif; +} +.brand img { + width: 40px; + height: 40px; +} +main { + padding-block: clamp(2rem, 8vw, 5rem); +} +h1 { + font-size: clamp(1.75rem, 5vw, 2.5rem); + line-height: 1.2; + letter-spacing: -0.025em; +} +p { + max-width: 38rem; +} +.notice { + margin-block: 2rem; + padding: 1.25rem; + border: 1px solid #8884; + border-radius: 0.875rem; + background: #8881; +} +.notice p { + margin: 0; +} +a { + color: var(--site-link-color); + text-underline-offset: 0.25em; +} +a:hover { + color: var(--site-link-hover-color); +} +a:focus-visible { + outline: 3px solid #438be5; + outline-offset: 4px; +} +footer { + border-top: 1px solid #8884; + font-size: 0.875rem; +}