UNPKG

@scure/sr25519

Version:

Audited & minimal implementation of sr25519 (polkadot) cryptography, with Merlin and Strobe

474 lines 17.8 kB
/** * Minimal JS implementation of sr25519 cryptography for Polkadot. * * Uses [Merlin](https://merlin.cool/index.html), * a transcript construction, built on [Strobe](https://strobe.sourceforge.io). * Merlin ensures two parties agree on the same state when communicating. * * More: https://wiki.polkadot.network/docs/learn-cryptography. */ import { mod } from '@noble/curves/abstract/modular.js'; import { ed25519, ristretto255, ristretto255_hasher } from '@noble/curves/ed25519.js'; import { aInRange, bitMask, bytesToNumberLE, equalBytes, isBytes, numberToBytesLE, } from '@noble/curves/utils.js'; import { sha512 } from '@noble/hashes/sha2.js'; import { keccakP } from '@noble/hashes/sha3.js'; import { concatBytes, randomBytes, u32, utf8ToBytes } from '@noble/hashes/utils.js'; // prettier-ignore const _0n = BigInt(0), _3n = BigInt(3); const RistrettoPoint = ristretto255.Point; function toData(d) { if (typeof d === 'string') return utf8ToBytes(d); if (isBytes(d)) return d; throw new Error('Wrong data'); } // Could've used bytes from hashes/assert, but we add extra arg function abytes(title, b, ...lengths) { if (!isBytes(b)) throw new Error(`${title}: Uint8Array expected`); if (lengths.length && !lengths.includes(b.length)) throw new Error(`${title}: Uint8Array expected of length ${lengths}, not of length=${b.length}`); } function checkU32(title, n) { if (!Number.isSafeInteger(n) || n < 0 || n > 0xff_ff_ff_ff) throw new Error(`${title}: wrong u32 integer: ${n}`); return n; } function cleanBytes(...list) { for (const t of list) t.fill(0); } const EMPTY = Uint8Array.of(); const CURVE_ORDER = ed25519.Point.Fn.ORDER; function parseScalar(title, bytes) { abytes(title, bytes, 32); const n = bytesToNumberLE(bytes); aInRange(title, n, _0n, CURVE_ORDER); return n; } const modN = (n) => mod(n, CURVE_ORDER); // STROBE128 (minimal version required for Merlin) // - https://strobe.sourceforge.io/specs/ // We can implement full version, but seems nobody uses this much. const STROBE_R = 166; const Flags = { I: 1, A: 1 << 1, C: 1 << 2, T: 1 << 3, M: 1 << 4, K: 1 << 5, }; // Differences: suffix, additional methods/flags class Strobe128 { state = new Uint8Array(200); state32; pos = 0; posBegin = 0; curFlags = 0; constructor(protocolLabel) { this.state.set([1, STROBE_R + 2, 1, 0, 1, 96], 0); this.state.set(utf8ToBytes('STROBEv1.0.2'), 6); this.state32 = u32(this.state); this.keccakF1600(); this.metaAD(protocolLabel, false); } keccakF1600() { keccakP(this.state32); } runF() { this.state[this.pos] ^= this.posBegin; this.state[this.pos + 1] ^= 0x04; this.state[STROBE_R + 1] ^= 0x80; this.keccakF1600(); this.pos = 0; this.posBegin = 0; } // keccak.update() absorb(data) { for (let i = 0; i < data.length; i++) { this.state[this.pos++] ^= data[i]; if (this.pos === STROBE_R) this.runF(); } } // keccak.xof() squeeze(len) { const data = new Uint8Array(len); for (let i = 0; i < data.length; i++) { data[i] = this.state[this.pos]; this.state[this.pos++] = 0; if (this.pos === STROBE_R) this.runF(); } return data; } overwrite(data) { for (let i = 0; i < data.length; i++) { this.state[this.pos++] = data[i]; if (this.pos === STROBE_R) this.runF(); } } beginOp(flags, more) { if (more) { if (this.curFlags !== flags) { throw new Error(`Continued op with changed flags from ${this.curFlags.toString(2)} to ${flags.toString(2)}`); } return; } if ((flags & Flags.T) !== 0) throw new Error('T flag is not supported'); const oldBegin = this.posBegin; this.posBegin = this.pos + 1; this.curFlags = flags; this.absorb(new Uint8Array([oldBegin, flags])); const forceF = (flags & (Flags.C | Flags.K)) !== 0; if (forceF && this.pos !== 0) this.runF(); } // Public API metaAD(data, more) { this.beginOp(Flags.M | Flags.A, more); this.absorb(toData(data)); } AD(data, more) { this.beginOp(Flags.A, more); this.absorb(toData(data)); } PRF(len, more) { this.beginOp(Flags.I | Flags.A | Flags.C, more); return this.squeeze(len); } KEY(data, more) { this.beginOp(Flags.A | Flags.C, more); this.overwrite(toData(data)); } // Utils clone() { const n = new Strobe128('0'); // tmp n.pos = this.pos; n.posBegin = this.posBegin; n.state.set(this.state); n.curFlags = this.curFlags; return n; } clean() { this.state.fill(0); // also clears state32, because same buffer this.pos = 0; this.curFlags = 0; this.posBegin = 0; } } // /STROBE128 // Merlin // https://merlin.cool/index.html class Merlin { strobe; constructor(label) { this.strobe = new Strobe128('Merlin v1.0'); this.appendMessage('dom-sep', label); } appendMessage(label, message) { this.strobe.metaAD(label, false); checkU32('Merlin.appendMessage', message.length); this.strobe.metaAD(numberToBytesLE(message.length, 4), true); this.strobe.AD(message, false); } challengeBytes(label, len) { this.strobe.metaAD(label, false); checkU32('Merlin.challengeBytes', len); this.strobe.metaAD(numberToBytesLE(len, 4), true); return this.strobe.PRF(len, false); } clean() { this.strobe.clean(); } } // /Merlin // Merlin signging context/transcript (sr25519 specific stuff, Merlin and Strobe are generic (but minimal)) class SigningContext extends Merlin { constructor(name) { super(name); } label(label) { this.appendMessage('', label); } bytes(bytes) { this.appendMessage('sign-bytes', bytes); return this; } protoName(label) { this.appendMessage('proto-name', label); } commitPoint(label, point) { this.appendMessage(label, point.toBytes()); } challengeScalar(label) { return modN(bytesToNumberLE(this.challengeBytes(label, 64))); } witnessScalar(label, random, nonceSeeds = []) { return modN(bytesToNumberLE(this.witnessBytes(label, 64, random, nonceSeeds))); } witnessBytes(label, len, random, nonceSeeds = []) { checkU32('SigningContext.witnessBytes', len); const strobeRng = this.strobe.clone(); for (const ns of nonceSeeds) { strobeRng.metaAD(label, false); checkU32('SigningContext.witnessBytes nonce length', ns.length); strobeRng.metaAD(numberToBytesLE(ns.length, 4), true); strobeRng.KEY(ns, false); } abytes('random', random, 32); strobeRng.metaAD('rng', false); strobeRng.KEY(random, false); strobeRng.metaAD(numberToBytesLE(len, 4), false); return strobeRng.PRF(len, false); } } // /Merlin signing context const MASK = bitMask(256); // == (n * CURVE.h) % CURVE_BIT_MASK const encodeScalar = (n) => numberToBytesLE((n << _3n) & MASK, 32); // n / CURVE.h const decodeScalar = (n) => bytesToNumberLE(n) >> _3n; // NOTE: secretKey is 64 bytes (key + nonce). This required for HDKD, since key can be derived not only from seed, but from other keys. export function getPublicKey(secretKey) { abytes('secretKey', secretKey, 64); const scalar = decodeScalar(secretKey.subarray(0, 32)); return RistrettoPoint.BASE.multiply(scalar).toBytes(); } export function secretFromSeed(seed) { abytes('seed', seed, 32); const r = sha512(seed); // NOTE: different from ed25519 r[0] &= 248; r[31] &= 63; r[31] |= 64; // this will strip upper 3 bits and lower 3 bits const key = encodeScalar(decodeScalar(r.subarray(0, 32))); const nonce = r.subarray(32, 64); const res = concatBytes(key, nonce); cleanBytes(key, nonce, r); return res; } // Seems like ed25519 keypair? Generates keypair from other keypair in ed25519 format // NOTE: not exported from wasm. Do we need this at all? export function fromKeypair(pair) { abytes('keypair', pair, 96); const sk = pair.subarray(0, 32); const nonce = pair.subarray(32, 64); const pubBytes = pair.subarray(64, 96); const key = encodeScalar(bytesToNumberLE(sk)); const realPub = getPublicKey(pair.subarray(0, 64)); if (!equalBytes(pubBytes, realPub)) throw new Error('wrong public key'); // No need to clean since subarray's return concatBytes(key, nonce, realPub); } // Basic sign. NOTE: context is currently constant. Please open issue if you need different one. const SUBSTRATE_CONTEXT = utf8ToBytes('substrate'); export function sign(secretKey, message, random = randomBytes(32)) { abytes('message', message); abytes('secretKey', secretKey, 64); const t = new SigningContext('SigningContext'); t.label(SUBSTRATE_CONTEXT); t.bytes(message); const keyScalar = decodeScalar(secretKey.subarray(0, 32)); const nonce = secretKey.subarray(32, 64); const pubPoint = RistrettoPoint.fromBytes(getPublicKey(secretKey)); t.protoName('Schnorr-sig'); t.commitPoint('sign:pk', pubPoint); const r = t.witnessScalar('signing', random, [nonce]); const R = RistrettoPoint.BASE.multiply(r); t.commitPoint('sign:R', R); const k = t.challengeScalar('sign:c'); const s = modN(k * keyScalar + r); const res = concatBytes(R.toBytes(), numberToBytesLE(s, 32)); res[63] |= 128; // add Schnorrkel marker t.clean(); return res; } export function verify(message, signature, publicKey) { abytes('message', message); abytes('signature', signature, 64); abytes('publicKey', publicKey, 32); if ((signature[63] & 0b1000_0000) === 0) throw new Error('Schnorrkel marker missing'); const sBytes = Uint8Array.from(signature.subarray(32, 64)); // copy before modification sBytes[31] &= 0b0111_1111; // remove Schnorrkel marker const R = RistrettoPoint.fromBytes(signature.subarray(0, 32)); const s = bytesToNumberLE(sBytes); aInRange('s', s, _0n, CURVE_ORDER); // Just in case, it will be checked at multiplication later const t = new SigningContext('SigningContext'); t.label(SUBSTRATE_CONTEXT); t.bytes(message); const pubPoint = RistrettoPoint.fromBytes(publicKey); if (pubPoint.equals(RistrettoPoint.ZERO)) return false; t.protoName('Schnorr-sig'); t.commitPoint('sign:pk', pubPoint); t.commitPoint('sign:R', R); const k = t.challengeScalar('sign:c'); const sP = RistrettoPoint.BASE.multiply(s); const RR = pubPoint.negate().multiply(k).add(sP); t.clean(); cleanBytes(sBytes); return RR.equals(R); } export function getSharedSecret(secretKey, publicKey) { abytes('secretKey', secretKey, 64); abytes('publicKey', publicKey, 32); const keyScalar = decodeScalar(secretKey.subarray(0, 32)); const pubPoint = RistrettoPoint.fromBytes(publicKey); if (pubPoint.equals(RistrettoPoint.ZERO)) throw new Error('wrong public key (infinity)'); return pubPoint.multiply(keyScalar).toBytes(); } // Derive export const HDKD = { secretSoft(secretKey, chainCode, random = randomBytes(32)) { abytes('secretKey', secretKey, 64); abytes('chainCode', chainCode, 32); const masterScalar = decodeScalar(secretKey.subarray(0, 32)); const masterNonce = secretKey.subarray(32, 64); const pubPoint = RistrettoPoint.fromBytes(getPublicKey(secretKey)); const t = new SigningContext('SchnorrRistrettoHDKD'); t.bytes(EMPTY); t.appendMessage('chain-code', chainCode); t.commitPoint('public-key', pubPoint); const scalar = t.challengeScalar('HDKD-scalar'); const hdkdChainCode = t.challengeBytes('HDKD-chaincode', 32); const nonceSeed = concatBytes(numberToBytesLE(masterScalar, 32), masterNonce); const nonce = t.witnessBytes('HDKD-nonce', 32, random, [masterNonce, nonceSeed]); const key = encodeScalar(modN(masterScalar + scalar)); const res = concatBytes(key, nonce); cleanBytes(key, nonce, nonceSeed, hdkdChainCode); t.clean(); return res; }, publicSoft(publicKey, chainCode) { abytes('publicKey', publicKey, 32); abytes('chainCode', chainCode, 32); const pubPoint = RistrettoPoint.fromBytes(publicKey); const t = new SigningContext('SchnorrRistrettoHDKD'); t.bytes(EMPTY); t.appendMessage('chain-code', chainCode); t.commitPoint('public-key', pubPoint); const scalar = t.challengeScalar('HDKD-scalar'); t.challengeBytes('HDKD-chaincode', 32); t.clean(); return pubPoint.add(RistrettoPoint.BASE.multiply(scalar)).toBytes(); }, secretHard(secretKey, chainCode) { abytes('secretKey', secretKey, 64); abytes('chainCode', chainCode, 32); const key = numberToBytesLE(decodeScalar(secretKey.subarray(0, 32)), 32); const t = new SigningContext('SchnorrRistrettoHDKD'); t.bytes(EMPTY); t.appendMessage('chain-code', chainCode); t.appendMessage('secret-key', key); const msk = t.challengeBytes('HDKD-hard', 32); const hdkdChainCode = t.challengeBytes('HDKD-chaincode', 32); t.clean(); const res = secretFromSeed(msk); cleanBytes(key, msk, hdkdChainCode); t.clean(); return res; }, }; const dleq = { proove(keyScalar, nonce, pubPoint, t, input, output, random) { t.protoName('DLEQProof'); t.commitPoint('vrf:h', input); const r = t.witnessScalar(`proving${'\0'}0`, random, [nonce]); const R = RistrettoPoint.BASE.multiply(r); t.commitPoint('vrf:R=g^r', R); const Hr = input.multiply(r); t.commitPoint('vrf:h^r', Hr); t.commitPoint('vrf:pk', pubPoint); t.commitPoint('vrf:h^sk', output); const c = t.challengeScalar('prove'); const s = modN(r - c * keyScalar); return { proof: { c, s }, proofBatchable: { R, Hr, s } }; }, verify(pubPoint, t, input, output, proof) { if (pubPoint.equals(RistrettoPoint.ZERO)) return false; t.protoName('DLEQProof'); t.commitPoint('vrf:h', input); const R = pubPoint.multiply(proof.c).add(RistrettoPoint.BASE.multiply(proof.s)); t.commitPoint('vrf:R=g^r', R); const Hr = output.multiply(proof.c).add(input.multiply(proof.s)); t.commitPoint('vrf:h^r', Hr); t.commitPoint('vrf:pk', pubPoint); t.commitPoint('vrf:h^sk', output); const realC = t.challengeScalar('prove'); if (proof.c === realC) return { R, Hr, s: proof.s }; // proofBatchable return false; }, }; // VRF: Verifiable Random Function function initVRF(ctx, msg, extra, pubPoint) { const t = new SigningContext('SigningContext'); t.label(ctx); t.bytes(msg); t.commitPoint('vrf-nm-pk', pubPoint); const hash = t.challengeBytes('VRFHash', 64); const input = ristretto255_hasher.deriveToCurve(hash); const transcript = new SigningContext('VRF'); if (extra.length) transcript.label(extra); t.clean(); cleanBytes(hash); return { input, t: transcript }; } export const vrf = { sign(msg, secretKey, ctx = EMPTY, extra = EMPTY, random = randomBytes(32)) { abytes('msg', msg); abytes('secretKey', secretKey, 64); abytes('ctx', ctx); abytes('extra', extra); const keyScalar = decodeScalar(secretKey.subarray(0, 32)); const nonce = secretKey.subarray(32, 64); const pubPoint = RistrettoPoint.fromBytes(getPublicKey(secretKey)); const { input, t } = initVRF(ctx, msg, extra, pubPoint); const output = input.multiply(keyScalar); const p = { input, output }; const { proof } = dleq.proove(keyScalar, nonce, pubPoint, t, input, output, random); const cBytes = numberToBytesLE(proof.c, 32); const sBytes = numberToBytesLE(proof.s, 32); const res = concatBytes(p.output.toBytes(), cBytes, sBytes); cleanBytes(nonce, cBytes, sBytes); return res; }, verify(msg, signature, publicKey, ctx = EMPTY, extra = EMPTY) { abytes('msg', msg); abytes('signature', signature, 96); // O(point) || c(scalar) || s(scalar) abytes('pubkey', publicKey, 32); abytes('ctx', ctx); abytes('extra', extra); const pubPoint = RistrettoPoint.fromBytes(publicKey); if (pubPoint.equals(RistrettoPoint.ZERO)) return false; const proof = { c: parseScalar('signature.c', signature.subarray(32, 64)), s: parseScalar('signature.s', signature.subarray(64, 96)), }; const { input, t } = initVRF(ctx, msg, extra, pubPoint); const output = RistrettoPoint.fromBytes(signature.subarray(0, 32)); if (output.equals(RistrettoPoint.ZERO)) throw new Error('vrf.verify: wrong output point (identity)'); const proofBatchable = dleq.verify(pubPoint, t, input, output, proof); return proofBatchable === false ? false : true; }, }; // NOTE: for tests only, don't use export const __tests = { Strobe128, Merlin, SigningContext, }; //# sourceMappingURL=index.js.map