Created
February 20, 2026 23:24
-
-
Save requilence/ad8e7857b7c23bd5bb2b25c401d7df3b to your computer and use it in GitHub Desktop.
Anytype account
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
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>SLIP-0010 Wallet</title> | |
| <style> | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| body{font-family:system-ui,-apple-system,sans-serif;background:#0e0e10;color:#e0e0e0;min-height:100vh;padding:2rem} | |
| h1{font-size:1.4rem;font-weight:600;margin-bottom:1.5rem;color:#fff} | |
| h2{font-size:1rem;font-weight:600;margin-bottom:1rem;color:#a0a0b0;text-transform:uppercase;letter-spacing:.08em} | |
| section{background:#18181b;border:1px solid #27272a;border-radius:12px;padding:1.5rem;margin-bottom:1.25rem;max-width:640px} | |
| label{display:block;font-size:.8rem;color:#71717a;margin-bottom:.35rem;font-weight:500} | |
| input,textarea{width:100%;background:#09090b;border:1px solid #27272a;border-radius:8px;padding:.6rem .75rem;color:#e0e0e0;font-family:ui-monospace,monospace;font-size:.85rem;outline:none;transition:border .15s} | |
| input:focus,textarea:focus{border-color:#3b82f6} | |
| textarea{resize:vertical;min-height:3.2rem} | |
| .row{margin-bottom:.85rem} | |
| button{background:#3b82f6;color:#fff;border:none;border-radius:8px;padding:.55rem 1.2rem;font-size:.85rem;font-weight:600;cursor:pointer;transition:background .15s} | |
| button:hover{background:#2563eb} | |
| button:disabled{opacity:.4;cursor:not-allowed} | |
| .out{background:#09090b;border:1px solid #27272a;border-radius:8px;padding:.6rem .75rem;font-family:ui-monospace,monospace;font-size:.8rem;word-break:break-all;min-height:2.2rem;color:#a1a1aa;line-height:1.5} | |
| .out.ok{color:#22c55e;border-color:#166534} | |
| .out.err{color:#ef4444;border-color:#7f1d1d} | |
| .muted{font-size:.75rem;color:#52525b;margin-top:.35rem} | |
| .cols{display:flex;gap:.75rem;align-items:flex-end} | |
| .cols>*{flex:1} | |
| .cols>button{flex:none} | |
| .badge{display:inline-block;padding:.15rem .5rem;border-radius:4px;font-size:.75rem;font-weight:600} | |
| .badge.valid{background:#052e16;color:#22c55e} | |
| .badge.invalid{background:#450a0a;color:#ef4444} | |
| #toggle-mn{background:none;border:1px solid #27272a;color:#71717a;padding:.35rem .7rem;font-size:.75rem;border-radius:6px;margin-left:.5rem} | |
| </style> | |
| </head> | |
| <body> | |
| <h1>SLIP-0010 Ed25519 Wallet</h1> | |
| <!-- ─── Mnemonic ─────────────────────────────────── --> | |
| <section> | |
| <h2>Mnemonic</h2> | |
| <div class="row"> | |
| <label>BIP-39 Mnemonic <button id="toggle-mn" type="button">show</button></label> | |
| <input id="mnemonic" type="password" placeholder="Enter 12 / 24 words or generate one…" spellcheck="false" autocomplete="off"> | |
| <div class="muted" id="mn-status"></div> | |
| </div> | |
| <div class="cols"> | |
| <button id="btn-gen">Generate New</button> | |
| <button id="btn-derive" disabled>Derive Account</button> | |
| </div> | |
| </section> | |
| <!-- ─── Account ──────────────────────────────────── --> | |
| <section id="sec-account" style="display:none"> | |
| <h2>Account <span class="muted" style="text-transform:none;letter-spacing:0">(m/44'/2046'/0'/0')</span></h2> | |
| <div class="row"> | |
| <label>Address</label> | |
| <div class="out" id="out-addr"></div> | |
| </div> | |
| <div class="row"> | |
| <label>Public Key (hex)</label> | |
| <div class="out" id="out-pub"></div> | |
| </div> | |
| </section> | |
| <!-- ─── Sign ─────────────────────────────────────── --> | |
| <section id="sec-sign" style="display:none"> | |
| <h2>Sign</h2> | |
| <div class="row"> | |
| <label>Message</label> | |
| <textarea id="sign-msg" rows="2" placeholder="Type a message to sign…"></textarea> | |
| </div> | |
| <button id="btn-sign">Sign Message</button> | |
| <div class="row" style="margin-top:.85rem"> | |
| <label>Signature (hex)</label> | |
| <div class="out" id="out-sig"></div> | |
| </div> | |
| </section> | |
| <!-- ─── Verify ───────────────────────────────────── --> | |
| <section> | |
| <h2>Verify</h2> | |
| <div class="row"> | |
| <label>Message</label> | |
| <textarea id="ver-msg" rows="2" placeholder="Message that was signed…"></textarea> | |
| </div> | |
| <div class="row"> | |
| <label>Signature (hex, 128 chars)</label> | |
| <input id="ver-sig" placeholder="Paste signature hex…" spellcheck="false"> | |
| </div> | |
| <div class="row"> | |
| <label>Account Address (A…)</label> | |
| <input id="ver-pub" placeholder="Paste account address…" spellcheck="false"> | |
| </div> | |
| <div class="cols"> | |
| <button id="btn-verify">Verify</button> | |
| </div> | |
| <div class="row" style="margin-top:.85rem"> | |
| <div class="out" id="out-verify"></div> | |
| </div> | |
| </section> | |
| <script> | |
| // slip10.js — SLIP-0010 Ed25519 HD Key Derivation + BIP-39 Mnemonics | |
| // Zero dependencies. Uses Web Crypto API + BigInt. | |
| // | |
| // All derivation methods are async (Web Crypto is async by design). | |
| // | |
| // Public API: | |
| // deriveForPath(path, seed) → Promise<Node> | |
| // newMasterNode(seed) → Promise<Node> | |
| // unmarshalNode(bytes) → Node | |
| // isValidPath(path) → boolean | |
| // mnemonicToSeed(mnemonic, pass?) → Promise<Uint8Array> | |
| // generateMnemonic(strength?) → Promise<string> | |
| // validateMnemonic(mnemonic) → Promise<boolean> | |
| // verify(msg, sig, publicKey) → Promise<boolean> | |
| // hexToBytes / bytesToHex → conversion helpers | |
| // FIRST_HARDENED_INDEX → 0x80000000 | |
| (function (root, factory) { | |
| if (typeof module !== 'undefined' && module.exports) module.exports = factory(); | |
| else if (typeof define === 'function' && define.amd) define(factory); | |
| else root.slip10 = factory(); | |
| })(typeof globalThis !== 'undefined' ? globalThis : this, function () { | |
| 'use strict'; | |
| // ── BIP-39 wordlist (injected by build.js) ─────────────────── | |
| const WORDLIST = ["abandon","ability","able","about","above","absent","absorb","abstract","absurd","abuse","access","accident","account","accuse","achieve","acid","acoustic","acquire","across","act","action","actor","actress","actual","adapt","add","addict","address","adjust","admit","adult","advance","advice","aerobic","affair","afford","afraid","again","age","agent","agree","ahead","aim","air","airport","aisle","alarm","album","alcohol","alert","alien","all","alley","allow","almost","alone","alpha","already","also","alter","always","amateur","amazing","among","amount","amused","analyst","anchor","ancient","anger","angle","angry","animal","ankle","announce","annual","another","answer","antenna","antique","anxiety","any","apart","apology","appear","apple","approve","april","arch","arctic","area","arena","argue","arm","armed","armor","army","around","arrange","arrest","arrive","arrow","art","artefact","artist","artwork","ask","aspect","assault","asset","assist","assume","asthma","athlete","atom","attack","attend","attitude","attract","auction","audit","august","aunt","author","auto","autumn","average","avocado","avoid","awake","aware","away","awesome","awful","awkward","axis","baby","bachelor","bacon","badge","bag","balance","balcony","ball","bamboo","banana","banner","bar","barely","bargain","barrel","base","basic","basket","battle","beach","bean","beauty","because","become","beef","before","begin","behave","behind","believe","below","belt","bench","benefit","best","betray","better","between","beyond","bicycle","bid","bike","bind","biology","bird","birth","bitter","black","blade","blame","blanket","blast","bleak","bless","blind","blood","blossom","blouse","blue","blur","blush","board","boat","body","boil","bomb","bone","bonus","book","boost","border","boring","borrow","boss","bottom","bounce","box","boy","bracket","brain","brand","brass","brave","bread","breeze","brick","bridge","brief","bright","bring","brisk","broccoli","broken","bronze","broom","brother","brown","brush","bubble","buddy","budget","buffalo","build","bulb","bulk","bullet","bundle","bunker","burden","burger","burst","bus","business","busy","butter","buyer","buzz","cabbage","cabin","cable","cactus","cage","cake","call","calm","camera","camp","can","canal","cancel","candy","cannon","canoe","canvas","canyon","capable","capital","captain","car","carbon","card","cargo","carpet","carry","cart","case","cash","casino","castle","casual","cat","catalog","catch","category","cattle","caught","cause","caution","cave","ceiling","celery","cement","census","century","cereal","certain","chair","chalk","champion","change","chaos","chapter","charge","chase","chat","cheap","check","cheese","chef","cherry","chest","chicken","chief","child","chimney","choice","choose","chronic","chuckle","chunk","churn","cigar","cinnamon","circle","citizen","city","civil","claim","clap","clarify","claw","clay","clean","clerk","clever","click","client","cliff","climb","clinic","clip","clock","clog","close","cloth","cloud","clown","club","clump","cluster","clutch","coach","coast","coconut","code","coffee","coil","coin","collect","color","column","combine","come","comfort","comic","common","company","concert","conduct","confirm","congress","connect","consider","control","convince","cook","cool","copper","copy","coral","core","corn","correct","cost","cotton","couch","country","couple","course","cousin","cover","coyote","crack","cradle","craft","cram","crane","crash","crater","crawl","crazy","cream","credit","creek","crew","cricket","crime","crisp","critic","crop","cross","crouch","crowd","crucial","cruel","cruise","crumble","crunch","crush","cry","crystal","cube","culture","cup","cupboard","curious","current","curtain","curve","cushion","custom","cute","cycle","dad","damage","damp","dance","danger","daring","dash","daughter","dawn","day","deal","debate","debris","decade","december","decide","decline","decorate","decrease","deer","defense","define","defy","degree","delay","deliver","demand","demise","denial","dentist","deny","depart","depend","deposit","depth","deputy","derive","describe","desert","design","desk","despair","destroy","detail","detect","develop","device","devote","diagram","dial","diamond","diary","dice","diesel","diet","differ","digital","dignity","dilemma","dinner","dinosaur","direct","dirt","disagree","discover","disease","dish","dismiss","disorder","display","distance","divert","divide","divorce","dizzy","doctor","document","dog","doll","dolphin","domain","donate","donkey","donor","door","dose","double","dove","draft","dragon","drama","drastic","draw","dream","dress","drift","drill","drink","drip","drive","drop","drum","dry","duck","dumb","dune","during","dust","dutch","duty","dwarf","dynamic","eager","eagle","early","earn","earth","easily","east","easy","echo","ecology","economy","edge","edit","educate","effort","egg","eight","either","elbow","elder","electric","elegant","element","elephant","elevator","elite","else","embark","embody","embrace","emerge","emotion","employ","empower","empty","enable","enact","end","endless","endorse","enemy","energy","enforce","engage","engine","enhance","enjoy","enlist","enough","enrich","enroll","ensure","enter","entire","entry","envelope","episode","equal","equip","era","erase","erode","erosion","error","erupt","escape","essay","essence","estate","eternal","ethics","evidence","evil","evoke","evolve","exact","example","excess","exchange","excite","exclude","excuse","execute","exercise","exhaust","exhibit","exile","exist","exit","exotic","expand","expect","expire","explain","expose","express","extend","extra","eye","eyebrow","fabric","face","faculty","fade","faint","faith","fall","false","fame","family","famous","fan","fancy","fantasy","farm","fashion","fat","fatal","father","fatigue","fault","favorite","feature","february","federal","fee","feed","feel","female","fence","festival","fetch","fever","few","fiber","fiction","field","figure","file","film","filter","final","find","fine","finger","finish","fire","firm","first","fiscal","fish","fit","fitness","fix","flag","flame","flash","flat","flavor","flee","flight","flip","float","flock","floor","flower","fluid","flush","fly","foam","focus","fog","foil","fold","follow","food","foot","force","forest","forget","fork","fortune","forum","forward","fossil","foster","found","fox","fragile","frame","frequent","fresh","friend","fringe","frog","front","frost","frown","frozen","fruit","fuel","fun","funny","furnace","fury","future","gadget","gain","galaxy","gallery","game","gap","garage","garbage","garden","garlic","garment","gas","gasp","gate","gather","gauge","gaze","general","genius","genre","gentle","genuine","gesture","ghost","giant","gift","giggle","ginger","giraffe","girl","give","glad","glance","glare","glass","glide","glimpse","globe","gloom","glory","glove","glow","glue","goat","goddess","gold","good","goose","gorilla","gospel","gossip","govern","gown","grab","grace","grain","grant","grape","grass","gravity","great","green","grid","grief","grit","grocery","group","grow","grunt","guard","guess","guide","guilt","guitar","gun","gym","habit","hair","half","hammer","hamster","hand","happy","harbor","hard","harsh","harvest","hat","have","hawk","hazard","head","health","heart","heavy","hedgehog","height","hello","helmet","help","hen","hero","hidden","high","hill","hint","hip","hire","history","hobby","hockey","hold","hole","holiday","hollow","home","honey","hood","hope","horn","horror","horse","hospital","host","hotel","hour","hover","hub","huge","human","humble","humor","hundred","hungry","hunt","hurdle","hurry","hurt","husband","hybrid","ice","icon","idea","identify","idle","ignore","ill","illegal","illness","image","imitate","immense","immune","impact","impose","improve","impulse","inch","include","income","increase","index","indicate","indoor","industry","infant","inflict","inform","inhale","inherit","initial","inject","injury","inmate","inner","innocent","input","inquiry","insane","insect","inside","inspire","install","intact","interest","into","invest","invite","involve","iron","island","isolate","issue","item","ivory","jacket","jaguar","jar","jazz","jealous","jeans","jelly","jewel","job","join","joke","journey","joy","judge","juice","jump","jungle","junior","junk","just","kangaroo","keen","keep","ketchup","key","kick","kid","kidney","kind","kingdom","kiss","kit","kitchen","kite","kitten","kiwi","knee","knife","knock","know","lab","label","labor","ladder","lady","lake","lamp","language","laptop","large","later","latin","laugh","laundry","lava","law","lawn","lawsuit","layer","lazy","leader","leaf","learn","leave","lecture","left","leg","legal","legend","leisure","lemon","lend","length","lens","leopard","lesson","letter","level","liar","liberty","library","license","life","lift","light","like","limb","limit","link","lion","liquid","list","little","live","lizard","load","loan","lobster","local","lock","logic","lonely","long","loop","lottery","loud","lounge","love","loyal","lucky","luggage","lumber","lunar","lunch","luxury","lyrics","machine","mad","magic","magnet","maid","mail","main","major","make","mammal","man","manage","mandate","mango","mansion","manual","maple","marble","march","margin","marine","market","marriage","mask","mass","master","match","material","math","matrix","matter","maximum","maze","meadow","mean","measure","meat","mechanic","medal","media","melody","melt","member","memory","mention","menu","mercy","merge","merit","merry","mesh","message","metal","method","middle","midnight","milk","million","mimic","mind","minimum","minor","minute","miracle","mirror","misery","miss","mistake","mix","mixed","mixture","mobile","model","modify","mom","moment","monitor","monkey","monster","month","moon","moral","more","morning","mosquito","mother","motion","motor","mountain","mouse","move","movie","much","muffin","mule","multiply","muscle","museum","mushroom","music","must","mutual","myself","mystery","myth","naive","name","napkin","narrow","nasty","nation","nature","near","neck","need","negative","neglect","neither","nephew","nerve","nest","net","network","neutral","never","news","next","nice","night","noble","noise","nominee","noodle","normal","north","nose","notable","note","nothing","notice","novel","now","nuclear","number","nurse","nut","oak","obey","object","oblige","obscure","observe","obtain","obvious","occur","ocean","october","odor","off","offer","office","often","oil","okay","old","olive","olympic","omit","once","one","onion","online","only","open","opera","opinion","oppose","option","orange","orbit","orchard","order","ordinary","organ","orient","original","orphan","ostrich","other","outdoor","outer","output","outside","oval","oven","over","own","owner","oxygen","oyster","ozone","pact","paddle","page","pair","palace","palm","panda","panel","panic","panther","paper","parade","parent","park","parrot","party","pass","patch","path","patient","patrol","pattern","pause","pave","payment","peace","peanut","pear","peasant","pelican","pen","penalty","pencil","people","pepper","perfect","permit","person","pet","phone","photo","phrase","physical","piano","picnic","picture","piece","pig","pigeon","pill","pilot","pink","pioneer","pipe","pistol","pitch","pizza","place","planet","plastic","plate","play","please","pledge","pluck","plug","plunge","poem","poet","point","polar","pole","police","pond","pony","pool","popular","portion","position","possible","post","potato","pottery","poverty","powder","power","practice","praise","predict","prefer","prepare","present","pretty","prevent","price","pride","primary","print","priority","prison","private","prize","problem","process","produce","profit","program","project","promote","proof","property","prosper","protect","proud","provide","public","pudding","pull","pulp","pulse","pumpkin","punch","pupil","puppy","purchase","purity","purpose","purse","push","put","puzzle","pyramid","quality","quantum","quarter","question","quick","quit","quiz","quote","rabbit","raccoon","race","rack","radar","radio","rail","rain","raise","rally","ramp","ranch","random","range","rapid","rare","rate","rather","raven","raw","razor","ready","real","reason","rebel","rebuild","recall","receive","recipe","record","recycle","reduce","reflect","reform","refuse","region","regret","regular","reject","relax","release","relief","rely","remain","remember","remind","remove","render","renew","rent","reopen","repair","repeat","replace","report","require","rescue","resemble","resist","resource","response","result","retire","retreat","return","reunion","reveal","review","reward","rhythm","rib","ribbon","rice","rich","ride","ridge","rifle","right","rigid","ring","riot","ripple","risk","ritual","rival","river","road","roast","robot","robust","rocket","romance","roof","rookie","room","rose","rotate","rough","round","route","royal","rubber","rude","rug","rule","run","runway","rural","sad","saddle","sadness","safe","sail","salad","salmon","salon","salt","salute","same","sample","sand","satisfy","satoshi","sauce","sausage","save","say","scale","scan","scare","scatter","scene","scheme","school","science","scissors","scorpion","scout","scrap","screen","script","scrub","sea","search","season","seat","second","secret","section","security","seed","seek","segment","select","sell","seminar","senior","sense","sentence","series","service","session","settle","setup","seven","shadow","shaft","shallow","share","shed","shell","sheriff","shield","shift","shine","ship","shiver","shock","shoe","shoot","shop","short","shoulder","shove","shrimp","shrug","shuffle","shy","sibling","sick","side","siege","sight","sign","silent","silk","silly","silver","similar","simple","since","sing","siren","sister","situate","six","size","skate","sketch","ski","skill","skin","skirt","skull","slab","slam","sleep","slender","slice","slide","slight","slim","slogan","slot","slow","slush","small","smart","smile","smoke","smooth","snack","snake","snap","sniff","snow","soap","soccer","social","sock","soda","soft","solar","soldier","solid","solution","solve","someone","song","soon","sorry","sort","soul","sound","soup","source","south","space","spare","spatial","spawn","speak","special","speed","spell","spend","sphere","spice","spider","spike","spin","spirit","split","spoil","sponsor","spoon","sport","spot","spray","spread","spring","spy","square","squeeze","squirrel","stable","stadium","staff","stage","stairs","stamp","stand","start","state","stay","steak","steel","stem","step","stereo","stick","still","sting","stock","stomach","stone","stool","story","stove","strategy","street","strike","strong","struggle","student","stuff","stumble","style","subject","submit","subway","success","such","sudden","suffer","sugar","suggest","suit","summer","sun","sunny","sunset","super","supply","supreme","sure","surface","surge","surprise","surround","survey","suspect","sustain","swallow","swamp","swap","swarm","swear","sweet","swift","swim","swing","switch","sword","symbol","symptom","syrup","system","table","tackle","tag","tail","talent","talk","tank","tape","target","task","taste","tattoo","taxi","teach","team","tell","ten","tenant","tennis","tent","term","test","text","thank","that","theme","then","theory","there","they","thing","this","thought","three","thrive","throw","thumb","thunder","ticket","tide","tiger","tilt","timber","time","tiny","tip","tired","tissue","title","toast","tobacco","today","toddler","toe","together","toilet","token","tomato","tomorrow","tone","tongue","tonight","tool","tooth","top","topic","topple","torch","tornado","tortoise","toss","total","tourist","toward","tower","town","toy","track","trade","traffic","tragic","train","transfer","trap","trash","travel","tray","treat","tree","trend","trial","tribe","trick","trigger","trim","trip","trophy","trouble","truck","true","truly","trumpet","trust","truth","try","tube","tuition","tumble","tuna","tunnel","turkey","turn","turtle","twelve","twenty","twice","twin","twist","two","type","typical","ugly","umbrella","unable","unaware","uncle","uncover","under","undo","unfair","unfold","unhappy","uniform","unique","unit","universe","unknown","unlock","until","unusual","unveil","update","upgrade","uphold","upon","upper","upset","urban","urge","usage","use","used","useful","useless","usual","utility","vacant","vacuum","vague","valid","valley","valve","van","vanish","vapor","various","vast","vault","vehicle","velvet","vendor","venture","venue","verb","verify","version","very","vessel","veteran","viable","vibrant","vicious","victory","video","view","village","vintage","violin","virtual","virus","visa","visit","visual","vital","vivid","vocal","voice","void","volcano","volume","vote","voyage","wage","wagon","wait","walk","wall","walnut","want","warfare","warm","warrior","wash","wasp","waste","water","wave","way","wealth","weapon","wear","weasel","weather","web","wedding","weekend","weird","welcome","west","wet","whale","what","wheat","wheel","when","where","whip","whisper","wide","width","wife","wild","will","win","window","wine","wing","wink","winner","winter","wire","wisdom","wise","wish","witness","wolf","woman","wonder","wood","wool","word","work","world","worry","worth","wrap","wreck","wrestle","wrist","write","wrong","yard","year","yellow","you","young","youth","zebra","zero","zone","zoo"]; | |
| // ── Web Crypto backend ──────────────────────────────────────── | |
| const _crypto = typeof globalThis !== 'undefined' && globalThis.crypto | |
| ? globalThis.crypto | |
| : (typeof crypto !== 'undefined' ? crypto : undefined); | |
| if (!_crypto || !_crypto.subtle) throw new Error('Web Crypto API not available'); | |
| const subtle = _crypto.subtle; | |
| // ════════════════════════════════════════════════════════════════ | |
| // Ed25519 — pure BigInt implementation | |
| // ════════════════════════════════════════════════════════════════ | |
| const ED_P = 2n ** 255n - 19n; | |
| const ED_L = 2n ** 252n + 27742317777372353535851937790883648493n; | |
| function mod(a, m) { | |
| if (m === undefined) m = ED_P; | |
| return ((a % m) + m) % m; | |
| } | |
| function modPow(base, exp, m) { | |
| if (m === undefined) m = ED_P; | |
| let r = 1n; | |
| base = mod(base, m); | |
| while (exp > 0n) { | |
| if (exp & 1n) r = mod(r * base, m); | |
| exp >>= 1n; | |
| base = mod(base * base, m); | |
| } | |
| return r; | |
| } | |
| function modInv(a) { return modPow(a, ED_P - 2n); } | |
| // Curve constant d = -121665/121666 mod p | |
| const CURVE_D = mod(-121665n * modInv(121666n)); | |
| // sqrt(-1) mod p | |
| const SQRT_M1 = modPow(2n, (ED_P - 1n) / 4n); | |
| // Base point: y = 4/5 mod p, x = positive square root | |
| const BASE_Y = mod(4n * modInv(5n)); | |
| const BASE_X = (function () { | |
| const y2 = mod(BASE_Y * BASE_Y); | |
| const x2 = mod((y2 - 1n) * modInv(mod(1n + CURVE_D * y2))); | |
| let x = modPow(x2, (ED_P + 3n) / 8n); | |
| if (mod(x * x) !== x2) x = mod(x * SQRT_M1); | |
| if (mod(x * x) !== x2) throw new Error('base point x: no sqrt'); | |
| if (x & 1n) x = ED_P - x; // positive = even | |
| return x; | |
| })(); | |
| // Extended coordinates: (X, Y, Z, T) x=X/Z y=Y/Z xy=T/Z | |
| const ZERO_PT = [0n, 1n, 1n, 0n]; | |
| const BASE_PT = [BASE_X, BASE_Y, 1n, mod(BASE_X * BASE_Y)]; | |
| function ptAdd(a, b) { | |
| const [X1, Y1, Z1, T1] = a; | |
| const [X2, Y2, Z2, T2] = b; | |
| const A = mod(X1 * X2); | |
| const B = mod(Y1 * Y2); | |
| const C = mod(CURVE_D * mod(T1 * T2)); | |
| const ZZ = mod(Z1 * Z2); | |
| const E = mod(mod((X1 + Y1) * (X2 + Y2)) - A - B); | |
| const F = mod(ZZ - C); | |
| const G = mod(ZZ + C); | |
| const H = mod(B + A); | |
| return [mod(E * F), mod(G * H), mod(F * G), mod(E * H)]; | |
| } | |
| function ptDouble(p) { | |
| const [X1, Y1, Z1] = p; | |
| const A = mod(X1 * X1); | |
| const B = mod(Y1 * Y1); | |
| const C = mod(2n * mod(Z1 * Z1)); | |
| const aA = mod(-A); | |
| const E = mod(mod((X1 + Y1) * (X1 + Y1)) - A - B); | |
| const G = mod(aA + B); | |
| const F = mod(G - C); | |
| const H = mod(aA - B); | |
| return [mod(E * F), mod(G * H), mod(F * G), mod(E * H)]; | |
| } | |
| function scalarMult(s, pt) { | |
| let R = ZERO_PT; | |
| let Q = pt; | |
| while (s > 0n) { | |
| if (s & 1n) R = ptAdd(R, Q); | |
| Q = ptDouble(Q); | |
| s >>= 1n; | |
| } | |
| return R; | |
| } | |
| function ptEncode(pt) { | |
| const [X, Y, Z] = pt; | |
| const zi = modInv(Z); | |
| const x = mod(X * zi); | |
| const y = mod(Y * zi); | |
| const out = new Uint8Array(32); | |
| let v = y; | |
| for (let i = 0; i < 32; i++) { out[i] = Number(v & 0xffn); v >>= 8n; } | |
| if (x & 1n) out[31] |= 0x80; | |
| return out; | |
| } | |
| function bigIntToBytes32LE(n) { | |
| const b = new Uint8Array(32); | |
| for (let i = 0; i < 32; i++) { b[i] = Number(n & 0xffn); n >>= 8n; } | |
| return b; | |
| } | |
| function bytesToBigIntLE(b) { | |
| let v = 0n; | |
| for (let i = b.length - 1; i >= 0; i--) v = (v << 8n) | BigInt(b[i]); | |
| return v; | |
| } | |
| function concat() { | |
| let len = 0; | |
| for (let i = 0; i < arguments.length; i++) len += arguments[i].length; | |
| const r = new Uint8Array(len); | |
| let off = 0; | |
| for (let i = 0; i < arguments.length; i++) { r.set(arguments[i], off); off += arguments[i].length; } | |
| return r; | |
| } | |
| function ptDecode(bytes) { | |
| const b = new Uint8Array(bytes); | |
| const sign = (b[31] >> 7) & 1; | |
| b[31] &= 0x7f; | |
| const y = bytesToBigIntLE(b); | |
| if (y >= ED_P) throw new Error('invalid point: y >= p'); | |
| const y2 = mod(y * y); | |
| const x2 = mod((y2 - 1n) * modInv(mod(1n + CURVE_D * y2))); | |
| if (x2 === 0n) { | |
| if (sign) throw new Error('invalid point: x=0 but sign set'); | |
| return [0n, y, 1n, 0n]; | |
| } | |
| let x = modPow(x2, (ED_P + 3n) / 8n); | |
| if (mod(x * x) !== x2) x = mod(x * SQRT_M1); | |
| if (mod(x * x) !== x2) throw new Error('invalid point: no sqrt'); | |
| if ((Number(x & 1n)) !== sign) x = ED_P - x; | |
| return [x, y, 1n, mod(x * y)]; | |
| } | |
| function ptEqual(a, b) { | |
| return mod(a[0] * b[2]) === mod(b[0] * a[2]) && | |
| mod(a[1] * b[2]) === mod(b[1] * a[2]); | |
| } | |
| function clampScalar(h) { | |
| const a = new Uint8Array(h.slice(0, 32)); | |
| a[0] &= 248; | |
| a[31] &= 127; | |
| a[31] |= 64; | |
| return a; | |
| } | |
| // ── CRC-16-XMODEM ───────────────────────────────────────────── | |
| function crc16xmodem(data) { | |
| let crc = 0x0000; | |
| for (let i = 0; i < data.length; i++) { | |
| crc ^= data[i] << 8; | |
| for (let j = 0; j < 8; j++) | |
| crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; | |
| } | |
| return crc; | |
| } | |
| // ── Base58 (Bitcoin alphabet) ───────────────────────────────── | |
| const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; | |
| function base58Encode(bytes) { | |
| let zeros = 0; | |
| while (zeros < bytes.length && bytes[zeros] === 0) zeros++; | |
| let n = 0n; | |
| for (let i = 0; i < bytes.length; i++) n = n * 256n + BigInt(bytes[i]); | |
| let out = ''; | |
| while (n > 0n) { out = B58[Number(n % 58n)] + out; n /= 58n; } | |
| return '1'.repeat(zeros) + out; | |
| } | |
| function base58Decode(str) { | |
| let zeros = 0; | |
| while (zeros < str.length && str[zeros] === '1') zeros++; | |
| let n = 0n; | |
| for (let i = 0; i < str.length; i++) { | |
| const c = B58.indexOf(str[i]); | |
| if (c === -1) throw new Error('invalid base58 character: ' + str[i]); | |
| n = n * 58n + BigInt(c); | |
| } | |
| const hex = n === 0n ? '' : n.toString(16); | |
| const padded = hex.length % 2 ? '0' + hex : hex; | |
| const dataBytes = new Uint8Array(padded.length / 2); | |
| for (let i = 0; i < dataBytes.length; i++) dataBytes[i] = parseInt(padded.substr(i * 2, 2), 16); | |
| const out = new Uint8Array(zeros + dataBytes.length); | |
| out.set(dataBytes, zeros); | |
| return out; | |
| } | |
| /** Decode an account address (Base58) → 32-byte public key. Verifies version + CRC16. */ | |
| function decodeAccount(address) { | |
| const raw = base58Decode(address); | |
| if (raw.length !== 35) throw new Error('invalid address length: expected 35 bytes, got ' + raw.length); | |
| if (raw[0] !== 0x5b) throw new Error('invalid version byte: expected 0x5b, got 0x' + raw[0].toString(16)); | |
| const payload = raw.subarray(0, 33); | |
| const expected = crc16xmodem(payload); | |
| const actual = raw[33] | (raw[34] << 8); | |
| if (expected !== actual) throw new Error('checksum mismatch'); | |
| return raw.slice(1, 33); | |
| } | |
| // ── Hashing (Web Crypto) ────────────────────────────────────── | |
| async function sha512(data) { | |
| return new Uint8Array(await subtle.digest('SHA-512', data)); | |
| } | |
| async function sha256(data) { | |
| return new Uint8Array(await subtle.digest('SHA-256', data)); | |
| } | |
| async function hmacSHA512(key, data) { | |
| const kb = typeof key === 'string' ? new TextEncoder().encode(key) : key; | |
| const ck = await subtle.importKey('raw', kb, { name: 'HMAC', hash: 'SHA-512' }, false, ['sign']); | |
| return new Uint8Array(await subtle.sign('HMAC', ck, data)); | |
| } | |
| // ── Ed25519 public key from 32-byte seed ────────────────────── | |
| async function ed25519PubFromSeed(seed) { | |
| const h = await sha512(seed); | |
| const scalar = bytesToBigIntLE(clampScalar(h)); | |
| return ptEncode(scalarMult(scalar, BASE_PT)); | |
| } | |
| // ════════════════════════════════════════════════════════════════ | |
| // SLIP-0010 Key Derivation | |
| // ════════════════════════════════════════════════════════════════ | |
| const FIRST_HARDENED_INDEX = 0x80000000; | |
| const ACCOUNT_PATH = "m/44'/2046'/0'/0'"; | |
| const SEED_KEY = 'ed25519 seed'; | |
| const PATH_RE = /^m(\/\d+')*$/; | |
| class Node { | |
| /** @private */ | |
| constructor(key, chainCode) { | |
| this._key = key; // Uint8Array(32) | |
| this._cc = chainCode; // Uint8Array(32) | |
| } | |
| /** Derive child node at hardened index i (must be >= 0x80000000). */ | |
| async derive(i) { | |
| if (i < FIRST_HARDENED_INDEX) throw new Error('ed25519 requires hardened derivation'); | |
| const data = new Uint8Array(37); // 0x00 || key(32) || index(4 BE) | |
| data.set(this._key, 1); | |
| new DataView(data.buffer).setUint32(33, i, false); | |
| const I = await hmacSHA512(this._cc, data); | |
| return new Node(I.slice(0, 32), I.slice(32)); | |
| } | |
| /** Ed25519 keypair: { publicKey(32), privateKey(64 = seed||pub) } */ | |
| async keypair() { | |
| const pub = await ed25519PubFromSeed(this._key); | |
| const priv = new Uint8Array(64); | |
| priv.set(this._key, 0); | |
| priv.set(pub, 32); | |
| return { publicKey: pub, privateKey: priv }; | |
| } | |
| /** 0x00-prefixed public key (33 bytes), per SLIP-0010 test vectors. */ | |
| async publicKeyWithPrefix() { | |
| const { publicKey } = await this.keypair(); | |
| const r = new Uint8Array(33); | |
| r.set(publicKey, 1); | |
| return r; | |
| } | |
| /** 32-byte Ed25519 seed for this node (same as rawSeed). */ | |
| privateKey() { return new Uint8Array(this._key); } | |
| /** 32-byte raw key material. Does NOT include the chain code. */ | |
| rawSeed() { return new Uint8Array(this._key); } | |
| /** | |
| * Sign a message with this node's Ed25519 key (RFC 8032). | |
| * @param {string|Uint8Array} message | |
| * @returns {Promise<Uint8Array>} 64-byte signature | |
| */ | |
| async sign(message) { | |
| const msg = typeof message === 'string' ? new TextEncoder().encode(message) : message; | |
| const h = await sha512(this._key); | |
| const scalar = bytesToBigIntLE(clampScalar(h)); | |
| const prefix = h.slice(32, 64); | |
| const pub = await ed25519PubFromSeed(this._key); | |
| const rHash = await sha512(concat(prefix, msg)); | |
| const r = mod(bytesToBigIntLE(rHash), ED_L); | |
| const R = ptEncode(scalarMult(r, BASE_PT)); | |
| const hram = await sha512(concat(R, pub, msg)); | |
| const S = mod(r + mod(bytesToBigIntLE(hram), ED_L) * scalar, ED_L); | |
| const sig = new Uint8Array(64); | |
| sig.set(R, 0); | |
| sig.set(bigIntToBytes32LE(S), 32); | |
| return sig; | |
| } | |
| /** | |
| * Account address: Base58( 0x5b || publicKey || crc16 ). | |
| * Produces an "A…"-prefixed string like the Go Encode() with AccountAddressVersionByte. | |
| * @returns {Promise<string>} | |
| */ | |
| async account() { | |
| const pub = await ed25519PubFromSeed(this._key); | |
| const raw = new Uint8Array(1 + 32 + 2); | |
| raw[0] = 0x5b; // version byte | |
| raw.set(pub, 1); | |
| const crc = crc16xmodem(raw.subarray(0, 33)); | |
| raw[33] = crc & 0xff; // checksum LE | |
| raw[34] = (crc >> 8) & 0xff; | |
| return base58Encode(raw); | |
| } | |
| /** Serialize to 64 bytes: key(32) || chainCode(32). */ | |
| marshalBinary() { | |
| const b = new Uint8Array(64); | |
| b.set(this._key, 0); | |
| b.set(this._cc, 32); | |
| return b; | |
| } | |
| } | |
| /** Create master node from root seed via HMAC-SHA512("ed25519 seed", seed). */ | |
| async function newMasterNode(seed) { | |
| const I = await hmacSHA512(SEED_KEY, seed); | |
| return new Node(I.slice(0, 32), I.slice(32)); | |
| } | |
| /** Restore a node from a 64-byte blob produced by marshalBinary(). */ | |
| function unmarshalNode(data) { | |
| if (data.length !== 64) throw new Error('invalid node blob length: ' + data.length); | |
| return new Node(new Uint8Array(data.slice(0, 32)), new Uint8Array(data.slice(32))); | |
| } | |
| /** Validate a BIP-32-style path (only hardened segments allowed for ed25519). */ | |
| function isValidPath(path) { | |
| if (!PATH_RE.test(path)) return false; | |
| for (const seg of path.split('/').slice(1)) { | |
| const n = Number(seg.replace("'", '')); | |
| if (!Number.isInteger(n) || n < 0 || n > 0xFFFFFFFF) return false; | |
| } | |
| return true; | |
| } | |
| /** Derive a node at `path` (e.g. "m/44'/607'/0'") from a root seed. */ | |
| async function deriveForPath(path, seed) { | |
| if (!isValidPath(path)) throw new Error('invalid derivation path'); | |
| let node = await newMasterNode(seed); | |
| for (const seg of path.split('/').slice(1)) { | |
| const idx = (Number(seg.replace("'", '')) + FIRST_HARDENED_INDEX) >>> 0; | |
| node = await node.derive(idx); | |
| } | |
| return node; | |
| } | |
| /** Derive the account node at m/44'/2046' from a root seed. */ | |
| async function deriveAccount(seed) { | |
| return deriveForPath(ACCOUNT_PATH, seed); | |
| } | |
| // ════════════════════════════════════════════════════════════════ | |
| // BIP-39 Mnemonics | |
| // ════════════════════════════════════════════════════════════════ | |
| /** Convert mnemonic phrase → 64-byte seed via PBKDF2-SHA512 (2048 rounds). */ | |
| async function mnemonicToSeed(mnemonic, passphrase) { | |
| if (passphrase === undefined) passphrase = ''; | |
| const enc = new TextEncoder(); | |
| const pwd = enc.encode(mnemonic.normalize('NFKD')); | |
| const salt = enc.encode(('mnemonic' + passphrase).normalize('NFKD')); | |
| const key = await subtle.importKey('raw', pwd, 'PBKDF2', false, ['deriveBits']); | |
| const bits = await subtle.deriveBits( | |
| { name: 'PBKDF2', salt: salt, iterations: 2048, hash: 'SHA-512' }, key, 512 | |
| ); | |
| return new Uint8Array(bits); | |
| } | |
| /** Generate a random mnemonic (strength: 128/160/192/224/256 bits → 12-24 words). */ | |
| async function generateMnemonic(strength) { | |
| if (strength === undefined) strength = 128; | |
| if (![128, 160, 192, 224, 256].includes(strength)) | |
| throw new Error('strength must be 128 / 160 / 192 / 224 / 256'); | |
| if (WORDLIST.length !== 2048) throw new Error('wordlist not loaded — run build.js first'); | |
| const ent = new Uint8Array(strength / 8); | |
| _crypto.getRandomValues(ent); | |
| const hash = await sha256(ent); | |
| const csBits = strength / 32; | |
| let bits = ''; | |
| for (const b of ent) bits += b.toString(2).padStart(8, '0'); | |
| for (let i = 0; i < csBits; i++) | |
| bits += ((hash[i >> 3] >> (7 - (i & 7))) & 1).toString(); | |
| const words = []; | |
| for (let i = 0; i < bits.length; i += 11) | |
| words.push(WORDLIST[parseInt(bits.slice(i, i + 11), 2)]); | |
| return words.join(' '); | |
| } | |
| /** Validate a mnemonic (word membership + checksum). */ | |
| async function validateMnemonic(mnemonic) { | |
| if (WORDLIST.length !== 2048) return false; | |
| const words = mnemonic.normalize('NFKD').trim().split(/\s+/); | |
| if (![12, 15, 18, 21, 24].includes(words.length)) return false; | |
| let bits = ''; | |
| for (const w of words) { | |
| const idx = WORDLIST.indexOf(w); | |
| if (idx === -1) return false; | |
| bits += idx.toString(2).padStart(11, '0'); | |
| } | |
| const entBits = (words.length * 11 * 32) / 33; | |
| const csBits = entBits / 32; | |
| const ent = new Uint8Array(entBits / 8); | |
| for (let i = 0; i < ent.length; i++) | |
| ent[i] = parseInt(bits.slice(i * 8, i * 8 + 8), 2); | |
| const hash = await sha256(ent); | |
| for (let i = 0; i < csBits; i++) { | |
| const want = (hash[i >> 3] >> (7 - (i & 7))) & 1; | |
| if (want !== Number(bits[entBits + i])) return false; | |
| } | |
| return true; | |
| } | |
| // ── Ed25519 Verify ───────────────────────────────────────────── | |
| /** | |
| * Verify an Ed25519 signature (RFC 8032). | |
| * @param {string|Uint8Array} message | |
| * @param {Uint8Array} signature 64 bytes (R || S) | |
| * @param {Uint8Array} publicKey 32 bytes | |
| * @returns {Promise<boolean>} | |
| */ | |
| async function verify(message, signature, publicKey) { | |
| const msg = typeof message === 'string' ? new TextEncoder().encode(message) : message; | |
| if (signature.length !== 64) return false; | |
| if (publicKey.length !== 32) return false; | |
| const R_enc = signature.slice(0, 32); | |
| const S = bytesToBigIntLE(signature.slice(32, 64)); | |
| if (S >= ED_L) return false; | |
| let A, R; | |
| try { A = ptDecode(publicKey); R = ptDecode(R_enc); } | |
| catch { return false; } | |
| const hram = await sha512(concat(R_enc, publicKey, msg)); | |
| const k = mod(bytesToBigIntLE(hram), ED_L); | |
| // Check: [S]B == R + [k]A | |
| const lhs = scalarMult(S, BASE_PT); | |
| const rhs = ptAdd(R, scalarMult(k, A)); | |
| return ptEqual(lhs, rhs); | |
| } | |
| // ── Hex helpers ─────────────────────────────────────────────── | |
| function hexToBytes(hex) { | |
| const b = new Uint8Array(hex.length / 2); | |
| for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.substr(i * 2, 2), 16); | |
| return b; | |
| } | |
| function bytesToHex(bytes) { | |
| return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); | |
| } | |
| // ── Public API ──────────────────────────────────────────────── | |
| return { | |
| FIRST_HARDENED_INDEX: FIRST_HARDENED_INDEX, | |
| ACCOUNT_PATH: ACCOUNT_PATH, | |
| deriveAccount: deriveAccount, | |
| deriveForPath: deriveForPath, | |
| newMasterNode: newMasterNode, | |
| unmarshalNode: unmarshalNode, | |
| isValidPath: isValidPath, | |
| mnemonicToSeed: mnemonicToSeed, | |
| generateMnemonic: generateMnemonic, | |
| validateMnemonic: validateMnemonic, | |
| verify: verify, | |
| decodeAccount: decodeAccount, | |
| hexToBytes: hexToBytes, | |
| bytesToHex: bytesToHex, | |
| }; | |
| }); | |
| </script> | |
| <script> | |
| (function () { | |
| const $ = id => document.getElementById(id); | |
| let currentNode = null; | |
| let currentAddr = null; | |
| // ── Toggle mnemonic visibility ── | |
| $('toggle-mn').onclick = function () { | |
| const inp = $('mnemonic'); | |
| const hidden = inp.type === 'password'; | |
| inp.type = hidden ? 'text' : 'password'; | |
| this.textContent = hidden ? 'hide' : 'show'; | |
| }; | |
| // ── Validate mnemonic on input ── | |
| $('mnemonic').addEventListener('input', async function () { | |
| const val = this.value.trim(); | |
| const status = $('mn-status'); | |
| const btn = $('btn-derive'); | |
| if (!val) { status.textContent = ''; btn.disabled = true; return; } | |
| const valid = await slip10.validateMnemonic(val); | |
| status.textContent = valid ? 'Valid mnemonic' : 'Invalid mnemonic'; | |
| status.style.color = valid ? '#22c55e' : '#ef4444'; | |
| btn.disabled = !valid; | |
| }); | |
| // ── Generate mnemonic ── | |
| $('btn-gen').onclick = async function () { | |
| const mn = await slip10.generateMnemonic(128); | |
| $('mnemonic').value = mn; | |
| $('mnemonic').dispatchEvent(new Event('input')); | |
| }; | |
| // ── Derive account ── | |
| $('btn-derive').onclick = async function () { | |
| this.disabled = true; | |
| this.textContent = 'Deriving…'; | |
| try { | |
| const mn = $('mnemonic').value.trim(); | |
| const seed = await slip10.mnemonicToSeed(mn); | |
| currentNode = await slip10.deriveAccount(seed); | |
| const kp = await currentNode.keypair(); | |
| currentAddr = await currentNode.account(); | |
| $('out-addr').textContent = currentAddr; | |
| $('out-pub').textContent = slip10.bytesToHex(kp.publicKey); | |
| $('sec-account').style.display = ''; | |
| $('sec-sign').style.display = ''; | |
| // Pre-fill verify account address for convenience | |
| $('ver-pub').value = currentAddr; | |
| } catch (e) { | |
| $('out-addr').textContent = 'Error: ' + e.message; | |
| $('out-addr').className = 'out err'; | |
| } | |
| this.disabled = false; | |
| this.textContent = 'Derive Account'; | |
| }; | |
| // ── Sign ── | |
| $('btn-sign').onclick = async function () { | |
| if (!currentNode) return; | |
| const msg = $('sign-msg').value; | |
| if (!msg) return; | |
| this.disabled = true; | |
| this.textContent = 'Signing…'; | |
| try { | |
| const sig = await currentNode.sign(msg); | |
| const hex = slip10.bytesToHex(sig); | |
| $('out-sig').textContent = hex; | |
| $('out-sig').className = 'out'; | |
| // Auto-fill verify fields | |
| $('ver-msg').value = msg; | |
| $('ver-sig').value = hex; | |
| $('ver-pub').value = currentAddr; | |
| } catch (e) { | |
| $('out-sig').textContent = 'Error: ' + e.message; | |
| $('out-sig').className = 'out err'; | |
| } | |
| this.disabled = false; | |
| this.textContent = 'Sign Message'; | |
| }; | |
| // ── Verify ── | |
| $('btn-verify').onclick = async function () { | |
| const outEl = $('out-verify'); | |
| const msg = $('ver-msg').value; | |
| const sigHex = $('ver-sig').value.trim(); | |
| const addr = $('ver-pub').value.trim(); | |
| if (!msg || !sigHex || !addr) { | |
| outEl.textContent = 'Fill in all fields'; | |
| outEl.className = 'out err'; | |
| return; | |
| } | |
| if (sigHex.length !== 128 || !/^[0-9a-fA-F]+$/.test(sigHex)) { | |
| outEl.textContent = 'Signature must be 128 hex characters (64 bytes)'; | |
| outEl.className = 'out err'; | |
| return; | |
| } | |
| this.disabled = true; | |
| this.textContent = 'Verifying…'; | |
| try { | |
| const pub = slip10.decodeAccount(addr); | |
| const sig = slip10.hexToBytes(sigHex); | |
| const ok = await slip10.verify(msg, sig, pub); | |
| outEl.textContent = ok ? 'Signature is valid' : 'Signature is INVALID'; | |
| outEl.className = ok ? 'out ok' : 'out err'; | |
| } catch (e) { | |
| outEl.textContent = 'Error: ' + e.message; | |
| outEl.className = 'out err'; | |
| } | |
| this.disabled = false; | |
| this.textContent = 'Verify'; | |
| }; | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment