This commit is contained in:
Keep Creating Online
2025-02-02 16:12:29 -05:00
parent 4112a2d138
commit 2628696c51

View File

@@ -9,7 +9,7 @@
margin: 20px;
max-width: 600px;
color: #fff;
background: transparent; /* transparent background */
background: transparent;
}
label {
display: inline-block;
@@ -24,8 +24,8 @@
padding: 6px;
margin-bottom: 16px;
box-sizing: border-box;
background-color: #234566; /* dark blue */
color: #fff; /* white text */
background-color: #234566;
color: #fff;
border: 1px solid #888;
}
button {
@@ -50,6 +50,7 @@
</head>
<body>
<h1>Bitvid Content Appeals Form</h1>
<form id="appeal-form">
<h3>1. User Information</h3>
<label for="npub">Nostr Public Key (npub)</label>
@@ -175,6 +176,7 @@
<button type="submit">Submit Appeal</button>
</form>
<p>
<strong>Processing Time:</strong> Appeals will be reviewed within 7-14
days.
@@ -183,11 +185,478 @@
<div id="status-message"></div>
<script>
/***********************************
* Minimal "nostr-tools" subset
***********************************/
/********************************************************************
* Minimal single-file solution that:
* 1) Generates ephemeral secp256k1 keys (sender).
* 2) Uses standard NIP-04 encryption (AES-256-CBC, base64).
* 3) Publishes kind=4 events to chosen relays.
********************************************************************/
// Simple bech32 decoding for npub => hex
/********************************************************************
* Noble Secp256k1 - stripped-down code from https://github.com/paulmillr/noble-secp256k1
* for ECDH and signing. This is a partial inline library (not full).
********************************************************************/
(function () {
// We'll store the main methods we need in an object:
window.nobleSecp256k1 = {};
const _0n = BigInt(0);
const _1n = BigInt(1);
const _2n = BigInt(2);
const _3n = BigInt(3);
const _8n = BigInt(8);
const CURVE = {
P: BigInt(
"0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"
),
n: BigInt(
"0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"
),
Gx: BigInt(
"55066263022277343669578718895168534326250603453777594175500187360389116729240"
),
Gy: BigInt(
"32670510020758816978083085130507043184471273380659243275938904335757337482424"
),
};
const pow2_256 = _1n << BigInt(256);
function mod(a, b = CURVE.P) {
const result = a % b;
return result >= _0n ? result : b + result;
}
function invert(number, modulo = CURVE.P) {
// Binary Extended Euclidian Algorithm
// https://brilliant.org/wiki/extended-euclidean-algorithm/
// returns x where (x*number)%modulo==1
if (number === _0n || modulo <= _0n) {
throw new Error("invert: wrong inputs");
}
let a = mod(number, modulo);
let b = modulo;
// prettier-ignore
let x0 = _0n, x1 = _1n;
while (a !== _0n) {
const q = b / a;
const r = b % a;
const x2 = x0 - q * x1;
b = a;
a = r;
x0 = x1;
x1 = x2;
}
if (b !== _1n) throw new Error("invert: does not exist");
return mod(x0, modulo);
}
function bytesToNumber(bytes) {
return BigInt(
"0x" +
[...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")
);
}
function numberTo32Bytes(num) {
const hex = num.toString(16).padStart(64, "0");
const len = hex.length / 2;
const u8 = new Uint8Array(len);
for (let i = 0; i < len; i++) {
u8[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return u8;
}
function isWithinCurveOrder(num) {
return _0n < num && num < CURVE.n;
}
// Simplified point addition/EC math
class Point {
constructor(px, py) {
this.x = px;
this.y = py;
}
static fromPrivateKey(privKey) {
const p = bytesToNumber(privKey);
if (!isWithinCurveOrder(p)) throw new Error("Invalid private key");
return Point.BASE.multiply(p);
}
// secp256k1 scalar multiplication
multiply(scalar) {
if (!isWithinCurveOrder(scalar)) {
throw new Error("Point#multiply: invalid scalar");
}
let n = scalar;
if (n === _0n) return Point.ZERO;
let p = Point.ZERO;
let d = this;
while (n > _0n) {
if ((n & _1n) === _1n) p = p.add(d);
d = d.double();
n >>= _1n;
}
return p;
}
double() {
const X1 = this.x;
const Y1 = this.y;
const a = mod(_3n * X1 * X1);
const inv = invert(_2n * Y1, CURVE.P);
const x3 = mod(a * a * inv * inv - _2n * X1);
const y3 = mod(a * (X1 - x3) * inv - Y1);
return new Point(x3, y3);
}
add(other) {
if (this.equals(Point.ZERO)) return other;
if (other.equals(Point.ZERO)) return this;
if (this.x === other.x) {
if (this.y !== other.y) return Point.ZERO;
return this.double();
}
const inv = invert(other.x - this.x, CURVE.P);
const slope = mod((other.y - this.y) * inv);
const x3 = mod(slope * slope - this.x - other.x);
const y3 = mod(slope * (this.x - x3) - this.y);
return new Point(x3, y3);
}
equals(other) {
return this.x === other.x && this.y === other.y;
}
toRawBytes(isCompressed = false) {
if (!isCompressed) {
// 0x04 + x + y
return new Uint8Array([
4,
...numberTo32Bytes(this.x),
...numberTo32Bytes(this.y),
]);
}
// 0x02 || 0x03 + x
const header = this.y & _1n ? 0x03 : 0x02;
return new Uint8Array([header, ...numberTo32Bytes(this.x)]);
}
static fromXY(x, y) {
return new Point(x, y);
}
}
Point.BASE = (function () {
return new Point(CURVE.Gx, CURVE.Gy);
})();
Point.ZERO = new Point(_0n, _0n);
// getSharedSecret and sign
function getSharedSecret(privKey, pubBytes) {
const px = pubBytesToPoint(pubBytes);
const ib = bytesToNumber(privKey);
const hash = px.multiply(ib);
return numberTo32Bytes(hash.x);
}
function pubBytesToPoint(bytes) {
if (bytes[0] === 0x04 && bytes.length === 65) {
const x = bytesToNumber(bytes.slice(1, 33));
const y = bytesToNumber(bytes.slice(33, 65));
return new Point(x, y);
} else if (
(bytes[0] === 0x02 || bytes[0] === 0x03) &&
bytes.length === 33
) {
const x = bytesToNumber(bytes.slice(1));
// We find y via formula y^2 = x^3 + 7 mod p
const y2 = mod(x * x * x + _7n, CURVE.P);
let y = powMod(y2, (CURVE.P + _1n) / _4n, CURVE.P);
const isOdd = (y & _1n) === _1n;
const wantOdd = bytes[0] === 0x03;
if (isOdd !== wantOdd) y = mod(-y, CURVE.P);
return new Point(x, y);
}
throw new Error("Unsupported compressed pubkey format");
}
// exponent
const _7n = BigInt(7);
const _4n = BigInt(4);
function powMod(a, e, m) {
let r = _1n;
let b = a;
while (e > 0) {
if (e & _1n) r = mod(r * b, m);
b = mod(b * b, m);
e >>= _1n;
}
return r;
}
async function sign(msgHash, privKey) {
// We won't implement a full RFC6979 here for brevity
// We'll do a simple ephemeral k = random approach. For production, use RFC6979 or a stable approach.
const d0 = bytesToNumber(privKey);
if (!isWithinCurveOrder(d0)) {
throw new Error("sign: invalid privkey");
}
// parse msgHash
const e = bytesToNumber(msgHash);
// ephemeral k
let k = bytesToNumber(await randomBytes(32));
while (!isWithinCurveOrder(k)) {
k = bytesToNumber(await randomBytes(32));
}
const R = Point.BASE.multiply(k);
const r = mod(R.x, CURVE.n);
if (r === _0n) return null;
const s = mod(invert(k, CURVE.n) * (e + r * d0), CURVE.n);
if (s === _0n) return null;
const sig = new Uint8Array(64);
// r, s each 32 bytes
sig.set(numberTo32Bytes(r), 0);
sig.set(numberTo32Bytes(s), 32);
return sig;
}
async function randomBytes(length) {
const arr = new Uint8Array(length);
crypto.getRandomValues(arr);
return arr;
}
function getPublicKey(privKey, compressed = true) {
const p = Point.fromPrivateKey(privKey);
return p.toRawBytes(compressed);
}
// Expose minimal API
window.nobleSecp256k1.getSharedSecret = getSharedSecret;
window.nobleSecp256k1.sign = sign;
window.nobleSecp256k1.getPublicKey = getPublicKey;
window.nobleSecp256k1.randomBytes = randomBytes;
})();
/********************************************************************
* NIP-04 encryption with ephemeral ephemeral keys:
* We'll do AES-256-CBC with random IV, base64-encode "ciphertext?iv".
********************************************************************/
async function nip04Encrypt(privKey, recipientPubKeyHex, plaintext) {
// 1) convert recipient hex => raw pubkey bytes
const recPubBin = hexToBytes(recipientPubKeyHex);
// 2) ECDH => 32-byte shared secret
const sharedKey = window.nobleSecp256k1.getSharedSecret(
privKey,
recPubBin
);
// remove leading 0 byte
const shReduced = sharedKey.slice(1);
// 3) import as AES key
const key = await crypto.subtle.importKey(
"raw",
shReduced,
{ name: "AES-CBC" },
false,
["encrypt"]
);
// 4) create random IV
const iv = new Uint8Array(16);
crypto.getRandomValues(iv);
// 5) AES-CBC
const enc = new TextEncoder();
const cipherBuffer = await crypto.subtle.encrypt(
{ name: "AES-CBC", iv },
key,
enc.encode(plaintext)
);
const cipherBytes = new Uint8Array(cipherBuffer);
const cipherBase64 = b64encode(cipherBytes);
const ivBase64 = b64encode(iv);
// final string is "cipher?iv"
return `${cipherBase64}?${ivBase64}`;
}
function hexToBytes(hex) {
if (hex.startsWith("0x")) hex = hex.slice(2);
const len = hex.length / 2;
const out = new Uint8Array(len);
for (let i = 0; i < len; i++) {
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
function bytesToHex(uint8a) {
let hex = "";
for (let i = 0; i < uint8a.length; i++) {
hex += uint8a[i].toString(16).padStart(2, "0");
}
return hex;
}
function b64encode(data) {
return btoa(String.fromCharCode(...data));
}
/********************************************************************
* getEventHash: standard Nostr ID for an event
********************************************************************/
async function getEventHash(evt) {
// We'll build the array per NIP-01
const enc = new TextEncoder();
const payload = JSON.stringify([
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags,
evt.content,
]);
const digest = await crypto.subtle.digest(
"SHA-256",
enc.encode(payload)
);
return bytesToHex(new Uint8Array(digest));
}
/********************************************************************
* signEvent: sign with ephemeral privkey
********************************************************************/
async function signEvent(evt, privKey) {
const idHex = await getEventHash(evt);
const idBytes = hexToBytes(idHex);
const signature = await window.nobleSecp256k1.sign(idBytes, privKey);
evt.id = idHex; // store the final ID
evt.sig = bytesToHex(signature);
}
/********************************************************************
* Minimal "SimplePool": open websockets to each relay, send event
********************************************************************/
class SimplePool {
constructor() {
this.conns = {};
}
async connectRelay(url) {
return new Promise((resolve, reject) => {
if (
this.conns[url] &&
this.conns[url].readyState === WebSocket.OPEN
) {
resolve(this.conns[url]);
return;
}
const ws = new WebSocket(url);
ws.onopen = () => {
this.conns[url] = ws;
resolve(ws);
};
ws.onerror = (err) => {
reject(err);
};
});
}
async publish(urls, event) {
return Promise.all(
urls.map(async (url) => {
const ws = await this.connectRelay(url);
ws.send(JSON.stringify(["EVENT", event]));
})
);
}
}
/********************************************************************
* On form submit:
* 1) generate ephemeral key
* 2) for each target npub => do standard nip04 => build kind=4 => sign => publish
********************************************************************/
const targetNpubs = [
// Example moderators:
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe",
];
const relayUrls = {
"wss://relay.snort.social": true,
"wss://relay.damus.io": true,
"wss://relay.primal.net": true,
};
const form = document.getElementById("appeal-form");
const statusEl = document.getElementById("status-message");
const pool = new SimplePool();
form.addEventListener("submit", async (evt) => {
evt.preventDefault();
statusEl.textContent = "";
// Gather form fields
const formData = new FormData(form);
const dataObject = {};
formData.forEach((val, key) => {
dataObject[key] = val.trim();
});
// 1) Generate ephemeral keys
const ephemeralPriv = await window.nobleSecp256k1.randomBytes(32);
// Public key in compressed form
const ephemeralPubBytes = window.nobleSecp256k1.getPublicKey(
ephemeralPriv,
true
);
const ephemeralPubHex = bytesToHex(ephemeralPubBytes);
let overallSuccess = false;
// convert entire form data to text
const contentText = JSON.stringify(dataObject, null, 2);
// For each mod npub, build a new event
for (const modNpub of targetNpubs) {
try {
// decode modNpub => hex
const modHex = decodeNpubToHex(modNpub);
// do nip04 encryption with ephemeral => mod
const ciphertext = await nip04Encrypt(
ephemeralPriv,
modHex,
contentText
);
// build event
const now = Math.floor(Date.now() / 1000);
const event = {
kind: 4, // DM
pubkey: ephemeralPubHex, // ephemeral pubkey in hex
created_at: now,
tags: [["p", modHex]],
content: ciphertext,
};
// sign
await signEvent(event, ephemeralPriv);
// publish
try {
await pool.publish(Object.keys(relayUrls), event);
overallSuccess = true;
statusEl.textContent += `Published to ${modNpub}\n`;
} catch (pubErr) {
statusEl.textContent += `Failed to publish to ${modNpub}: ${pubErr}\n`;
}
} catch (err) {
statusEl.textContent += `Error sending to ${modNpub}: ${err}\n`;
}
}
if (overallSuccess) {
alert("Appeal submitted successfully.");
form.reset();
} else {
alert("Submission encountered errors. Check status messages above.");
}
});
/********************************************************************
* decodeNpubToHex: minimal bech32 decode from your prior snippet
********************************************************************/
function decodeNpubToHex(npub) {
const { hrp, data } = bech32Decode(npub);
if (hrp !== "npub") throw new Error("Not an npub");
const converted = convertBits(data, 5, 8, false);
return bytesToHex(new Uint8Array(converted));
}
const ALPHABET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
function polymod(values) {
let chk = 1;
@@ -247,8 +716,8 @@
function convertBits(data, inBits, outBits, pad = true) {
let value = 0;
let bits = 0;
const result = [];
const maxV = (1 << outBits) - 1;
const result = [];
for (let i = 0; i < data.length; i++) {
value = (value << inBits) | data[i];
bits += inBits;
@@ -268,230 +737,6 @@
}
return result;
}
function decodeNpubToHex(npub) {
const { hrp, data } = bech32Decode(npub);
if (hrp !== "npub") {
throw new Error("Not an npub");
}
const converted = convertBits(data, 5, 8, false);
return bytesToHex(new Uint8Array(converted));
}
function bytesToHex(uint8a) {
let hex = "";
for (let i = 0; i < uint8a.length; i++) {
const b = uint8a[i];
hex += b.toString(16).padStart(2, "0");
}
return hex;
}
// Simple SHA-256 for event hashing
// Here we'll rely on browser's crypto.subtle
async function sha256Hex(msgUint8) {
const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
const hashArray = new Uint8Array(hashBuffer);
let hex = "";
for (let i = 0; i < hashArray.length; i++) {
hex += hashArray[i].toString(16).padStart(2, "0");
}
return hex;
}
// Helper to build event.id
// This is the "serialize" approach in NIP-01
async function getEventHash(evt) {
const { kind, pubkey, created_at, tags, content } = evt;
const payload = JSON.stringify([
0,
pubkey,
created_at,
kind,
tags,
content,
]);
const enc = new TextEncoder();
const encodedPayload = enc.encode(payload);
return await sha256Hex(encodedPayload);
}
// Minimal "SimplePool" that can publish events
class SimplePool {
constructor() {
this.conns = {};
}
async connectRelay(url) {
return new Promise((resolve, reject) => {
if (
this.conns[url] &&
this.conns[url].readyState === WebSocket.OPEN
) {
resolve(this.conns[url]);
return;
}
const ws = new WebSocket(url);
ws.onopen = () => {
this.conns[url] = ws;
resolve(ws);
};
ws.onerror = (err) => {
reject(err);
};
});
}
async publish(urls, event) {
// For each URL, ensure a WS connection, then send
return Promise.all(
urls.map(async (url) => {
try {
const ws = await this.connectRelay(url);
const msg = ["EVENT", event];
ws.send(JSON.stringify(msg));
} catch (err) {
throw new Error(`Failed to publish to ${url}: ${err.message}`);
}
})
);
}
}
/******************************************
* Our "fakeEncrypt" to match your app
******************************************/
function fakeEncrypt(str) {
return str.split("").reverse().join("");
}
/******************************************
* NostrClient - like in your app
******************************************/
class NostrClient {
constructor(relayUrls) {
this.relayUrls = relayUrls;
this.pool = new SimplePool();
this.pubkey = null; // will store the extension's pubkey
}
// Connect is trivial here; we do it on publish instead
async init() {
// no-op for now
}
// Attempt to get pubkey from extension
async login() {
if (!window.nostr) {
throw new Error(
"No window.nostr found. Please install a Nostr extension."
);
}
const pubkey = await window.nostr.getPublicKey();
this.pubkey = pubkey;
return pubkey;
}
/**
* Publish a kind=4 DM using "fake encryption".
* This is not secure. Replace with actual encryption if needed.
*/
async sendAppealDM(targetNpub, formDataObj) {
if (!this.pubkey) {
throw new Error("You must login first (no pubkey).");
}
// Convert target npub => hex
let targetHex;
try {
targetHex = decodeNpubToHex(targetNpub);
} catch (err) {
throw new Error("Invalid target npub: " + err.message);
}
// Fake-encrypt the entire JSON
const jsonStr = JSON.stringify(formDataObj, null, 2);
const ciphertext = fakeEncrypt(jsonStr);
// Build the event
const now = Math.floor(Date.now() / 1000);
const event = {
kind: 4,
pubkey: this.pubkey,
created_at: now,
tags: [["p", targetHex]],
content: ciphertext,
};
// We need to hash the event, then sign with extension
event.id = await getEventHash(event);
event.sig = await window.nostr.signEvent(event);
// Publish to all configured relays
await this.pool.publish(Object.keys(this.relayUrls), event);
return event;
}
}
/******************************************
* Main code
******************************************/
// Configure your target npubs and relays:
const targetNpubs = [
// e.g. Moderation accounts:
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe",
];
const relayUrls = {
"wss://relay.snort.social": true,
"wss://relay.damus.io": true,
"wss://relay.primal.net": true,
};
const form = document.getElementById("appeal-form");
const statusEl = document.getElementById("status-message");
// Create a local client
const nostrClient = new NostrClient(relayUrls);
// We won't call .init() for now, since we connect on publish if needed.
form.addEventListener("submit", async (evt) => {
evt.preventDefault();
statusEl.textContent = "";
// Gather form fields
const data = new FormData(form);
const appealData = {};
for (const [key, val] of data.entries()) {
appealData[key] = val.trim();
}
// If user is not logged in, attempt login
if (!nostrClient.pubkey) {
try {
await nostrClient.login();
} catch (err) {
statusEl.textContent =
"Failed to login via extension: " + err.message;
return;
}
}
let anySuccess = false;
// For each target mod npub, send the DM
for (const modNpub of targetNpubs) {
try {
await nostrClient.sendAppealDM(modNpub, appealData);
statusEl.textContent += `Sent DM to ${modNpub}\n`;
anySuccess = true;
} catch (err) {
const msg = `Failed to send DM to ${modNpub}: ${err.message}`;
console.error(msg);
statusEl.textContent += msg + "\n";
}
}
if (anySuccess) {
alert("Appeal submitted successfully to at least one mod npub.");
form.reset();
} else {
alert("Could not submit to any target moderators. Check logs above.");
}
});
</script>
</body>
</html>