diff --git a/touchfluffytail.club/bun.lock b/touchfluffytail.club/bun.lock index 34ffbeb..0d8413b 100644 --- a/touchfluffytail.club/bun.lock +++ b/touchfluffytail.club/bun.lock @@ -10,13 +10,14 @@ "@atproto/api": "^0.24.3", "@atproto/common-web": "^0.5.14", "@atproto/oauth-client-expo": "^0.2.6", + "@craftzdog/react-native-buffer": "^6.1.2", "@expo/ui": "~57.0.23", "@noble/post-quantum": "^0.7.1", - "@react-native-async-storage/async-storage": "2.2.0", "@react-native-vector-icons/fontawesome6": "^13.1.5", "@react-native/typescript-config": "^0.87.1", "@types/node": "^26.6.4", "core-js": "^3.50.0", + "debug": "^4.4.3", "expo": "~57.0.28", "expo-blur": "~57.0.3", "expo-camera": "~57.0.6", @@ -74,6 +75,7 @@ }, "patchedDependencies": { "@atproto/oauth-client-expo@0.2.6": "patches/@atproto%2Foauth-client-expo@0.2.6.patch", + "@atproto-labs/fetch@0.3.6": "patches/@atproto-labs%2Ffetch@0.3.6.patch", }, "overrides": { "lightningcss": "1.30.1", @@ -303,6 +305,8 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@craftzdog/react-native-buffer": ["@craftzdog/react-native-buffer@6.1.2", "", { "dependencies": { "ieee754": "^1.2.1", "react-native-quick-base64": "^3.0.0" } }, "sha512-KV1HitN05FHLLDG7Zb/yftDsa+mKBYBzFMQ0PMldvUicq6vWOtAvz9mDavt7Fzozh+WNqORE+yFDkkdWysZ/SA=="], + "@dual-bundle/import-meta-resolve": ["@dual-bundle/import-meta-resolve@4.2.1", "", {}, "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg=="], "@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="], @@ -497,8 +501,6 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw=="], - "@react-native-async-storage/async-storage": ["@react-native-async-storage/async-storage@2.2.0", "", { "dependencies": { "merge-options": "^3.0.4" }, "peerDependencies": { "react-native": "^0.0.0-0 || >=0.65 <1.0" } }, "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw=="], - "@react-native-masked-view/masked-view": ["@react-native-masked-view/masked-view@0.3.2", "", { "peerDependencies": { "react": ">=16", "react-native": ">=0.57" } }, "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ=="], "@react-native-vector-icons/common": ["@react-native-vector-icons/common@13.0.4", "", { "dependencies": { "find-up": "^8.0.0", "picocolors": "^1.1.1", "plist": "^3.1.0" }, "peerDependencies": { "@react-native-vector-icons/get-image": "^13.0.2", "@react-native/assets-registry": "*", "expo-font": "*", "react": "*", "react-native": "*" }, "optionalPeers": ["@react-native-vector-icons/get-image", "@react-native/assets-registry", "expo-font"], "bin": { "rnvi-update-plist": "lib/commonjs/scripts/updatePlist.js" } }, "sha512-1MvNyRf3KIW747rJsmka/+EdEwV92ccVHedEXFV2kQ0lo6D7uvrIBTtffeDidN4//l8+R4bN3IiQv0tXQzaRXQ=="], @@ -1149,6 +1151,8 @@ "hyphenate-style-name": ["hyphenate-style-name@1.1.0", "", {}, "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -1205,8 +1209,6 @@ "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], - "is-plain-obj": ["is-plain-obj@2.1.0", "", {}, "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA=="], - "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], @@ -1327,8 +1329,6 @@ "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], - "merge-options": ["merge-options@3.0.4", "", { "dependencies": { "is-plain-obj": "^2.1.0" } }, "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ=="], - "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], "metro": ["metro@0.84.5", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", "@babel/generator": "^7.29.1", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", "hermes-parser": "0.35.0", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.84.5", "metro-cache": "0.84.5", "metro-cache-key": "0.84.5", "metro-config": "0.84.5", "metro-core": "0.84.5", "metro-file-map": "0.84.5", "metro-resolver": "0.84.5", "metro-runtime": "0.84.5", "metro-source-map": "0.84.5", "metro-symbolicate": "0.84.5", "metro-transform-plugins": "0.84.5", "metro-transform-worker": "0.84.5", "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", "throat": "^5.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "metro": "src/cli.js" } }, "sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw=="], @@ -1539,6 +1539,8 @@ "react-native-qrcode-svg": ["react-native-qrcode-svg@6.3.26", "", { "dependencies": { "prop-types": "^15.8.0", "qrcode": "^1.5.4", "text-encoding": "^0.7.0" }, "peerDependencies": { "react": "*", "react-native": ">=0.63.4", "react-native-svg": ">=14.0.0" } }, "sha512-xhQywklf0qh5Z9ooy2rEuCrv6tRksVAeFUdYgv5y3fUkwQN53yp2Iu49QTgxj8QyjFgo0fYwee+knBBDzbNH+g=="], + "react-native-quick-base64": ["react-native-quick-base64@3.0.1", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-EjUP2U7WqKmlMmoY7XGyHomy8bM0q4+yCDCRg4ZezQ6zedYRwc7yVk4V2O/iSftKaLEzhuW98lpyPMdk1iPHXQ=="], + "react-native-reanimated": ["react-native-reanimated@4.5.1", "", { "dependencies": { "react-native-is-edge-to-edge": "^1.3.1", "semver": "^7.7.3" }, "peerDependencies": { "react": "*", "react-native": "0.83 - 0.86", "react-native-worklets": "0.10.x" } }, "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA=="], "react-native-safe-area-context": ["react-native-safe-area-context@5.7.0", "", { "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ=="], diff --git a/touchfluffytail.club/lexicons-src/club/touchfluffytail/touchedgrass.ts b/touchfluffytail.club/lexicons-src/club/touchfluffytail/touchedgrass.ts index 5a3727b..590fecf 100644 --- a/touchfluffytail.club/lexicons-src/club/touchfluffytail/touchedgrass.ts +++ b/touchfluffytail.club/lexicons-src/club/touchfluffytail/touchedgrass.ts @@ -13,6 +13,12 @@ export default document({ description: "target for irl meetup, did of the user being verified", }), ), + PubKeyTid: required( + string({ + format: "tid", + description: "tid of the public key that should be used for verification", + }), + ), Payload: required(string({ description: "signed did of the person verifiying" })), CreatedAt: required( string({ format: "datetime", description: "when record was created" }), diff --git a/touchfluffytail.club/lexicons/club/touchfluffytail/touchedfluffytail.json b/touchfluffytail.club/lexicons/club/touchfluffytail/touchedfluffytail.json index 05f4890..b2e0f55 100644 --- a/touchfluffytail.club/lexicons/club/touchfluffytail/touchedfluffytail.json +++ b/touchfluffytail.club/lexicons/club/touchfluffytail/touchedfluffytail.json @@ -9,6 +9,11 @@ "description": "target for irl meetup, did of the user being verified", "format": "did" }, + "PubKeyTid": { + "type": "string", + "description": "tid of the public key that should be used for verification", + "format": "tid" + }, "Payload": { "type": "string", "description": "signed did of the person verifiying" @@ -21,6 +26,7 @@ }, "required": [ "Target", + "PubKeyTid", "Payload", "CreatedAt" ], diff --git a/touchfluffytail.club/package.json b/touchfluffytail.club/package.json index 192c704..76bd15b 100644 --- a/touchfluffytail.club/package.json +++ b/touchfluffytail.club/package.json @@ -9,13 +9,14 @@ "@atproto/api": "^0.24.3", "@atproto/common-web": "^0.5.14", "@atproto/oauth-client-expo": "^0.2.6", + "@craftzdog/react-native-buffer": "^6.1.2", "@expo/ui": "~57.0.23", "@noble/post-quantum": "^0.7.1", - "@react-native-async-storage/async-storage": "2.2.0", "@react-native-vector-icons/fontawesome6": "^13.1.5", "@react-native/typescript-config": "^0.87.1", "@types/node": "^26.6.4", "core-js": "^3.50.0", + "debug": "^4.4.3", "expo": "~57.0.28", "expo-blur": "~57.0.3", "expo-camera": "~57.0.6", @@ -79,7 +80,6 @@ "typecheck": "tsc --noEmit", "lexicons:export": "lex-cli export", "lexicons:generate": "lex-cli generate", - "oauth-metadata": "bun scripts/sync-oauth-client-metadata.ts", "prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean", "fmt": "prettier . --write" }, @@ -88,7 +88,8 @@ "lightningcss": "1.30.1" }, "patchedDependencies": { - "@atproto/oauth-client-expo@0.2.6": "patches/@atproto%2Foauth-client-expo@0.2.6.patch" + "@atproto/oauth-client-expo@0.2.6": "patches/@atproto%2Foauth-client-expo@0.2.6.patch", + "@atproto-labs/fetch@0.3.6": "patches/@atproto-labs%2Ffetch@0.3.6.patch" }, "expo": { "doctor": { diff --git a/touchfluffytail.club/patches/@atproto-labs%2Ffetch@0.3.6.patch b/touchfluffytail.club/patches/@atproto-labs%2Ffetch@0.3.6.patch new file mode 100644 index 0000000..83a1101 --- /dev/null +++ b/touchfluffytail.club/patches/@atproto-labs%2Ffetch@0.3.6.patch @@ -0,0 +1,30 @@ +diff --git a/dist/fetch-response.js b/dist/fetch-response.js +index 7e58b4a52a7c0c4def2248b7058bec37286a1440..9f2106e6584d0927c72b3468e57f5945dbbcbc2c 100644 +--- a/dist/fetch-response.js ++++ b/dist/fetch-response.js +@@ -76,10 +76,21 @@ export async function peekJson(response, maxSize = Infinity) { + // 1) Clone the request so we can consume the body + const clonedResponse = response.clone(); + // 2) Make sure the request's body is not too large +- const limitedResponse = response.body && maxSize < Infinity +- ? new TransformedResponse(clonedResponse, new MaxBytesTransformStream(maxSize)) +- : // Note: some runtimes (e.g. react-native) don't expose a body property +- clonedResponse; ++ let limitedResponse; ++ if (response.body && maxSize < Infinity) { ++ try { ++ limitedResponse = new TransformedResponse(clonedResponse, new MaxBytesTransformStream(maxSize)); ++ } ++ catch { ++ // react-native's global Response (whatwg-fetch) cannot be subclassed, so ++ // this wrapper cannot be built there; fall back to a fresh clone. ++ limitedResponse = response.clone(); ++ } ++ } ++ else { ++ // Note: some runtimes (e.g. react-native) don't expose a body property ++ limitedResponse = clonedResponse; ++ } + // 3) Parse the JSON + return limitedResponse.json(); + } diff --git a/touchfluffytail.club/src/app/(authenticated)/logout.tsx b/touchfluffytail.club/src/app/(authenticated)/logout.tsx deleted file mode 100644 index 68f9167..0000000 --- a/touchfluffytail.club/src/app/(authenticated)/logout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { View } from "react-native"; - -export default function Logout() { - return ; -} diff --git a/touchfluffytail.club/src/app/_layout.tsx b/touchfluffytail.club/src/app/_layout.tsx index f304c5d..2c4e888 100644 --- a/touchfluffytail.club/src/app/_layout.tsx +++ b/touchfluffytail.club/src/app/_layout.tsx @@ -1,3 +1,4 @@ +import "#/lib/polyfills"; import { SplashScreen, Stack } from "expo-router"; import { useCallback } from "react"; import { View } from "react-native"; diff --git a/touchfluffytail.club/src/app/auth/callback.tsx b/touchfluffytail.club/src/app/auth/callback.tsx index e14b39f..ad54a9a 100644 --- a/touchfluffytail.club/src/app/auth/callback.tsx +++ b/touchfluffytail.club/src/app/auth/callback.tsx @@ -1,23 +1,5 @@ -import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; +import { Redirect } from "expo-router"; export default function AuthCallback() { - return ( - - - finishing sign-in… - - ); + return ; } - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: "center", - justifyContent: "center", - gap: 12, - }, - label: { - fontSize: 14, - color: "#a3a3a3", - }, -}); diff --git a/touchfluffytail.club/src/app/auth/logout.tsx b/touchfluffytail.club/src/app/auth/logout.tsx new file mode 100644 index 0000000..3fe7c95 --- /dev/null +++ b/touchfluffytail.club/src/app/auth/logout.tsx @@ -0,0 +1,50 @@ +import { useAuth } from "#/components/SessionProvider"; + +import { useRouter } from "expo-router"; +import { useEffect } from "react"; + +import { ActivityIndicator, Text, View } from "react-native"; + +export default function Logout() { + const { hasSavedDid, isLoading, requireSession, signOut } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (isLoading) return; + if (!hasSavedDid) { + router.replace("/"); + return; + } + + let active = true; + + const logout = async () => { + try { + await requireSession(); + } catch (error) { + console.warn("Signing out without a live session:", error); + } + + try { + await signOut(); + } catch (error) { + console.error("Failed to sign out:", error); + } finally { + if (active) router.replace("/"); + } + }; + + void logout(); + + return () => { + active = false; + }; + }, [hasSavedDid, isLoading, requireSession, signOut, router]); + + return ( + + + finishing sign-out… + + ); +} diff --git a/touchfluffytail.club/src/app/(authenticated)/scan.tsx b/touchfluffytail.club/src/app/auth/scan.tsx similarity index 50% rename from touchfluffytail.club/src/app/(authenticated)/scan.tsx rename to touchfluffytail.club/src/app/auth/scan.tsx index e955c1c..2ae0a5c 100644 --- a/touchfluffytail.club/src/app/(authenticated)/scan.tsx +++ b/touchfluffytail.club/src/app/auth/scan.tsx @@ -1,14 +1,17 @@ import { useAuth } from "#/components/SessionProvider"; import { useRouter } from "expo-router"; +import { useEffect } from "react"; import { View } from "react-native"; export default function Scan() { const router = useRouter(); - const { isAuthed } = useAuth(); + const { hasSavedDid, isLoading } = useAuth(); - if (!isAuthed) { - router.navigate("/sign-in"); - } + useEffect(() => { + if (isLoading || hasSavedDid) return; + + router.replace("/sign-in"); + }, [hasSavedDid, isLoading, router]); return ; } diff --git a/touchfluffytail.club/src/app/index.tsx b/touchfluffytail.club/src/app/index.tsx index 443e576..8cfe4b9 100644 --- a/touchfluffytail.club/src/app/index.tsx +++ b/touchfluffytail.club/src/app/index.tsx @@ -1,59 +1,20 @@ import NavBar from "#/components/NavBar"; -import { AuthRequiredError, useAuth } from "#/components/SessionProvider"; +import { useAuth } from "#/components/SessionProvider"; import { BlurTargetView, BlurView } from "expo-blur"; import { router } from "expo-router"; import { styled } from "nativewind"; -import { useRef, useState } from "react"; +import { useRef } from "react"; import { Pressable, Text, View } from "react-native"; import "react-native-get-random-values"; import QRcode from "#/components/QRcode"; import { SafeAreaView } from "react-native-safe-area-context"; import "./global.css"; -type VerifiedSession = { - handle: string; - did: string; -}; - export default function Home() { - const { did, status, isAuthed, hasSavedDid, requireSession } = useAuth(); - - const [busy, setBusy] = useState<"session" | "sign-out" | null>(null); - const [verified, setVerified] = useState(null); - const [notice, setNotice] = useState(null); - const [error, setError] = useState(null); + const { hasSavedDid } = useAuth(); const qrTarget = useRef(null); - function report(error: unknown) { - setError(error instanceof Error ? error.message : String(error)); - } - - async function handleCheckSession() { - if (busy) return; - - setBusy("session"); - setError(null); - setNotice(null); - - try { - const liveAgent = await requireSession(); - const { data } = await liveAgent.com.atproto.server.getSession(); - - setVerified({ handle: data.handle, did: data.did }); - setNotice("session verified"); - } catch (sessionError) { - if (sessionError instanceof AuthRequiredError && sessionError.reason === "no-session") { - router.push("/sign-in"); - return; - } - - report(sessionError); - } finally { - setBusy(null); - } - } - return ( @@ -64,7 +25,7 @@ export default function Home() { - {!isAuthed ? ( + {!hasSavedDid ? ( { - await generate_key_pair(handle, signIn); - - if (router.canGoBack()) router.back(); - else router.replace("/"); + await generateKeyPair({ handle, signIn, savePubkeyTid, router }); }} /> @@ -38,31 +38,39 @@ export default function SignIn() { ); } -async function generate_key_pair( - handle: string, - signin: (handle: string, privateKey: Uint8Array) => Promise, -) { +async function generateKeyPair({ + handle, + signIn, + savePubkeyTid, + router, +}: { + handle: string; + signIn: (handle: string, privateKey: Uint8Array) => Promise; + savePubkeyTid: (tid: string) => Promise; + router: ImperativeRouter; +}) { const keys = ml_dsa65.keygen(); const pubkey = keys.publicKey; const privkey = keys.secretKey; - const agent = await signin(handle, privkey); + const agent = await signIn(handle, privkey); const createdAt = new Date().toISOString(); const validTill = new Date(createdAt); validTill.setFullYear(validTill.getFullYear() + 1); const tid = TID.nextStr(); - const record = { + const record: Main = { $type: "club.touchfluffytail.pubkey", CreatedAt: createdAt, - pubkey: Buffer.from(pubkey).toString("base64"), - ValidTill: validTill, + PubKey: Buffer.from(pubkey).toString("hex"), + ValidTill: validTill.toISOString(), }; const result = safeParse(mainSchema, record); if (!result.ok) { throw new Error("How the fuck did we get here luna?"); } + const resp = await agent.com.atproto.repo.putRecord({ repo: agent.assertDid, rkey: tid, @@ -72,4 +80,30 @@ async function generate_key_pair( if (!resp.success) { throw new Error("once more i thought this was not possible"); } + + const resp_validate = await agent.com.atproto.repo.getRecord({ + repo: agent.assertDid, + collection: "club.touchfluffytail.pubkey", + rkey: tid, + }); + + const fetched = resp_validate.data.value; + const validation = safeParse(mainSchema, fetched); + if (!validation.ok) { + throw new Error("Record was written but failed schema validation."); + } + + const savedRecord = validation.value; + + if ( + savedRecord.PubKey !== record.PubKey || + savedRecord.CreatedAt !== record.CreatedAt || + savedRecord.ValidTill !== record.ValidTill + ) { + throw new Error("The repo returned a public key record that differs from the one we wrote."); + } + + await savePubkeyTid(tid); + + router.dismissTo("/"); } diff --git a/touchfluffytail.club/src/components/NavBar.tsx b/touchfluffytail.club/src/components/NavBar.tsx index 2f127fa..b71aa20 100644 --- a/touchfluffytail.club/src/components/NavBar.tsx +++ b/touchfluffytail.club/src/components/NavBar.tsx @@ -6,14 +6,14 @@ import { useAuth } from "./SessionProvider"; export default function NavBar() { const router = useRouter(); - const { isAuthed } = useAuth(); + const { hasSavedDid } = useAuth(); const items: MenuItems[] = [ - ...(isAuthed + ...(hasSavedDid ? [ { title: "Logout", - onpress: () => router.navigate("./(authenticated)/logout"), + onpress: () => router.navigate("./auth/logout"), }, ] : []), @@ -25,22 +25,27 @@ export default function NavBar() { return ( - Touch Fluffy Tail + router.navigate("/auth/scan")} + accessibilityRole="button" + accessibilityLabel="Switch camera view"> + + - - router.navigate("./(authenticated)/scan")} - accessibilityRole="button" - accessibilityLabel="Switch camera view"> - - + + Touch Fluffy Tail + + diff --git a/touchfluffytail.club/src/components/QRcode.tsx b/touchfluffytail.club/src/components/QRcode.tsx index 4dcb94a..c33ad9a 100644 --- a/touchfluffytail.club/src/components/QRcode.tsx +++ b/touchfluffytail.club/src/components/QRcode.tsx @@ -6,7 +6,15 @@ import QRCode from "react-native-qrcode-svg"; import { useAuth } from "./SessionProvider"; const totp = new NitroTotp(); -const PERIOD = 30; +const PERIOD_MS = 30_000; + +function totpWindow(now = Date.now()) { + return Math.floor(now / PERIOD_MS); +} + +function timeLeftInWindow(now = Date.now()) { + return PERIOD_MS - (now % PERIOD_MS); +} type TotpResult = { seed: string; @@ -18,7 +26,6 @@ export default function QRcode() { const [totpResult, setTotpResult] = useState(null); - const [remaining, setRemaining] = useState(PERIOD); const [macId] = useState("Unavailable"); const [error, setError] = useState(null); @@ -28,6 +35,8 @@ export default function QRcode() { if (!seed) return; let active = true; + let shownWindow = totpWindow(); + let frame = 0; const generate = () => { try { @@ -45,27 +54,23 @@ export default function QRcode() { } }; - const updateTimer = () => { - const seconds = Math.floor(Date.now() / 1000); - const left = PERIOD - (seconds % PERIOD); - - if (active) { - setRemaining(left); - } + generate(); + const watch = () => { + const currentWindow = totpWindow(); - if (left === PERIOD) { + if (currentWindow !== shownWindow && active) { + shownWindow = currentWindow; generate(); } - }; - generate(); - updateTimer(); + frame = requestAnimationFrame(watch); + }; - const timer = setInterval(updateTimer, 250); + frame = requestAnimationFrame(watch); return () => { active = false; - clearInterval(timer); + cancelAnimationFrame(frame); }; }, [seed]); @@ -84,28 +89,22 @@ export default function QRcode() { totp: code, }) : ""; - const progress = (PERIOD - remaining) / PERIOD; return ( - Verify Me :3 + Touch Fluffy Tail With Me :3 {error ? ( {error} ) : code ? ( <> - Refreshes in {remaining}s + - - - + ) : ( @@ -122,3 +121,40 @@ export default function QRcode() { ); } + +function TotpCountdown() { + const [secondsLeft, setSecondsLeft] = useState(() => Math.ceil(timeLeftInWindow() / 1000)); + + useEffect(() => { + const timer = setInterval(() => { + setSecondsLeft(Math.ceil(timeLeftInWindow() / 1000)); + }, 200); + + return () => clearInterval(timer); + }, []); + + return Refreshes in {secondsLeft}s; +} + +function TotpProgress() { + const [elapsed, setElapsed] = useState(() => 1 - timeLeftInWindow() / PERIOD_MS); + + useEffect(() => { + let frame = 0; + + const tick = () => { + setElapsed(1 - timeLeftInWindow() / PERIOD_MS); + frame = requestAnimationFrame(tick); + }; + + frame = requestAnimationFrame(tick); + + return () => cancelAnimationFrame(frame); + }, []); + + return ( + + + + ); +} diff --git a/touchfluffytail.club/src/components/SessionProvider.tsx b/touchfluffytail.club/src/components/SessionProvider.tsx index f6d0b31..2c3cc54 100644 --- a/touchfluffytail.club/src/components/SessionProvider.tsx +++ b/touchfluffytail.club/src/components/SessionProvider.tsx @@ -1,12 +1,14 @@ import { oauthClient } from "#/lib/atproto"; import { Agent } from "@atproto/api"; import { TokenRefreshError } from "@atproto/oauth-client-expo"; +import { Buffer } from "@craftzdog/react-native-buffer"; import { deleteItemAsync, getItemAsync, setItemAsync } from "expo-secure-store"; import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from "react"; import { NitroSecret, SecretSize } from "react-native-nitro-totp"; const DID_KEY = "club.touchfluffytail.did"; const PRIVATE_KEY = "club.touchfluffytail.privkey"; +const PUBKEY_TID_KEY = "club.touchfluffytail.pubkey_tid"; const SEED_KEY = "club.touchfluffytail.otp_seed"; const nitroSecret = new NitroSecret(); @@ -32,13 +34,21 @@ type AuthContextType = { hasSavedDid: boolean; seed: string | null; privateKey: Uint8Array | null; + pubkeyTid: string | null; signIn: (handleOrDid: string, privateKey: Uint8Array) => Promise; + savePubkeyTid: (tid: string) => Promise; signOut: () => Promise; requireSession: () => Promise; }; const AuthContext = createContext(undefined); +async function restoreAgent(sub: string): Promise { + const session = await oauthClient.restore(sub); + + return new Agent(session); +} + function toAuthRequiredError(error: unknown): AuthRequiredError { if (error instanceof TokenRefreshError && error.cause === undefined) { return new AuthRequiredError("no-session", "No usable session is stored on this device.", { @@ -59,6 +69,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [isLoading, setIsLoading] = useState(true); const [seed, setSeed] = useState(null); const [privateKey, setPrivateKey] = useState | null>(null); + const [pubkeyTid, setPubkeyTid] = useState(null); useEffect(() => { let active = true; @@ -68,7 +79,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { const savedDid = await getItemAsync(DID_KEY); if (active && savedDid) setDid(savedDid); if (active && savedDid) { - const privateKeyString = await getItemAsync(PRIVATE_KEY); + const [privateKeyString, savedTid] = await Promise.all([ + getItemAsync(PRIVATE_KEY), + getItemAsync(PUBKEY_TID_KEY), + ]); + + if (active && savedTid) setPubkeyTid(savedTid); + if (!privateKeyString) { throw new Error("Unable to find privatekey"); } @@ -110,6 +127,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { await deleteItemAsync(DID_KEY); await deleteItemAsync(PRIVATE_KEY); + await deleteItemAsync(PUBKEY_TID_KEY); await deleteItemAsync(SEED_KEY); }, [did]); @@ -121,8 +139,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } try { - const session = await oauthClient.restore(did); - const nextAgent = new Agent(session); + const nextAgent = await restoreAgent(did); setAgent(nextAgent); @@ -137,6 +154,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const signIn = useCallback( async (handleOrDid: string, privateKey: Uint8Array): Promise => { const session = await oauthClient.signIn(handleOrDid.trim()); + console.log(`Logged in as ${session.did}`); if (did && did !== session.did) await forgetIdentity(); @@ -147,6 +165,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { await setItemAsync(PRIVATE_KEY, privateKeyString); setPrivateKey(privateKey); + // The fresh key pair has no pubkey record yet, so any previously stored + // TID would point at a key this device no longer holds. + await deleteItemAsync(PUBKEY_TID_KEY); + setPubkeyTid(null); + const nextAgent = new Agent(session); setAgent(nextAgent); @@ -155,11 +178,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { [did, forgetIdentity], ); + const savePubkeyTid = useCallback(async (tid: string) => { + await setItemAsync(PUBKEY_TID_KEY, tid); + setPubkeyTid(tid); + }, []); + const signOut = useCallback(async () => { await forgetIdentity(); setAgent(null); setDid(null); setPrivateKey(null); + setPubkeyTid(null); }, [forgetIdentity]); const status: AuthStatus = isLoading @@ -181,7 +210,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { hasSavedDid: did !== null, seed, privateKey, + pubkeyTid, signIn, + savePubkeyTid, signOut, requireSession, }}> diff --git a/touchfluffytail.club/src/lexicons/types/club/touchfluffytail/touchedfluffytail.ts b/touchfluffytail.club/src/lexicons/types/club/touchfluffytail/touchedfluffytail.ts index 2f1f9e3..d228004 100644 --- a/touchfluffytail.club/src/lexicons/types/club/touchfluffytail/touchedfluffytail.ts +++ b/touchfluffytail.club/src/lexicons/types/club/touchfluffytail/touchedfluffytail.ts @@ -15,6 +15,10 @@ const _mainSchema = /*#__PURE__*/ v.record( * signed did of the person verifiying */ "Payload": /*#__PURE__*/ v.string(), + /** + * tid of the public key that should be used for verification + */ + "PubKeyTid": /*#__PURE__*/ v.tidString(), /** * target for irl meetup, did of the user being verified */ diff --git a/touchfluffytail.club/src/lib/polyfills.ts b/touchfluffytail.club/src/lib/polyfills.ts new file mode 100644 index 0000000..a928f0f --- /dev/null +++ b/touchfluffytail.club/src/lib/polyfills.ts @@ -0,0 +1,8 @@ +if ( + typeof AbortSignal !== "undefined" && + typeof AbortSignal.prototype.throwIfAborted !== "function" +) { + AbortSignal.prototype.throwIfAborted = function throwIfAborted() { + if (this.aborted) throw this.reason ?? new Error("This operation was aborted"); + }; +}