Skip to content

Instantly share code, notes, and snippets.

@requilence
Created February 20, 2026 23:24
Show Gist options
  • Select an option

  • Save requilence/ad8e7857b7c23bd5bb2b25c401d7df3b to your computer and use it in GitHub Desktop.

Select an option

Save requilence/ad8e7857b7c23bd5bb2b25c401d7df3b to your computer and use it in GitHub Desktop.
Anytype account
<!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