diff --git a/packages/app/src/Nip26/index.ts b/packages/app/src/Nip26/index.ts new file mode 100644 index 00000000..89dbaf8d --- /dev/null +++ b/packages/app/src/Nip26/index.ts @@ -0,0 +1,87 @@ +import * as secp from "@noble/secp256k1"; + +import { EventKind, HexKey } from "@snort/nostr"; +import { sha256, unwrap } from "Util"; + +export interface DelegationData { + delegatee: HexKey; + delegator?: HexKey; + sig?: string; + kinds?: Array; + createdBefore?: number; + createdAfter?: number; +} + +export function delegationToQuery(d: DelegationData) { + const opt: Array = []; + if (d.kinds && d.kinds.length > 0) { + opt.push(...d.kinds.map(v => `kind=${v}`)); + } + if (d.createdAfter) { + opt.push(`created_at>${d.createdAfter}`); + } + if (d.createdBefore) { + opt.push(`created_at<${d.createdBefore}`); + } + + if (opt.length === 0) { + throw new Error("Invalid delegation data, no query"); + } + return opt.join("&"); +} + +export function delegationToToken(d: DelegationData) { + return `nostr:delegation:${d.delegatee}:${delegationToQuery(d)}`; +} + +export function parseDelegationToken(s: string) { + const sp = s.split(":"); + if (sp.length !== 4 || sp[0] !== "nostr" || sp[1] !== "delegation") { + throw new Error("Invalid delegation token"); + } + + const delegatee = sp[2]; + const params = sp[3]; + if (delegatee.length !== 64) { + throw new Error("Invalid delegation token, delegatee pubkey must be 64 char hex string"); + } + + const paramsSplit = params.split("&"); + + const ret = { + delegatee, + } as DelegationData; + + for (const x of paramsSplit) { + const xS = x.split(/[<>=]/); + switch (xS[0]) { + case "kind": { + ret.kinds ??= []; + ret.kinds.push(Number(xS[1])); + break; + } + case "created_at": { + const op = x.substring(10, 11); + if (op === ">") { + ret.createdAfter = Number(xS[1]); + } else if (op === "<") { + ret.createdBefore = Number(xS[1]); + } else { + throw new Error("Invalid delegation token, created_at operator must be '<' or '>'"); + } + break; + } + default: { + throw new Error(`Invalid delegation token, unknown param ${xS[0]}`); + } + } + } + + return ret; +} + +export function verifyDelegation(d: DelegationData) { + const token = sha256(delegationToToken(d)); + console.debug(token); + return secp.schnorr.verify(unwrap(d.sig), token, unwrap(d.delegator)); +} diff --git a/packages/app/src/Pages/SettingsPage.tsx b/packages/app/src/Pages/SettingsPage.tsx index 3228314d..875abc09 100644 --- a/packages/app/src/Pages/SettingsPage.tsx +++ b/packages/app/src/Pages/SettingsPage.tsx @@ -9,6 +9,7 @@ import { WalletSettingsRoutes } from "Pages/settings/WalletSettings"; import Nip5ManagePage from "Pages/settings/ManageNip5"; import messages from "./messages"; +import DelegationPage from "./settings/Delegation"; export default function SettingsPage() { const navigate = useNavigate(); @@ -48,5 +49,9 @@ export const SettingsRoutes: RouteObject[] = [ path: "nip5", element: , }, + { + path: "delegation", + element: , + }, ...WalletSettingsRoutes, ]; diff --git a/packages/app/src/Pages/settings/Delegation.tsx b/packages/app/src/Pages/settings/Delegation.tsx new file mode 100644 index 00000000..c3c844f0 --- /dev/null +++ b/packages/app/src/Pages/settings/Delegation.tsx @@ -0,0 +1,136 @@ +import { delegationToToken, parseDelegationToken, verifyDelegation } from "Nip26"; +import { useEffect, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useDispatch, useSelector } from "react-redux"; +import { setDelegation } from "State/Login"; +import { RootState } from "State/Store"; + +export default function DelegationPage() { + const dispatch = useDispatch(); + const [from, setFrom] = useState(new Date().toISOString().split("Z")[0]); + const [to, setTo] = useState(new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 30).toISOString().split("Z")[0]); + + const delegation = useSelector((s: RootState) => s.login.delegation); + const [newToken, setNewToken] = useState(""); + const [newDelegator, setNewDelegator] = useState(""); + const [newSig, setNewSig] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + if (delegation) { + setNewToken(delegationToToken(delegation)); + setNewDelegator(delegation.delegator ?? ""); + setNewSig(delegation.sig ?? ""); + } + }, [delegation]); + + async function trySetDelegation() { + try { + setError(""); + const parsedToken = parseDelegationToken(newToken); + parsedToken.sig = newSig; + parsedToken.delegator = newDelegator; + console.debug(parsedToken); + if (!(await verifyDelegation(parsedToken))) { + throw new Error("Invalid delegation, invalid sig"); + } + + dispatch(setDelegation(parsedToken)); + } catch (e) { + setError((e as Error).message); + } + } + + return ( + <> +

+ +

+
+
+ +
+
+ setNewToken(e.target.value)} + /> +
+
+
+
+ +
+
+ setNewDelegator(e.target.value)} /> +
+
+
+
+ +
+
+ setNewSig(e.target.value)} /> +
+
+ + {error && {error}} +
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ setFrom(e.target.value)} /> +
+
+
+
+ +
+
+ setTo(e.target.value)} /> +
+
+ + + ); +} diff --git a/packages/app/src/Pages/settings/Index.tsx b/packages/app/src/Pages/settings/Index.tsx index 678758e6..728b5778 100644 --- a/packages/app/src/Pages/settings/Index.tsx +++ b/packages/app/src/Pages/settings/Index.tsx @@ -52,6 +52,11 @@ const SettingsIndex = () => { +
navigate("delegation")}> + + + +
diff --git a/packages/app/src/State/Login.ts b/packages/app/src/State/Login.ts index cb250461..b4b14103 100644 --- a/packages/app/src/State/Login.ts +++ b/packages/app/src/State/Login.ts @@ -8,6 +8,7 @@ import type { AppDispatch, RootState } from "State/Store"; import { ImgProxySettings } from "Hooks/useImgProxy"; import { sanitizeRelayUrl, unwrap } from "Util"; import { DmCache } from "Cache"; +import { DelegationData } from "Nip26"; const PrivateKeyItem = "secret"; const PublicKeyItem = "pubkey"; @@ -205,6 +206,11 @@ export interface LoginStore { * Users cusom preferences */ preferences: UserPreferences; + + /** + * NIP-26 Delegation information + */ + delegation?: DelegationData; } export const DefaultImgProxy = { @@ -465,6 +471,9 @@ const LoginSlice = createSlice({ state.preferences = action.payload; window.localStorage.setItem(UserPreferencesKey, JSON.stringify(state.preferences)); }, + setDelegation: (state, action: PayloadAction) => { + state.delegation = action.payload; + }, }, }); @@ -487,6 +496,7 @@ export const { markNotificationsRead, setLatestNotifications, setPreferences, + setDelegation, } = LoginSlice.actions; export function sendNotification({ diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 4474f2fe..be253222 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -275,6 +275,7 @@ textarea { input[type="text"], input[type="password"], input[type="number"], +input[type="datetime-local"], select, textarea { padding: 12px; @@ -293,7 +294,7 @@ textarea { border: 1px solid rgba(0, 0, 0, 0.3); } -option, +select:not([multiple]) > option, optgroup { background-color: var(--bg-color); }