Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions migration-5.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,27 @@ Existing v0 (legacy base64), v1 (`00…`), and v2 (`01…`) keysets continue to

---

## Crypto deep imports were reorganized

The internal crypto module layout changed to separate curve-specific primitives from shared
coordination code. The new curve files are implementation modules, not stable import targets. If
you were relying on existing file-level imports such as `crypto/core`, move those imports to the
public package entry point.

Use the public package entry point instead:

```ts
// Before
import { blindMessage, hashToCurve } from '@cashu/cashu-ts/crypto/core';

// After
import { blindMessage, hashToCurve } from '@cashu/cashu-ts';
```

If you were relying on a symbol that is not exported by the package entry point in v5, treat it as internal implementation detail and open an issue before depending on it.

---

## `checkProofsStates` now requires `id` on every proof

`Wallet.checkProofsStates` previously accepted `Array<Pick<Proof, 'secret'>>` — only `secret` was required. v5 requires both `id` and `secret`: `Array<Pick<Proof, 'secret' | 'id'>>`.
Expand Down
27 changes: 5 additions & 22 deletions src/crypto/NUT01.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { HDKey } from '@scure/bip32';
import { CTSError } from '../model/Errors';
import { deriveKeysetId } from '../utils';

import { BLS_G2_GENERATOR, hashToCurveBls } from './bls';
import { type UnblindedSignature, createRandomSecretKey, hashToCurve, isBlsKeyset } from './core';
import { type UnblindedSignature } from './core';
import { getG2PubKeyFromPrivKey, hashToCurveBls } from './curve_bls';
import { createRandomSecretKey, getPubKeyFromPrivKey, hashToCurve } from './curve_secp';
import { isBlsKeyset } from './curves';

const DERIVATION_PATH = "m/0'/0'/0'";

Expand Down Expand Up @@ -47,25 +49,6 @@ export function deserializeMintKeys(serializedMintKeys: SerializedMintKeys): Raw
return mintKeys;
}

export function getPubKeyFromPrivKey(privKey: Uint8Array): Uint8Array<ArrayBufferLike> {
return secp256k1.getPublicKey(privKey, true);
}

/**
* V3 (BLS) mint pubkey: K2 = a · G2_gen, compressed to 96 bytes.
*
* The 32-byte private key is interpreted as a big-endian scalar and reduced mod the BLS Fr order
* (same convention as the mint-side blind signer for v3).
*/
export function getG2PubKeyFromPrivKey(privKey: Uint8Array): Uint8Array<ArrayBufferLike> {
const a = bls12_381.fields.Fr.fromBytes(privKey);
/* c8 ignore next 3 — defensive guard; a==0 requires all-zero privKey bytes (impossible in practice). */
if (a === 0n) {
throw new CTSError('Mint scalar must be non-zero');
}
return BLS_G2_GENERATOR.multiply(a).toBytes(true);
}

/**
* Creates new mint keys.
*
Expand Down Expand Up @@ -137,7 +120,7 @@ export function createNewMintKeys(
*
* @remarks
* Dispatches by keyset version. v0/v1/v2 keysets use secp256k1; v3 keysets use BLS12-381 G1. The
* wallet-side pairing equivalent for v3 is {@link verifyUnblindedSignatureBls} in `./bls`.
* wallet-side pairing equivalent for v3 is {@link verifyUnblindedSignatureBls} in `./curve_bls`.
*/
export function verifyUnblindedSignature(proof: UnblindedSignature, privKey: Uint8Array): boolean {
if (isBlsKeyset(proof.id)) {
Expand Down
3 changes: 2 additions & 1 deletion src/crypto/NUT12.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { concatBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { CTSError } from '../model/Errors';
import { Bytes } from '../utils';

import { type DLEQ, hash_e, hashToCurve } from './core';
import { type DLEQ } from './core';
import { hash_e, hashToCurve } from './curve_secp';

const DST_R = utf8ToBytes('Cashu_DLEQ_R_v1');
const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');
Expand Down
4 changes: 2 additions & 2 deletions src/crypto/NUT13.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { HDKey } from '@scure/bip32';
import { CTSError } from '../model/Errors';
import { Bytes, isBase64String } from '../utils';

import { BLS_FR_ORDER } from './bls';
import { getKeysetIdInt, isBlsKeyset } from './core';
import { BLS_FR_ORDER } from './curve_bls';
import { getKeysetIdInt, isBlsKeyset } from './curves';

const STANDARD_DERIVATION_PATH = `m/129372'/0'`;

Expand Down
2 changes: 1 addition & 1 deletion src/crypto/NUT28.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
import { CTSError } from '../model/Errors';
import { Bytes, hexToNumber, numberToHexPadded64 } from '../utils';

import { pointFromHex } from './core';
import { pointFromHex } from './curve_secp';

/**
* BIP340-style domain separation tag (DST) for P2BK.
Expand Down
162 changes: 1 addition & 161 deletions src/crypto/core.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { type WeierstrassPoint } from '@noble/curves/abstract/weierstrass.js';
import { schnorr, secp256k1 } from '@noble/curves/secp256k1.js';
import { randomBytes, bytesToHex, hexToBytes } from '@noble/curves/utils.js';
import { bytesToHex, hexToBytes } from '@noble/curves/utils.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { utf8ToBytes } from '@noble/hashes/utils.js';

import { CTSError } from '../model/Errors';
import { Bytes, hexToNumber, encodeBase64toUint8, isValidHex } from '../utils';

import { type G1Point, pointFromHexG1 } from './bls';

/**
* Private key type - can be hex string or Uint8Array.
Expand Down Expand Up @@ -37,162 +33,6 @@ export type UnblindedSignature = {
id: string;
};

const DOMAIN_SEPARATOR = utf8ToBytes('Secp256k1_HashToCurve_Cashu_');

export function hashToCurve(secret: Uint8Array): WeierstrassPoint<bigint> {
const msgToHash = sha256(Bytes.concat(DOMAIN_SEPARATOR, secret));
const counter = new Uint32Array(1);
const maxIterations = 2 ** 16;
for (let i = 0; i < maxIterations; i++) {
const counterBytes = new Uint8Array(counter.buffer);
const hash = sha256(Bytes.concat(msgToHash, counterBytes));
try {
return pointFromHex(bytesToHex(Bytes.concat(new Uint8Array([0x02]), hash)));
} catch {
counter[0]++;
}
}
throw new CTSError('No valid point found');
}

export function hash_e(pubkeys: Array<WeierstrassPoint<bigint>>): Uint8Array {
const hexStrings = pubkeys.map((p) => p.toHex(false));
const e_ = hexStrings.join('');
return sha256(new TextEncoder().encode(e_));
}

export function pointFromBytes(bytes: Uint8Array) {
return secp256k1.Point.fromHex(bytesToHex(bytes));
}

export function pointFromHex(hex: string) {
return secp256k1.Point.fromHex(hex);
}

/**
* Tagged-union point covering both keyset curves on the wallet output / proof path.
*
* - `secp`: secp256k1 compressed point (33 bytes, 66 hex) — v0/v1/v2 keysets.
* - `blsG1`: BLS12-381 G1 compressed point (48 bytes, 96 hex) — v3 keysets.
*/
export type CurvePoint =
| { kind: 'secp'; pt: WeierstrassPoint<bigint> }
| { kind: 'blsG1'; pt: G1Point };

export function asSecpPoint(pt: WeierstrassPoint<bigint>): CurvePoint {
return { kind: 'secp', pt };
}

export function asBlsG1Point(pt: G1Point): CurvePoint {
return { kind: 'blsG1', pt };
}

/**
* Decode a compressed point hex string to a {@link CurvePoint}, picking the curve by length: 66 hex
* chars → secp256k1, 96 hex chars → BLS12-381 G1.
*
* Lengths are disjoint across the supported curves (secp uncompressed is 130; G2 compressed is
* 192), so there is no ambiguity.
*/
export function pointFromHexAuto(hex: string): CurvePoint {
if (hex.length === 66) return { kind: 'secp', pt: secp256k1.Point.fromHex(hex) };
if (hex.length === 96) return { kind: 'blsG1', pt: pointFromHexG1(hex) };
throw new CTSError(`Cannot decode point: unexpected hex length ${hex.length}`);
}

export function pointToHex(p: CurvePoint): string {
return p.pt.toHex(true);
}

/**
* True if `keysetId` is a v3 BLS12-381 keyset id (modern hex, version byte 0x02).
*
* @remarks
* Strict version gate: does not assume future keyset versions are BLS.
*/
export function isBlsKeyset(keysetId: string): boolean {
if (keysetId.length !== 16 && keysetId.length !== 66) return false;
if (!isValidHex(keysetId)) return false;
return keysetId.startsWith('02');
}

export const getKeysetIdInt = (keysetId: string): bigint => {
let keysetIdInt: bigint;
if (/^[a-fA-F0-9]+$/.test(keysetId)) {
keysetIdInt = hexToNumber(keysetId) % BigInt(2 ** 31 - 1);
} else {
//legacy keyset compatibility
keysetIdInt = Bytes.toBigInt(encodeBase64toUint8(keysetId)) % BigInt(2 ** 31 - 1);
}
return keysetIdInt;
};

export function createRandomSecretKey(): Uint8Array<ArrayBufferLike> {
return secp256k1.utils.randomSecretKey();
}

export function createBlindSignature(
B_: WeierstrassPoint<bigint>,
privateKey: Uint8Array,
id: string,
): BlindSignature {
const a = secp256k1.Point.Fn.fromBytes(privateKey);
const C_: WeierstrassPoint<bigint> = B_.multiply(a);
return { C_, id };
}

/**
* Creates a random blinded message.
*
* @remarks
* The secret is a UTF-8 encoded 64-character lowercase hex string, generated from 32 random bytes
* as recommended by NUT-00.
* @returns A RawBlindedMessage: {B_, r, secret}
*/
export function createRandomRawBlindedMessage(): RawBlindedMessage {
const secretStr = bytesToHex(randomBytes(32)); // 64 char ASCII hex string
const secretBytes = new TextEncoder().encode(secretStr); // UTF-8 of the hex
return blindMessage(secretBytes);
}

/**
* Blind a secret message.
*
* @param secret A UTF-8 byte encoded string.
* @param r Optional. Deterministic blinding scalar to use (eg: for testing / seeded)
* @returns A RawBlindedMessage: {B_, r, secret}
*/
export function blindMessage(secret: Uint8Array, r?: bigint): RawBlindedMessage {
const Y = hashToCurve(secret);
if (r === undefined) {
r = secp256k1.Point.Fn.fromBytes(createRandomSecretKey());
} else if (r === 0n) {
throw new CTSError('Blinding factor r must be non-zero');
}
const rG = secp256k1.Point.BASE.multiply(r);
const B_ = Y.add(rG);
return { B_, r, secret };
}

export function unblindSignature(
C_: WeierstrassPoint<bigint>,
r: bigint,
A: WeierstrassPoint<bigint>,
): WeierstrassPoint<bigint> {
const C = C_.subtract(A.multiply(r));
return C;
}

export function constructUnblindedSignature(
blindSig: BlindSignature,
r: bigint,
secret: Uint8Array,
key: WeierstrassPoint<bigint>,
): UnblindedSignature {
const C = unblindSignature(blindSig.C_, r, key);
return { id: blindSig.id, secret, C };
}

// ------------------------------
// Schnorr Signing / Verification
// ------------------------------
Expand Down
15 changes: 15 additions & 0 deletions src/crypto/bls.ts → src/crypto/curve_bls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,21 @@ export function pointFromHexG2(hex: string): G2Point {
return p;
}

/**
* V3 (BLS) mint pubkey: K2 = a · G2_gen, compressed to 96 bytes.
*
* The 32-byte private key is interpreted as a big-endian scalar and reduced mod the BLS Fr order
* (same convention as the mint-side blind signer for v3).
*/
export function getG2PubKeyFromPrivKey(privKey: Uint8Array): Uint8Array<ArrayBufferLike> {
const a = Fr.fromBytes(privKey);
/* c8 ignore next 3 — defensive guard; a==0 requires all-zero privKey bytes (impossible in practice). */
if (a === 0n) {
throw new CTSError('Mint scalar must be non-zero');
}
return BLS_G2_GENERATOR.multiply(a).toBytes(true);
}

function randomScalar(): bigint {
// bls12_381's Fr.fromBytes accepts 32 bytes BE and reduces mod ORDER.
return Fr.fromBytes(randomBytes(32));
Expand Down
Loading
Loading