mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
Phase 0 — crypto bundle for NWC
Bundle @noble/secp256k1 v3.1.0 + @noble/hashes v2.2.0 as an IIFE
(noble-bundle.min.js, 15.8 KB) and load it before the main script.
The bundle exposes window.ArtstrNoble with schnorr, getPublicKey,
getSharedSecret, sha256, hex/randomBytes utils, plus secpUtils.
(noble v3 keeps hash functions external; the bundle wires
hashes.sha256 + hashes.hmacSha256 so schnorr.sign / verify work.)
On top of that, add an `NWC` IIFE in the main script with:
- sharedSecret(privHex, peerPubHex) — NIP-04 32-byte ECDH x-coord
(handles x-only 32-byte and full 33-byte pubkey hex).
- encrypt04 / decrypt04 — AES-256-CBC via crypto.subtle, 16-byte
random IV, `<base64ct>?iv=<base64iv>` wire format.
- getEventHash + signEventWithKey — NIP-01 event hashing + schnorr
signing using a *passed-in* hex privkey (the NWC connection's
secret, not the user's identity).
- verifyEvent — for kind-9735 receipt verification later.
- getPubkeyHex — schnorr x-only pubkey from a hex privkey.
Phase-0 self-test runs once at load: BIP-340 test vector #1 derives
the expected x-only pubkey AND verifies the published signature
through window.ArtstrNoble.schnorr; a NIP-04 round-trip encrypts
+ decrypts a known plaintext between two random keypairs; a
synthetic Nostr event is signed and verified through our helpers.
All checks pass silently; any failure logs a single console.warn
so the user sees "Crypto self-test failed — NWC features will not
work" if a bad bundle ever ships.
Verified via CDP:
- derivedPub matches BIP-340 vector 1: dff1d77f...502ba659
- sigVerify on the vector signature: true
- nip04Roundtrip: true
- eventSigned + eventVerify: true
- No console errors loading the app; existing custom-art editor
smoke test (add a color layer, render) still works.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
3e75b562a0
commit
7eae78e909
2 files changed
+167
No files matched your search
+161
@@ -2692,6 +2692,26 @@
|
||||
body.dark .layoutTab.active, body.dark button.primary { color:#fff; }
|
||||
.sheetWrap{margin:0 auto;}
|
||||
</style>
|
||||
<!--
|
||||
Bundled @noble/secp256k1 + @noble/hashes subset. Used by the NWC
|
||||
(NIP-47) wallet client — that flow needs schnorr signing + secp256k1
|
||||
ECDH with the NWC connection's secret key (not the user's identity
|
||||
key), which window.nostr can't do. Exposes `window.ArtstrNoble`.
|
||||
|
||||
To rebuild after a noble upgrade:
|
||||
cd /tmp && mkdir -p noble-fetch && cd noble-fetch
|
||||
npm init -y
|
||||
npm install @noble/secp256k1@<v> @noble/hashes@<v>
|
||||
cat > entry.js <<'EOF'
|
||||
import { schnorr, getPublicKey, getSharedSecret, utils as secpUtils } from '@noble/secp256k1';
|
||||
import { sha256 } from '@noble/hashes/sha2.js';
|
||||
import { hexToBytes, bytesToHex, randomBytes } from '@noble/hashes/utils.js';
|
||||
window.ArtstrNoble = { schnorr, getPublicKey, getSharedSecret, sha256, hexToBytes, bytesToHex, randomBytes, secpUtils };
|
||||
EOF
|
||||
npx esbuild entry.js --bundle --format=iife --minify --target=es2020 --outfile=noble-bundle.min.js
|
||||
cp noble-bundle.min.js <repo>/src/
|
||||
-->
|
||||
<script src="noble-bundle.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nostrWidget" id="nostrWidget">
|
||||
@@ -4387,6 +4407,147 @@
|
||||
const safe$ = (id) => { try { return $(id); } catch { return null; } };
|
||||
const on = (id, type, fn) => { const el = $(id); if (el) el.addEventListener(type, fn); };
|
||||
|
||||
// ---- NWC crypto (Phase 0) ------------------------------------------------
|
||||
// The NIP-47 (Nostr Wallet Connect) flow signs / encrypts as a *different*
|
||||
// pubkey than the user's identity — the NWC URI carries its own secret key.
|
||||
// window.nostr can only operate on the user's identity, so we need a
|
||||
// bundled secp256k1 + NIP-04 implementation. The bundle (noble-bundle.min.js,
|
||||
// loaded above) provides schnorr + ECDH + sha256; we layer NIP-04 (AES-256
|
||||
// -CBC via crypto.subtle) and a tiny event-signing helper on top.
|
||||
const NWC = (() => {
|
||||
const N = () => window.ArtstrNoble;
|
||||
|
||||
const utf8ToBytes = (s) => new TextEncoder().encode(s);
|
||||
const bytesToUtf8 = (b) => new TextDecoder().decode(b);
|
||||
const bytesToBase64 = (b) => {
|
||||
let s = ''; for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s);
|
||||
};
|
||||
const base64ToBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const hexToBytes = (h) => N().hexToBytes(h);
|
||||
const bytesToHex = (b) => N().bytesToHex(b);
|
||||
|
||||
// NIP-04: shared secret is the 32-byte X coord of ECDH(priv, pub).
|
||||
// noble's getSharedSecret returns a 33-byte compressed point — slice the
|
||||
// 02/03 prefix off.
|
||||
function sharedSecret(privHex, pubHex) {
|
||||
const priv = hexToBytes(privHex);
|
||||
// Pub on the wire is x-only (32 bytes). secp256k1 wants the full point;
|
||||
// prepend 02 to lift the x to the even-Y point on the curve.
|
||||
let pub;
|
||||
if (pubHex.length === 64) pub = hexToBytes('02' + pubHex);
|
||||
else if (pubHex.length === 66) pub = hexToBytes(pubHex);
|
||||
else throw new Error('sharedSecret: pubkey must be 32 or 33 bytes hex');
|
||||
const compressed = N().getSharedSecret(priv, pub);
|
||||
return compressed.slice(1, 33);
|
||||
}
|
||||
|
||||
// NIP-04 encrypt: AES-256-CBC with PKCS#7 padding (Web Crypto default),
|
||||
// 16-byte random IV. Output: `<base64(ct)>?iv=<base64(iv)>`.
|
||||
async function encrypt04(privHex, peerPubHex, plaintext) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw', sharedSecret(privHex, peerPubHex),
|
||||
{ name: 'AES-CBC' }, false, ['encrypt']
|
||||
);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt(
|
||||
{ name: 'AES-CBC', iv }, key, utf8ToBytes(plaintext)
|
||||
));
|
||||
return bytesToBase64(ct) + '?iv=' + bytesToBase64(iv);
|
||||
}
|
||||
|
||||
async function decrypt04(privHex, peerPubHex, content) {
|
||||
const [ctB64, ivPart] = String(content).split('?iv=');
|
||||
if (!ctB64 || !ivPart) throw new Error('decrypt04: malformed NIP-04 payload');
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw', sharedSecret(privHex, peerPubHex),
|
||||
{ name: 'AES-CBC' }, false, ['decrypt']
|
||||
);
|
||||
const pt = new Uint8Array(await crypto.subtle.decrypt(
|
||||
{ name: 'AES-CBC', iv: base64ToBytes(ivPart) }, key, base64ToBytes(ctB64)
|
||||
));
|
||||
return bytesToUtf8(pt);
|
||||
}
|
||||
|
||||
// NIP-01 event id = sha256(serialized([0, pubkey, created_at, kind, tags, content]))
|
||||
function getEventHash(event) {
|
||||
const serial = JSON.stringify([
|
||||
0, event.pubkey, event.created_at, event.kind, event.tags || [], event.content || ''
|
||||
]);
|
||||
return bytesToHex(N().sha256(utf8ToBytes(serial)));
|
||||
}
|
||||
|
||||
// Sign an event with a hex privkey (NOT the user's identity key — this
|
||||
// is for NWC's wallet-side keypair). Returns the event with id + sig + pubkey.
|
||||
function signEventWithKey(event, privHex) {
|
||||
const priv = hexToBytes(privHex);
|
||||
const pubkey = bytesToHex(N().schnorr.getPublicKey(priv));
|
||||
const draft = { ...event, pubkey, tags: event.tags || [], content: event.content || '' };
|
||||
const id = getEventHash(draft);
|
||||
const sig = bytesToHex(N().schnorr.sign(hexToBytes(id), priv));
|
||||
return { ...draft, id, sig };
|
||||
}
|
||||
|
||||
// Verify a Nostr event signature. Used for relay-fast-path receipt
|
||||
// verification later in Phase 3.
|
||||
function verifyEvent(event) {
|
||||
try {
|
||||
if (!event?.id || !event?.sig || !event?.pubkey) return false;
|
||||
if (getEventHash(event) !== event.id) return false;
|
||||
return N().schnorr.verify(hexToBytes(event.sig), hexToBytes(event.id), hexToBytes(event.pubkey));
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Derive the schnorr x-only pubkey for a hex privkey.
|
||||
function getPubkeyHex(privHex) {
|
||||
return bytesToHex(N().schnorr.getPublicKey(hexToBytes(privHex)));
|
||||
}
|
||||
|
||||
return {
|
||||
utf8ToBytes, bytesToUtf8, bytesToBase64, base64ToBytes,
|
||||
hexToBytes, bytesToHex,
|
||||
sharedSecret, encrypt04, decrypt04,
|
||||
getEventHash, signEventWithKey, verifyEvent, getPubkeyHex,
|
||||
};
|
||||
})();
|
||||
|
||||
// Phase-0 self-test (runs once at load). BIP-340 test vector index 1 for
|
||||
// schnorr signing + a NIP-04 round-trip. Logs a single warning if anything
|
||||
// breaks; silent on success so we don't spam the console for everyone.
|
||||
(async () => {
|
||||
try {
|
||||
if (!window.ArtstrNoble) throw new Error('noble bundle did not load');
|
||||
// BIP-340 test vector #1 (from bitcoin/bips bip-0340/test-vectors.csv).
|
||||
const tvPriv = 'B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF';
|
||||
const tvPub = 'DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659';
|
||||
const tvMsg = '243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89';
|
||||
const tvSig = '6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE33418906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A';
|
||||
const derivedPub = NWC.getPubkeyHex(tvPriv);
|
||||
if (derivedPub.toUpperCase() !== tvPub) throw new Error('schnorr pubkey mismatch');
|
||||
const okSig = window.ArtstrNoble.schnorr.verify(
|
||||
NWC.hexToBytes(tvSig), NWC.hexToBytes(tvMsg), NWC.hexToBytes(tvPub)
|
||||
);
|
||||
if (!okSig) throw new Error('schnorr.verify failed on BIP-340 test vector');
|
||||
// NIP-04 round-trip: encrypt + decrypt with two random keypairs.
|
||||
const aPriv = NWC.bytesToHex(window.ArtstrNoble.randomBytes(32));
|
||||
const bPriv = NWC.bytesToHex(window.ArtstrNoble.randomBytes(32));
|
||||
const aPub = NWC.getPubkeyHex(aPriv);
|
||||
const bPub = NWC.getPubkeyHex(bPriv);
|
||||
const msg = 'NIP-04 round-trip — Artstr NWC self-test ✓';
|
||||
const ct = await NWC.encrypt04(aPriv, bPub, msg);
|
||||
const pt = await NWC.decrypt04(bPriv, aPub, ct);
|
||||
if (pt !== msg) throw new Error('NIP-04 round-trip mismatch');
|
||||
// Sign + verify a synthetic Nostr event using a random key.
|
||||
const ev = { kind: 1, created_at: Math.floor(Date.now() / 1000), tags: [], content: 'hello' };
|
||||
const signed = NWC.signEventWithKey(ev, aPriv);
|
||||
if (!NWC.verifyEvent(signed)) throw new Error('verifyEvent failed on signed event');
|
||||
} catch (err) {
|
||||
console.warn('[Artstr] Crypto self-test failed — NWC features will not work:', err);
|
||||
}
|
||||
})();
|
||||
// ---- end NWC crypto -----------------------------------------------------
|
||||
|
||||
|
||||
// In-app toast — replaces all uses of native toastError(). Default type is
|
||||
// 'info'; use toastError / toastSuccess for the common severity flavors.
|
||||
function toast(message, opts = {}) {
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
(()=>{var ae=Object.defineProperty;var ue=(t,e,n)=>e in t?ae(t,e,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[e]=n;var l=(t,e,n)=>ue(t,typeof e!="symbol"?e+"":e,n);var Ht=Object.freeze({p:0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn,n:0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n,h:1n,a:0n,b:7n,Gx:0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n,Gy:0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n}),{p:v,n:Z,Gx:he,Gy:de,b:St}=Ht,w=32,K=64,P={publicKey:w+1,publicKeyUncompressed:K+1,signature:K,seed:w+w/2},b=(t="",e=Error)=>{let n=new e(t),{captureStackTrace:s}=Error;throw typeof s=="function"&&s(n,b),n},le=t=>t instanceof Uint8Array||ArrayBuffer.isView(t)&&t.constructor.name==="Uint8Array"&&t.BYTES_PER_ELEMENT===1,E=(t,e,n="")=>{let s=le(t),r=t?.length,o=e!==void 0;if(!s||o&&r!==e){let c=n&&`"${n}" `,f=o?` of length ${e}`:"",a=s?`length=${r}`:`type=${typeof t}`,h=c+"expected Uint8Array"+f+", got "+a;return s?b(h,RangeError):b(h,TypeError)}return t},j=t=>new Uint8Array(t),Bt=(t,e)=>t.toString(16).padStart(e,"0"),Lt=t=>{let e="";for(let n of E(t))e+=Bt(n,2);return e},_={_0:48,_9:57,A:65,F:70,a:97,f:102},bt=t=>t>=_._0&&t<=_._9?t-_._0:t>=_.A&&t<=_.F?t-(_.A-10):t>=_.a&&t<=_.f?t-(_.a-10):void 0,Ut=t=>{let e="hex invalid";if(typeof t!="string")return b(e);let n=t.length,s=n/2;if(n%2)return b(e);let r=j(s);for(let o=0,c=0;o<s;o++,c+=2){let f=bt(t.charCodeAt(c)),a=bt(t.charCodeAt(c+1));if(f===void 0||a===void 0)return b(e);r[o]=f*16+a}return r},yt=()=>globalThis?.crypto?.subtle??b("crypto.subtle must be defined, consider polyfill"),R=(...t)=>{let e=0;for(let r of t)e+=E(r).length;let n=j(e),s=0;for(let r of t)n.set(r,s),s+=r.length;return n},at=(t=w)=>(globalThis?.crypto).getRandomValues(j(t)),ot=BigInt,V=(t,e,n,s="bad number: out of range")=>typeof t!="bigint"?b(s,TypeError):e<=t&&t<n?t:b(s,RangeError),i=(t,e=v)=>{let n=t%e;return n>=0n?n:e+n},z=t=>i(t,Z),xe=(t,e)=>{(t===0n||e<=0n)&&b("no inverse n="+t+" mod="+e);let n=i(t,e),s=e,r=0n,o=1n,c=1n,f=0n;for(;n!==0n;){let a=s/n,h=s%n,d=r-c*a,u=o-f*a;s=n,n=h,r=c,o=f,c=d,f=u}return s===1n?i(r,e):b("no inverse")},_t=t=>{let e=J[t];return typeof e!="function"&&b("hashes."+t+" not set"),e},pt=(t,e,n)=>E(_t(t)(e,n),w,"digest"),gt=(t,e,n)=>Promise.resolve(_t(t)(e,n)).then(s=>E(s,w,"digest"));var rt=t=>t instanceof L?t:b("Point expected"),Tt=t=>i(i(t*t)*t+St),mt=t=>V(t,0n,v),M=t=>V(t,1n,v),be=t=>V(t,1n,Z),W=t=>!(t&1n),It=t=>Uint8Array.of(t),ye=t=>It(W(t)?2:3),vt=t=>{let e=Tt(M(t)),n=1n;for(let s=e,r=(v+1n)/4n;r>0n;r>>=1n)r&1n&&(n=n*s%v),s=s*s%v;return i(n*n)!==e&&b("sqrt invalid"),W(n)?n:i(-n)};var B=class B{constructor(e,n,s){l(this,"X");l(this,"Y");l(this,"Z");this.X=mt(e),this.Y=M(n),this.Z=mt(s),Object.freeze(this)}static CURVE(){return Ht}static fromAffine(e){let{x:n,y:s}=e;return n===0n&&s===0n?F:new B(n,s,1n)}static fromBytes(e){E(e);let{publicKey:n,publicKeyUncompressed:s}=P,r,o=e.length,c=e[0],f=e.subarray(1),a=Y(f,0,w);if(o===n&&(c===2||c===3)){let h=vt(a);c===3&&(h=i(-h)),r=new B(a,h,1n)}return o===s&&c===4&&(r=new B(a,Y(f,w,K),1n)),r?r.assertValidity():b("bad point: not on curve")}static fromHex(e){return B.fromBytes(Ut(e))}get x(){return this.toAffine().x}get y(){return this.toAffine().y}equals(e){let{X:n,Y:s,Z:r}=this,{X:o,Y:c,Z:f}=rt(e),a=i(n*f),h=i(o*r),d=i(s*f),u=i(c*r);return a===h&&d===u}is0(){return this.equals(F)}negate(){return new B(this.X,i(-this.Y),this.Z)}double(){return this.add(this)}add(e){let{X:n,Y:s,Z:r}=this,{X:o,Y:c,Z:f}=rt(e),a=0n,h=St,d=0n,u=0n,x=0n,y=i(h*3n),p=i(n*o),g=i(s*c),A=i(r*f),O=i(n+s),m=i(o+c);O=i(O*m),m=i(p+g),O=i(O-m),m=i(n+r);let S=i(o+f);return m=i(m*S),S=i(p+A),m=i(m-S),S=i(s+r),d=i(c+f),S=i(S*d),d=i(g+A),S=i(S-d),x=i(a*m),d=i(y*A),x=i(d+x),d=i(g-x),x=i(g+x),u=i(d*x),g=i(p+p),g=i(g+p),A=i(a*A),m=i(y*m),g=i(g+A),A=i(p-A),A=i(a*A),m=i(m+A),p=i(g*m),u=i(u+p),p=i(S*m),d=i(O*d),d=i(d-p),p=i(O*g),x=i(S*x),x=i(x+p),new B(d,u,x)}subtract(e){return this.add(rt(e).negate())}multiply(e,n=!0){if(!n&&e===0n)return F;if(be(e),e===1n)return this;if(this.equals(C))return _e(e).p;let s=F,r=C;for(let o=this;e>0n;o=o.double(),e>>=1n)e&1n?s=s.add(o):n&&(r=r.add(o));return s}multiplyUnsafe(e){return this.multiply(e,!1)}toAffine(){let{X:e,Y:n,Z:s}=this;if(this.equals(F))return{x:0n,y:0n};if(s===1n)return{x:e,y:n};let r=xe(s,v);return i(s*r)!==1n&&b("inverse invalid"),{x:i(e*r),y:i(n*r)}}assertValidity(){let{x:e,y:n}=this.toAffine();return M(e),M(n),i(n*n)===Tt(e)?this:b("bad point: not on curve")}toBytes(e=!0){let{x:n,y:s}=this.assertValidity().toAffine(),r=U(n);return e?R(ye(s),r):R(It(4),r,U(s))}toHex(e){return Lt(this.toBytes(e))}};l(B,"BASE"),l(B,"ZERO");var L=B,C=new L(he,de,1n),F=new L(0n,1n,0n);L.BASE=C;L.ZERO=F;var pe=(t,e,n)=>C.multiply(e,!1).add(t.multiply(n,!1)).assertValidity(),G=t=>ot("0x"+(Lt(t)||"0")),Y=(t,e,n)=>G(t.subarray(e,n)),ge=2n**256n,U=t=>Ut(Bt(V(t,0n,ge),K)),q=t=>{let e=G(E(t,w,"secret key"));return V(e,1n,Z,"invalid secret key: outsidLine truncated
|
||||
/*! Bundled license information:
|
||||
|
||||
@noble/secp256k1/index.js:
|
||||
(*! noble-secp256k1 - MIT License (c) 2019 Paul Miller (paulmillr.com) *)
|
||||
*/
|
||||
Reference in new issue
Block a user