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");
+ };
+}