Created
August 18, 2026 03:04
-
-
Save nfl0/a7bca23616b82d32e2f0c2b2a4038b32 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Browser-side implementation of Zcash Wormhole Ceremony v1. | |
| // No external dependencies. Uses WebCrypto only for Ed25519; all transcript | |
| // hashing, Pallas receiver derivation, F4Jumble, and Bech32m are implemented here. | |
| export const FORMAT = 'zcash-wormhole-ceremony'; | |
| export const FORMAT_VERSION = 1; | |
| const PERSON = new TextEncoder().encode('ZcashWormholeV1'); | |
| const PROFILE_DK = hexToBytes('31d6a685be570f9faf3ca8b052e887840b2c9f8d67224ca82aefb9e2ee5bedaf'); | |
| const PROFILE_D = hexToBytes('8ff3386971cb64b8e77899'); | |
| const PROFILE_GD_X = 0x09ceb27d1d782ab1df0bae032509c83643023ad15ad5a86e902d71da049f531bn; | |
| const PROFILE_GD_Y = 0x24a15eeff9e1d0d672c9847da6341f8c8d154a8a48187ba55eb9df2a206c3633n; | |
| const P = 0x40000000000000000000000000000000224698fc094cf91b992d30ed00000001n; | |
| const Q = 0x40000000000000000000000000000000224698fc0994a8dd8c46eb2100000001n; | |
| const TYPE_ORCHARD = 0x03; | |
| const BECH32M_CONST = 0x2bc830a3; | |
| const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; | |
| const MASK64 = (1n << 64n) - 1n; | |
| const UTF8 = new TextEncoder(); | |
| const IV = [ | |
| 0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n, | |
| 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n, | |
| ]; | |
| const SIGMA = [ | |
| [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15], | |
| [14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3], | |
| [11,8,12,0,5,2,15,13,10,14,3,6,7,1,9,4], | |
| [7,9,3,1,13,12,11,14,2,6,5,10,4,0,15,8], | |
| [9,0,5,7,2,4,10,15,14,1,11,12,6,8,3,13], | |
| [2,12,6,10,0,11,8,3,4,13,7,5,15,14,1,9], | |
| [12,5,1,15,14,13,4,10,0,7,6,3,9,2,8,11], | |
| [13,11,7,14,12,1,3,9,5,0,15,4,8,6,2,10], | |
| [6,15,14,9,11,3,0,8,12,2,13,7,1,4,10,5], | |
| [10,2,8,4,7,6,1,5,15,11,9,14,3,12,13,0], | |
| [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15], | |
| [14,10,4,8,9,15,13,6,1,12,0,2,11,7,5,3], | |
| ]; | |
| function concat(...parts) { | |
| const len = parts.reduce((n, p) => n + p.length, 0); | |
| const out = new Uint8Array(len); | |
| let off = 0; | |
| for (const p of parts) { out.set(p, off); off += p.length; } | |
| return out; | |
| } | |
| function u32le(n) { const a = new Uint8Array(4); new DataView(a.buffer).setUint32(0, n, true); return a; } | |
| function u64le(n) { | |
| let x = BigInt(n); const a = new Uint8Array(8); | |
| for (let i=0;i<8;i++) { a[i] = Number(x & 255n); x >>= 8n; } | |
| return a; | |
| } | |
| function lp(a) { return concat(u32le(a.length), a); } | |
| function eqBytes(a,b) { if (a.length !== b.length) return false; let d=0; for(let i=0;i<a.length;i++) d|=a[i]^b[i]; return d===0; } | |
| function rotr(x,n) { const k=BigInt(n); return ((x >> k) | ((x << (64n-k)) & MASK64)) & MASK64; } | |
| function add64(...xs) { let z=0n; for(const x of xs) z=(z+x)&MASK64; return z; } | |
| function read64le(a,off) { let x=0n; for(let i=7;i>=0;i--) x=(x<<8n)|BigInt(a[off+i]); return x; } | |
| function write64le(a,off,x) { let z=x; for(let i=0;i<8;i++){ a[off+i]=Number(z&255n); z>>=8n; } } | |
| export function blake2b(data, digestSize=32, personal=new Uint8Array()) { | |
| if (!(data instanceof Uint8Array)) data = new Uint8Array(data); | |
| if (digestSize < 1 || digestSize > 64) throw new Error('invalid BLAKE2b digest size'); | |
| if (personal.length > 16) throw new Error('BLAKE2b personalization too long'); | |
| const param = new Uint8Array(64); | |
| param[0]=digestSize; param[2]=1; param[3]=1; | |
| param.set(personal, 48); | |
| const h = IV.map((x,i)=>x ^ read64le(param,i*8)); | |
| const compress = (block, count, last) => { | |
| const m = Array.from({length:16},(_,i)=>read64le(block,i*8)); | |
| const v = [...h, ...IV]; | |
| v[12] ^= BigInt(count) & MASK64; | |
| v[13] ^= BigInt(count) >> 64n; | |
| if (last) v[14] ^= MASK64; | |
| const G = (a,b,c,d,x,y) => { | |
| v[a]=add64(v[a],v[b],x); v[d]=rotr(v[d]^v[a],32); | |
| v[c]=add64(v[c],v[d]); v[b]=rotr(v[b]^v[c],24); | |
| v[a]=add64(v[a],v[b],y); v[d]=rotr(v[d]^v[a],16); | |
| v[c]=add64(v[c],v[d]); v[b]=rotr(v[b]^v[c],63); | |
| }; | |
| for(let r=0;r<12;r++) { | |
| const s=SIGMA[r]; | |
| G(0,4,8,12,m[s[0]],m[s[1]]); G(1,5,9,13,m[s[2]],m[s[3]]); | |
| G(2,6,10,14,m[s[4]],m[s[5]]); G(3,7,11,15,m[s[6]],m[s[7]]); | |
| G(0,5,10,15,m[s[8]],m[s[9]]); G(1,6,11,12,m[s[10]],m[s[11]]); | |
| G(2,7,8,13,m[s[12]],m[s[13]]); G(3,4,9,14,m[s[14]],m[s[15]]); | |
| } | |
| for(let i=0;i<8;i++) h[i] = h[i] ^ v[i] ^ v[i+8]; | |
| }; | |
| if (data.length === 0) compress(new Uint8Array(128), 0, true); | |
| else { | |
| let off=0; | |
| while(off < data.length) { | |
| const take = Math.min(128, data.length-off); | |
| const block = new Uint8Array(128); block.set(data.subarray(off, off+take)); | |
| off += take; compress(block, off, off === data.length); | |
| } | |
| } | |
| const full = new Uint8Array(64); h.forEach((x,i)=>write64le(full,i*8,x)); | |
| return full.subarray(0,digestSize); | |
| } | |
| function H(...parts) { return blake2b(concat(...parts),32,PERSON); } | |
| export function bytesToHex(a) { return [...a].map(b=>b.toString(16).padStart(2,'0')).join(''); } | |
| export function hexToBytes(s) { | |
| if (typeof s !== 'string' || s.length % 2 || !/^[0-9a-fA-F]*$/.test(s)) throw new Error('invalid hex'); | |
| const a = new Uint8Array(s.length/2); for(let i=0;i<a.length;i++) a[i]=parseInt(s.slice(i*2,i*2+2),16); return a; | |
| } | |
| function bigintFromLE(a) { let x=0n; for(let i=a.length-1;i>=0;i--) x=(x<<8n)|BigInt(a[i]); return x; } | |
| function bigintToLE(x,len) { const a=new Uint8Array(len); let z=x; for(let i=0;i<len;i++){a[i]=Number(z&255n);z>>=8n;} return a; } | |
| function utf8Limited(s,max,label) { if(typeof s!=='string'||!s) throw new Error(`${label} is required`); const b=UTF8.encode(s); if(b.length>max) throw new Error(`${label} exceeds ${max} bytes`); return b; } | |
| export function genesisHash(ceremonyId, name) { return H(UTF8.encode('genesis\0'), ceremonyId, lp(utf8Limited(name,256,'name'))); } | |
| export function contributionMessage(ceremonyId, sequence, parent, participant, pub, entropy) { | |
| if (pub.length!==32 || entropy.length!==32 || parent.length!==32 || ceremonyId.length!==16) throw new Error('invalid contribution lengths'); | |
| return concat(UTF8.encode('contribution\0'), ceremonyId, u64le(sequence), parent, lp(utf8Limited(participant,128,'participant')), pub, entropy); | |
| } | |
| export function stateHash(parent, message, signature) { if(signature.length!==64) throw new Error('invalid signature length'); return H(UTF8.encode('state\0'), parent, lp(message), signature); } | |
| async function importEd25519Public(raw) { return crypto.subtle.importKey('raw', raw, {name:'Ed25519'}, false, ['verify']); } | |
| export async function supportsEd25519() { | |
| try { const kp=await crypto.subtle.generateKey({name:'Ed25519'}, false, ['sign','verify']); await crypto.subtle.sign({name:'Ed25519'}, kp.privateKey, new Uint8Array([1])); return true; } catch { return false; } | |
| } | |
| export async function verifyCeremony(doc) { | |
| if (!doc || doc.format!==FORMAT || doc.version!==FORMAT_VERSION) throw new Error('unsupported ceremony format/version'); | |
| const cid=hexToBytes(doc.ceremony_id); if(cid.length!==16) throw new Error('invalid ceremony_id'); | |
| const gen=hexToBytes(doc.genesis_hash); const expectedGen=genesisHash(cid,doc.name); if(!eqBytes(gen,expectedGen)) throw new Error('genesis_hash mismatch'); | |
| if(!Array.isArray(doc.contributions)) throw new Error('contributions must be an array'); | |
| let state=gen; | |
| for(let i=0;i<doc.contributions.length;i++) { | |
| const r=doc.contributions[i], seq=i+1; | |
| if(r.sequence!==seq) throw new Error(`contribution ${seq}: sequence mismatch`); | |
| const parent=hexToBytes(r.parent_hash); if(!eqBytes(parent,state)) throw new Error(`contribution ${seq}: parent_hash mismatch`); | |
| const pub=hexToBytes(r.public_key), entropy=hexToBytes(r.entropy), sig=hexToBytes(r.signature), claimed=hexToBytes(r.state_hash); | |
| const msg=contributionMessage(cid,seq,parent,r.participant,pub,entropy); | |
| const key=await importEd25519Public(pub); | |
| const ok=await crypto.subtle.verify({name:'Ed25519'},key,sig,msg); if(!ok) throw new Error(`contribution ${seq}: invalid Ed25519 signature`); | |
| const next=stateHash(parent,msg,sig); if(!eqBytes(next,claimed)) throw new Error(`contribution ${seq}: state_hash mismatch`); state=next; | |
| } | |
| return state; | |
| } | |
| export async function makeContribution(doc, participant) { | |
| const parent=await verifyCeremony(doc); const cid=hexToBytes(doc.ceremony_id); const sequence=doc.contributions.length+1; | |
| const entropy=crypto.getRandomValues(new Uint8Array(32)); | |
| const kp=await crypto.subtle.generateKey({name:'Ed25519'}, false, ['sign','verify']); | |
| const pub=new Uint8Array(await crypto.subtle.exportKey('raw',kp.publicKey)); | |
| const msg=contributionMessage(cid,sequence,parent,participant,pub,entropy); | |
| const sig=new Uint8Array(await crypto.subtle.sign({name:'Ed25519'},kp.privateKey,msg)); | |
| const next=stateHash(parent,msg,sig); | |
| return {sequence,parent_hash:bytesToHex(parent),participant,public_key:bytesToHex(pub),entropy:bytesToHex(entropy),signature:bytesToHex(sig),state_hash:bytesToHex(next)}; | |
| } | |
| function mod(x,m=P) { x%=m; return x<0n?x+m:x; } | |
| function modPow(base,exp,m) { let b=mod(base,m), e=exp, r=1n; while(e){if(e&1n)r=(r*b)%m;b=(b*b)%m;e>>=1n;} return r; } | |
| function inv(x) { return modPow(mod(x),P-2n,P); } | |
| // Jacobian Pallas point: affine x=X/Z^2, y=Y/Z^3. Z=0 is identity. | |
| function jdbl(a) { | |
| if(a.z===0n || a.y===0n) return {x:0n,y:1n,z:0n}; | |
| const A=mod(a.x*a.x), B=mod(a.y*a.y), C=mod(B*B); | |
| const D=mod(2n*(mod((a.x+B)*(a.x+B))-A-C)); | |
| const E=mod(3n*A), F=mod(E*E); | |
| return {x:mod(F-2n*D), y:mod(E*(D-mod(F-2n*D))-8n*C), z:mod(2n*a.y*a.z)}; | |
| } | |
| function jadd(a,b) { | |
| if(a.z===0n) return b; if(b.z===0n) return a; | |
| const z1z1=mod(a.z*a.z), z2z2=mod(b.z*b.z); | |
| const u1=mod(a.x*z2z2), u2=mod(b.x*z1z1); | |
| const s1=mod(a.y*b.z*z2z2), s2=mod(b.y*a.z*z1z1); | |
| if(u1===u2) return s1===s2 ? jdbl(a) : {x:0n,y:1n,z:0n}; | |
| const h=mod(u2-u1), i=mod((2n*h)*(2n*h)), j=mod(h*i), r=mod(2n*(s2-s1)), v=mod(u1*i); | |
| const x3=mod(r*r-j-2n*v), y3=mod(r*(v-x3)-2n*s1*j), z3=mod(((a.z+b.z)*(a.z+b.z)-z1z1-z2z2)*h); | |
| return {x:x3,y:y3,z:z3}; | |
| } | |
| function pointMul(s, affine) { | |
| let n=s%Q, acc={x:0n,y:1n,z:0n}, base={x:affine.x,y:affine.y,z:1n}; | |
| while(n){ if(n&1n) acc=jadd(acc,base); base=jdbl(base); n>>=1n; } return acc; | |
| } | |
| function toAffine(a) { if(a.z===0n) return null; const zi=inv(a.z), z2=mod(zi*zi), z3=mod(z2*zi); return {x:mod(a.x*z2),y:mod(a.y*z3)}; } | |
| function encodePoint(a) { const p=toAffine(a); if(!p) return new Uint8Array(32); const out=bigintToLE(p.x,32); if(p.y&1n) out[31]|=0x80; return out; } | |
| function deriveIvk(finalState) { | |
| for(let c=0;c<0xffffffff;c++) { const candidate=H(UTF8.encode('ivk\0'),finalState,u32le(c)); const n=bigintFromLE(candidate); if(n>0n&&n<P)return candidate; } | |
| throw new Error('IVK rejection sampling exhausted'); | |
| } | |
| function compactSize(n) { if(n<253)return new Uint8Array([n]); if(n<=0xffff)return concat(new Uint8Array([253]),bigintToLE(BigInt(n),2)); if(n<=0xffffffff)return concat(new Uint8Array([254]),bigintToLE(BigInt(n),4)); return concat(new Uint8Array([255]),bigintToLE(BigInt(n),8)); } | |
| function xor(a,b) { const o=new Uint8Array(a.length); for(let i=0;i<a.length;i++)o[i]=a[i]^b[i]; return o; } | |
| function f4jumble(data) { | |
| if(data.length<48||data.length>4194368) throw new Error('F4Jumble length out of range'); | |
| const lL=Math.min(64,Math.floor(data.length/2)), lR=data.length-lL, a=data.subarray(0,lL), b=data.subarray(lL); | |
| const h=(i,u)=>blake2b(u,lL,concat(UTF8.encode('UA_F4Jumble_H'),new Uint8Array([i,0,0]))); | |
| const g=(i,u)=>{ const parts=[]; for(let j=0;j<Math.ceil(lR/64);j++)parts.push(blake2b(u,64,concat(UTF8.encode('UA_F4Jumble_G'),new Uint8Array([i]),bigintToLE(BigInt(j),2)))); return concat(...parts).subarray(0,lR); }; | |
| const x=xor(b,g(0,a)), y=xor(a,h(0,x)), d=xor(x,g(1,y)), c=xor(y,h(1,d)); return concat(c,d); | |
| } | |
| function convertBits(data,fromBits,toBits,pad=true) { let acc=0,bits=0; const out=[], maxv=(1<<toBits)-1, maxacc=(1<<(fromBits+toBits-1))-1; for(const value of data){if(value<0||(value>>fromBits))throw new Error('invalid bit conversion'); acc=((acc<<fromBits)|value)&maxacc;bits+=fromBits;while(bits>=toBits){bits-=toBits;out.push((acc>>bits)&maxv);}} if(pad&&bits)out.push((acc<<(toBits-bits))&maxv); return out; } | |
| function polymod(values) { const gen=[0x3b6a57b2,0x26508e6d,0x1ea119fa,0x3d4233dd,0x2a1462b3]; let chk=1; for(const value of values){const top=chk>>>25;chk=((chk&0x1ffffff)<<5)^value;for(let i=0;i<5;i++)if((top>>>i)&1)chk^=gen[i];} return chk>>>0; } | |
| function bech32m(hrp,data) { const ex=[...hrp].map(c=>c.charCodeAt(0)>>5).concat([0],[...hrp].map(c=>c.charCodeAt(0)&31)); const pm=(polymod(ex.concat(data,[0,0,0,0,0,0]))^BECH32M_CONST)>>>0; const cs=[];for(let i=0;i<6;i++)cs.push((pm>>>(5*(5-i)))&31);return hrp+'1'+data.concat(cs).map(x=>CHARSET[x]).join(''); } | |
| function unified(raw,hrp) { const item=concat(compactSize(TYPE_ORCHARD),compactSize(raw.length),raw), hb=UTF8.encode(hrp), pad=concat(hb,new Uint8Array(16-hb.length)); return bech32m(hrp,convertBits(f4jumble(concat(item,pad)),8,5)); } | |
| export async function deriveWormhole(doc) { | |
| const finalState=await verifyCeremony(doc); const ivk=deriveIvk(finalState), n=bigintFromLE(ivk); | |
| const pkd=encodePoint(pointMul(n,{x:PROFILE_GD_X,y:PROFILE_GD_Y})); | |
| const receiver=concat(PROFILE_D,pkd), rawIvk=concat(PROFILE_DK,ivk); | |
| return { | |
| transcript_hash:bytesToHex(finalState), | |
| raw_receiver_hex:bytesToHex(receiver), raw_incoming_viewing_key_hex:bytesToHex(rawIvk), | |
| unified_address_mainnet:unified(receiver,'u'), unified_address_testnet:unified(receiver,'utest'), | |
| unified_incoming_viewing_key_mainnet:unified(rawIvk,'uivk'), unified_incoming_viewing_key_testnet:unified(rawIvk,'uivktest'), | |
| ivk_hex_le:bytesToHex(ivk), pk_d_hex:bytesToHex(pkd), contribution_count:doc.contributions.length, | |
| }; | |
| } | |
| export const __test = {H, f4jumble, unified, deriveIvk, pointMul, encodePoint, PROFILE_GD_X, PROFILE_GD_Y, P, Q}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment