Fix Gashboard encrypted chat on HTTP and open the native signer on launch
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
"typecheck": "vue-tsc --noEmit"
|
"typecheck": "vue-tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@noble/ciphers": "0.5.3",
|
||||||
"applesauce-accounts": "5.1.0",
|
"applesauce-accounts": "5.1.0",
|
||||||
"applesauce-signers": "5.1.0",
|
"applesauce-signers": "5.1.0",
|
||||||
"nostr-tools": "2.10.4",
|
"nostr-tools": "2.10.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
|
||||||
import { useAuthStore } from "./stores/auth";
|
import { useAuthStore } from "./stores/auth";
|
||||||
import { useStatsStore } from "./stores/stats";
|
import { useStatsStore } from "./stores/stats";
|
||||||
import ChatDrawer from "./components/ChatDrawer.vue";
|
import ChatDrawer from "./components/ChatDrawer.vue";
|
||||||
@@ -9,6 +9,11 @@ import ReleaseNotesDrawer from "./components/ReleaseNotesDrawer.vue";
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const stats = useStatsStore();
|
const stats = useStatsStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
function logout(): void {
|
||||||
|
auth.logout();
|
||||||
|
void router.push({ name: "login" });
|
||||||
|
}
|
||||||
const crt = ref(false);
|
const crt = ref(false);
|
||||||
const chatOpen = ref(false);
|
const chatOpen = ref(false);
|
||||||
const releaseOpen = ref(false);
|
const releaseOpen = ref(false);
|
||||||
@@ -63,7 +68,7 @@ watch(
|
|||||||
<template v-if="auth.isLoggedIn">
|
<template v-if="auth.isLoggedIn">
|
||||||
<button class="thin" @click="chatOpen = !chatOpen">chat</button>
|
<button class="thin" @click="chatOpen = !chatOpen">chat</button>
|
||||||
<span class="muted">{{ auth.npub ? shortNpub(auth.npub) : "" }}</span>
|
<span class="muted">{{ auth.npub ? shortNpub(auth.npub) : "" }}</span>
|
||||||
<button class="thin" @click="auth.logout()">logout</button>
|
<button class="thin" @click="logout">logout</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const mediaStickers = computed(() => CHAT_STICKERS.filter((sticker) => sticker.i
|
|||||||
const textStickers = computed(() => CHAT_STICKERS.filter((sticker) => !sticker.image));
|
const textStickers = computed(() => CHAT_STICKERS.filter((sticker) => !sticker.image));
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (auth.isLoggedIn) void start();
|
if (props.open && auth.isLoggedIn) void start();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -36,10 +36,12 @@ onUnmounted(() => {
|
|||||||
watch(
|
watch(
|
||||||
() => auth.isLoggedIn,
|
() => auth.isLoggedIn,
|
||||||
(loggedIn) => {
|
(loggedIn) => {
|
||||||
if (loggedIn) void start();
|
if (loggedIn) {
|
||||||
else {
|
if (props.open) void start();
|
||||||
|
} else {
|
||||||
sub?.close();
|
sub?.close();
|
||||||
sub = null;
|
sub = null;
|
||||||
|
chat.clearChatSession();
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
profiles.value = {};
|
profiles.value = {};
|
||||||
reactions.value = {};
|
reactions.value = {};
|
||||||
@@ -50,16 +52,17 @@ watch(
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
(open) => {
|
async (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (!canSend.value) void auth.restoreSavedSigner();
|
if (!canSend.value) await auth.restoreSavedSigner();
|
||||||
|
if (auth.isLoggedIn) await start();
|
||||||
void scrollBottom();
|
void scrollBottom();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
async function start(): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
if (sub) return;
|
if (sub || loading.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { gcm } from "@noble/ciphers/aes";
|
||||||
import type { Event as NostrEvent } from "nostr-tools";
|
import type { Event as NostrEvent } from "nostr-tools";
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
import { SimplePool } from "nostr-tools/pool";
|
import { SimplePool } from "nostr-tools/pool";
|
||||||
@@ -34,9 +35,17 @@ export type ChatProfile = {
|
|||||||
|
|
||||||
let reactionCache: ChatReaction[] = [];
|
let reactionCache: ChatReaction[] = [];
|
||||||
let cachedRecipients: string[] | null = null;
|
let cachedRecipients: string[] | null = null;
|
||||||
let cachedRoomKey: CryptoKey | null = null;
|
let cachedRoomKey: Uint8Array | null = null;
|
||||||
let cachedRoomKeyRaw = "";
|
let cachedRoomKeyRaw = "";
|
||||||
|
|
||||||
|
export function clearChatSession(): void {
|
||||||
|
cachedRoomKey?.fill(0);
|
||||||
|
cachedRoomKey = null;
|
||||||
|
cachedRoomKeyRaw = "";
|
||||||
|
cachedRecipients = null;
|
||||||
|
reactionCache = [];
|
||||||
|
}
|
||||||
|
|
||||||
export function canSendChat(): boolean {
|
export function canSendChat(): boolean {
|
||||||
return hasActiveSigner();
|
return hasActiveSigner();
|
||||||
}
|
}
|
||||||
@@ -164,11 +173,9 @@ async function encryptPayload(plaintext: string): Promise<string> {
|
|||||||
const key = await ensureRoomKey();
|
const key = await ensureRoomKey();
|
||||||
try {
|
try {
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
const ciphertext = await crypto.subtle.encrypt(
|
// WebCrypto is unavailable on LAN HTTP. Noble preserves the same AES-GCM
|
||||||
{ name: "AES-GCM", iv },
|
// wire format (ciphertext followed by the 128-bit authentication tag).
|
||||||
key,
|
const ciphertext = gcm(key, iv).encrypt(new TextEncoder().encode(plaintext));
|
||||||
new TextEncoder().encode(plaintext),
|
|
||||||
);
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
v: 1,
|
v: 1,
|
||||||
alg: "aes-gcm",
|
alg: "aes-gcm",
|
||||||
@@ -192,11 +199,9 @@ async function decryptPayload(value: string): Promise<string> {
|
|||||||
return "[encrypted with unknown chat format]";
|
return "[encrypted with unknown chat format]";
|
||||||
}
|
}
|
||||||
const key = await ensureRoomKey();
|
const key = await ensureRoomKey();
|
||||||
const decrypted = await crypto.subtle.decrypt(
|
const iv = unbase64(envelope.iv);
|
||||||
{ name: "AES-GCM", iv: unbase64(envelope.iv) },
|
if (iv.length !== 12) throw new Error("Invalid chat nonce");
|
||||||
key,
|
const decrypted = gcm(key, iv).decrypt(unbase64(envelope.ciphertext));
|
||||||
unbase64(envelope.ciphertext),
|
|
||||||
);
|
|
||||||
return new TextDecoder().decode(decrypted);
|
return new TextDecoder().decode(decrypted);
|
||||||
} catch {
|
} catch {
|
||||||
return "[encrypted - decrypt failed]";
|
return "[encrypted - decrypt failed]";
|
||||||
@@ -218,7 +223,7 @@ async function getRecipients(): Promise<string[]> {
|
|||||||
return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean);
|
return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureRoomKey(snapshot?: ChatSnapshot): Promise<CryptoKey> {
|
async function ensureRoomKey(snapshot?: ChatSnapshot): Promise<Uint8Array> {
|
||||||
if (cachedRoomKey) return cachedRoomKey;
|
if (cachedRoomKey) return cachedRoomKey;
|
||||||
const signer = getActiveSigner();
|
const signer = getActiveSigner();
|
||||||
if (!signer) throw new Error("Reconnect your signer to unlock private chat");
|
if (!signer) throw new Error("Reconnect your signer to unlock private chat");
|
||||||
@@ -250,12 +255,11 @@ async function publishRoomKey(raw: string): Promise<void> {
|
|||||||
await postChatKeyWraps({ wraps });
|
await postChatKeyWraps({ wraps });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importRoomKey(raw: string): Promise<CryptoKey> {
|
async function importRoomKey(raw: string): Promise<Uint8Array> {
|
||||||
if (cachedRoomKey && cachedRoomKeyRaw === raw) return cachedRoomKey;
|
if (cachedRoomKey && cachedRoomKeyRaw === raw) return cachedRoomKey;
|
||||||
cachedRoomKey = await crypto.subtle.importKey("raw", unbase64(raw), { name: "AES-GCM" }, false, [
|
const key = unbase64(raw);
|
||||||
"encrypt",
|
if (key.length !== 32) throw new Error("Invalid private chat room key");
|
||||||
"decrypt",
|
cachedRoomKey = key;
|
||||||
]);
|
|
||||||
cachedRoomKeyRaw = raw;
|
cachedRoomKeyRaw = raw;
|
||||||
return cachedRoomKey;
|
return cachedRoomKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
const token = ref<string | null>(stored?.token ?? null);
|
const token = ref<string | null>(stored?.token ?? null);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
|
const nativeLoginPending = ref(!stored?.token);
|
||||||
const restoringSigner = ref(false);
|
const restoringSigner = ref(false);
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!token.value);
|
const isLoggedIn = computed(() => !!token.value);
|
||||||
@@ -107,6 +108,7 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function logout(): void {
|
function logout(): void {
|
||||||
|
nativeLoginPending.value = false;
|
||||||
api.clearToken();
|
api.clearToken();
|
||||||
signer.clearSigner();
|
signer.clearSigner();
|
||||||
token.value = null;
|
token.value = null;
|
||||||
@@ -133,6 +135,7 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
error,
|
error,
|
||||||
busy,
|
busy,
|
||||||
restoringSigner,
|
restoringSigner,
|
||||||
|
nativeLoginPending,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
hasActiveSigner,
|
hasActiveSigner,
|
||||||
loginExtension,
|
loginExtension,
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ export type ApiError = { error: { code: string; message: string } };
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
archipelagoNostr?: {
|
||||||
|
selectIdentity(): Promise<unknown>;
|
||||||
|
getSelectedIdentity(): { nostr_pubkey: string } | null;
|
||||||
|
};
|
||||||
nostr?: {
|
nostr?: {
|
||||||
getPublicKey(): Promise<string>;
|
getPublicKey(): Promise<string>;
|
||||||
signEvent(event: {
|
signEvent(event: {
|
||||||
|
|||||||
@@ -9,13 +9,23 @@ const route = useRoute();
|
|||||||
const bunkerUri = ref("");
|
const bunkerUri = ref("");
|
||||||
const showBunkerInput = ref(false);
|
const showBunkerInput = ref(false);
|
||||||
const waitingRemote = ref(false);
|
const waitingRemote = ref(false);
|
||||||
|
const choosingIdentity = ref(false);
|
||||||
|
|
||||||
async function loginExt(): Promise<void> {
|
async function loginExt(initialLaunch = false): Promise<void> {
|
||||||
|
if (auth.busy || choosingIdentity.value) return;
|
||||||
|
choosingIdentity.value = true;
|
||||||
|
auth.error = null;
|
||||||
try {
|
try {
|
||||||
|
const native = window.archipelagoNostr;
|
||||||
|
if (native && (!initialLaunch || !native.getSelectedIdentity())) {
|
||||||
|
await native.selectIdentity();
|
||||||
|
}
|
||||||
await auth.loginExtension();
|
await auth.loginExtension();
|
||||||
void router.push({ name: "dashboard" });
|
void router.push({ name: "dashboard" });
|
||||||
} catch {
|
} catch (error) {
|
||||||
/* surfaced via auth.error */
|
auth.error = error instanceof Error ? error.message : "Sign-in cancelled";
|
||||||
|
} finally {
|
||||||
|
choosingIdentity.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +68,12 @@ async function resumeRemoteApp(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (route.query.remote === "return") void resumeRemoteApp();
|
if (route.query.remote === "return") {
|
||||||
|
void resumeRemoteApp();
|
||||||
|
} else if (window.archipelagoNostr && auth.nativeLoginPending && !auth.isLoggedIn) {
|
||||||
|
auth.nativeLoginPending = false;
|
||||||
|
void loginExt(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -73,14 +88,14 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="options">
|
<div class="options">
|
||||||
<button class="primary big" :disabled="auth.busy" @click="loginExt">
|
<button class="primary big" :disabled="auth.busy || choosingIdentity" @click="loginExt(false)">
|
||||||
{{ auth.busy ? "..." : "sign in with Nostr" }}
|
{{ auth.busy ? "..." : "sign in with Nostr" }}
|
||||||
<small>Archipelago identity or browser extension</small>
|
<small>Archipelago identity or browser extension</small>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="primary big"
|
class="primary big"
|
||||||
:disabled="auth.busy"
|
:disabled="auth.busy || choosingIdentity"
|
||||||
@click="loginRemoteApp"
|
@click="loginRemoteApp"
|
||||||
>
|
>
|
||||||
{{ waitingRemote ? "waiting for signer..." : "open signer app" }}
|
{{ waitingRemote ? "waiting for signer..." : "open signer app" }}
|
||||||
@@ -89,7 +104,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="big"
|
class="big"
|
||||||
:disabled="auth.busy"
|
:disabled="auth.busy || choosingIdentity"
|
||||||
@click="showBunkerInput = !showBunkerInput"
|
@click="showBunkerInput = !showBunkerInput"
|
||||||
>
|
>
|
||||||
bunker URI
|
bunker URI
|
||||||
@@ -104,7 +119,7 @@ onMounted(() => {
|
|||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<button class="primary" :disabled="auth.busy" @click="loginBunker">
|
<button class="primary" :disabled="auth.busy || choosingIdentity" @click="loginBunker">
|
||||||
{{ auth.busy ? "connecting…" : "connect" }}
|
{{ auth.busy ? "connecting…" : "connect" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Generated
+3
@@ -65,6 +65,9 @@ importers:
|
|||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@noble/ciphers':
|
||||||
|
specifier: 0.5.3
|
||||||
|
version: 0.5.3
|
||||||
applesauce-accounts:
|
applesauce-accounts:
|
||||||
specifier: 5.1.0
|
specifier: 5.1.0
|
||||||
version: 5.1.0(typescript@5.7.2)
|
version: 5.1.0(typescript@5.7.2)
|
||||||
|
|||||||
Reference in New Issue
Block a user