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
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ RATE_LIMIT_PM ?= 200
# ------------------------
# Pin versions
# ------------------------
CDK_IMAGE_RC ?= cashubtc/mintd:0.14.3
CDK_IMAGE ?= cashubtc/mintd:v0.16.0
CDK_IMAGE_RC ?= cashubtc/mintd:0.17.3-rc.0
CDK_IMAGE ?= cashubtc/mintd:0.17.3
CDK_NAME ?= cashu-dev-cdk

NUT_IMAGE_RC ?= cashubtc/nutshell:0.18.2
NUT_IMAGE ?= cashubtc/nutshell:0.20.0
NUT_IMAGE ?= cashubtc/nutshell:0.20.3
NUT_NAME ?= cashu-dev-nutshell

# ------------------------
Expand Down
13 changes: 3 additions & 10 deletions etc/cashu-ts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1885,19 +1885,16 @@ export type SigAllApi = {
extractSwapPackage: (preview: SwapPreview) => SigAllSigningPackage;
extractMeltPackage: <TQuote extends Pick<MeltQuoteBaseResponse, 'quote'>>(preview: MeltPreview<TQuote>) => SigAllSigningPackage;
serializePackage: (pkg: SigAllSigningPackage) => string;
deserializePackage: (input: string, options?: {
validateDigest?: boolean;
}) => SigAllSigningPackage;
deserializePackage: (input: string) => SigAllSigningPackage;
signPackage: (pkg: SigAllSigningPackage, privkey: string) => SigAllSigningPackage;
signDigest: (hexDigest: string, privkey: string) => string;
mergeSwapPackage: (pkg: SigAllSigningPackage, preview: SwapPreview) => SwapPreview;
mergeMeltPackage: <TQuote extends Pick<MeltQuoteBaseResponse, 'quote'>>(pkg: SigAllSigningPackage, preview: MeltPreview<TQuote>) => MeltPreview<TQuote>;
};

// @public (undocumented)
// @public
export type SigAllDigests = {
legacy: string;
current: string;
v0: string;
};

// @public
Expand All @@ -1907,10 +1904,6 @@ export type SigAllSigningPackage = {
quote?: string;
inputs: Array<Pick<Proof, 'secret' | 'C'>>;
outputs: SerializedBlindedMessage[];
digests: {
legacy?: string;
current: string;
};
witness?: {
signatures: string[];
};
Expand Down
38 changes: 2 additions & 36 deletions src/crypto/NUT11.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,7 @@ export function assertSigAllInputs(inputs: Proof[]): void {
}

/**
* Message aggregation for SIG_ALL.
* Message aggregation for SIG_ALL (v0, unframed concatenation).
*
* NOTE: Use `assertSigAllInputs()` to ensure valid message inputs.
*
Expand All @@ -692,7 +692,7 @@ export function assertSigAllInputs(inputs: Proof[]): void {
* @param quoteId Optional. Quote id for Melt transactions.
* @internal
*/
export function buildP2PKSigAllMessage(
export function buildP2PKSigAllMessageV0(
inputs: Array<Pick<Proof, 'secret' | 'C'>>,
outputs: Array<Pick<OutputDataLike, 'blindedMessage'>>,
quoteId?: string,
Expand Down Expand Up @@ -856,37 +856,3 @@ function resolveNSigsRefund(secret: Secret, lockState: LockState, refundKeys: st
}
return 0; // refund lock inactive
}

// ------------------------------
// Deprecated
// ------------------------------

/**
* Message aggregation for SIG_ALL (legacy format).
*
* @remarks
* Melt transactions MUST include the quoteId.
*
* For compatibility with NutShell (all releases), CDK <v0.14.0.
* @internal
*/
export function buildLegacyP2PKSigAllMessage(
inputs: Array<Pick<Proof, 'secret'>>,
outputs: Array<Pick<OutputDataLike, 'blindedMessage'>>,
quoteId?: string,
): string {
const parts: string[] = [];
// Concat inputs: secret_0 ...
for (const p of inputs) {
parts.push(p.secret);
}
// Concat outputs: B_0 ...
for (const o of outputs) {
parts.push(o.blindedMessage.B_);
}
// Add quoteId for melts
if (quoteId) {
parts.push(quoteId);
}
return parts.join('');
}
131 changes: 45 additions & 86 deletions src/model/SigAll.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
computeMessageDigest,
buildLegacyP2PKSigAllMessage,
buildP2PKSigAllMessage,
schnorrSignDigest,
} from '../crypto';
import { computeMessageDigest, buildP2PKSigAllMessageV0, schnorrSignDigest } from '../crypto';
import { parseWitnessData } from '../crypto/NUT11';
import { Bytes, JSONInt, encodeUint8toBase64Url } from '../utils';
import type { MeltPreview, SwapPreview } from '../wallet/types';
Expand All @@ -18,11 +13,15 @@ import type { Proof, MeltQuoteBaseResponse, SerializedBlindedMessage } from './t
const SIGALL_PREFIX = 'sigallA';

/**
* Per-format SIG_ALL digests, keyed by transcript version.
*
* @experimental
*/
export type SigAllDigests = {
legacy: string;
current: string;
/**
* Unframed concatenation format (CDK >= 0.14.0, Nutshell > 0.20.2).
*/
v0: string;
};

/**
Expand All @@ -43,7 +42,7 @@ export type SigAllSigningPackage = {
*/
type: 'swap' | 'melt';
/**
* For melt packages only.
* Required for melt packages, absent for swaps.
*/
quote?: string;
/**
Expand All @@ -54,19 +53,6 @@ export type SigAllSigningPackage = {
* NUT-00 `BlindedMessages` for signing verification.
*/
outputs: SerializedBlindedMessage[];
/**
* Per-format digests to support multiple SIG_ALL formats.
*/
digests: {
/**
* For Nutshell (all releases), CDK < 0.14.0.
*/
legacy?: string;
/**
* From CDK >= 0.14.0.
*/
current: string;
};
/**
* Signatures collected (to be injected into the first proof witness).
*/
Expand All @@ -79,12 +65,10 @@ function computeDigests(
quoteId?: string,
): SigAllDigests {
const sigAllOutputs = outputs.map((blindedMessage) => ({ blindedMessage }));
const legacyMsg = buildLegacyP2PKSigAllMessage(inputs, sigAllOutputs, quoteId);
const currentMsg = buildP2PKSigAllMessage(inputs, sigAllOutputs, quoteId);
const v0Msg = buildP2PKSigAllMessageV0(inputs, sigAllOutputs, quoteId);

return {
legacy: computeMessageDigest(legacyMsg, true),
current: computeMessageDigest(currentMsg, true),
v0: computeMessageDigest(v0Msg, true),
};
}

Expand All @@ -97,7 +81,6 @@ function serializePackage(pkg: SigAllSigningPackage): string {
ordered.inputs = pkg.inputs;
ordered.outputs = pkg.outputs;

if (pkg.digests) ordered.digests = pkg.digests;
if (pkg.witness) ordered.witness = pkg.witness;

const json = JSONInt.stringify(ordered) ?? '{}';
Expand All @@ -106,10 +89,7 @@ function serializePackage(pkg: SigAllSigningPackage): string {
return `${SIGALL_PREFIX}${base64url}`;
}

function deserializePackage(
input: string,
options?: { validateDigest?: boolean },
): SigAllSigningPackage {
function deserializePackage(input: string): SigAllSigningPackage {
if (!input.startsWith(SIGALL_PREFIX)) {
throw new CTSError(`Invalid signing package: must start with "${SIGALL_PREFIX}"`);
}
Expand Down Expand Up @@ -153,6 +133,12 @@ function deserializePackage(
throw new CTSError(`Invalid signing package type: ${type}`);
}

// The quote is part of the signed melt transcript; without it a melt package
// would produce a swap-shaped message.
if (type === 'melt' && (typeof pkg.quote !== 'string' || pkg.quote.length === 0)) {
throw new CTSError('Melt signing package requires a quote');
}

if (!Array.isArray(pkg.inputs)) {
throw new CTSError('Signing package inputs must be an array');
}
Expand Down Expand Up @@ -188,42 +174,34 @@ function deserializePackage(
output.amount = Amount.from(output.amount);
}

const digests = pkg.digests as Record<string, string> | undefined;
if (!digests || typeof digests.current !== 'string' || digests.current.length === 0) {
throw new CTSError('Signing package digests.current is required');
}

// Optional digest validation
if (options?.validateDigest) {
const recomputed = computeDigests(pkg.inputs, pkg.outputs, pkg.quote);
if (recomputed.current !== digests.current) {
throw new CTSError('Digest validation failed: current digest mismatch');
}
if (digests.legacy && recomputed.legacy !== digests.legacy) {
throw new CTSError('Digest validation failed: legacy digest mismatch');
if (pkg.witness !== undefined) {
const witness = pkg.witness as { signatures?: unknown };
if (
!witness ||
typeof witness !== 'object' ||
!Array.isArray(witness.signatures) ||
witness.signatures.some((s) => typeof s !== 'string')
) {
throw new CTSError('Signing package witness.signatures must be a string array');
}
}

return pkg;
// Rebuild from validated fields only, so unknown keys never survive transport.
return {
version: SIGALL_PREFIX,
type,
...(type === 'melt' ? { quote: pkg.quote } : {}),
inputs: pkg.inputs.map((p) => ({ secret: p.secret, C: p.C })),
outputs: pkg.outputs.map((o) => ({ amount: o.amount, id: o.id, B_: o.B_ })),
...(pkg.witness ? { witness: { signatures: pkg.witness.signatures } } : {}),
};
}

function signPackage(pkg: SigAllSigningPackage, privkey: string): SigAllSigningPackage {
const newSigs: string[] = [];

if (!pkg.digests?.current) {
throw new CTSError('digests.current is required to sign package');
}

// Sign precomputed digests
newSigs.push(schnorrSignDigest(pkg.digests.current, privkey));
if (pkg.digests.legacy) {
newSigs.push(schnorrSignDigest(pkg.digests.legacy, privkey));
}

// validate that signing actually produced signatures
if (newSigs.length === 0) {
throw new CTSError('No signatures produced during signing');
}
// Sign transcripts recomputed from the package contents; a signer only ever
// signs what the package shows, never a digest chosen elsewhere.
const digests = computeDigests(pkg.inputs, pkg.outputs, pkg.quote);
const newSigs = [schnorrSignDigest(digests.v0, privkey)];

return {
...pkg,
Expand Down Expand Up @@ -258,27 +236,12 @@ function buildSigningPackage(
outputs: SerializedBlindedMessage[],
quoteId?: string,
): SigAllSigningPackage {
// compute legacy and current SIG_ALL digests for backward compatibility
const digests = computeDigests(inputs, outputs, quoteId);

// verify current digest was computed correctly (catches bugs).
const sigAllOutputs = outputs.map((blindedMessage) => ({ blindedMessage }));
const msg = buildP2PKSigAllMessage(inputs, sigAllOutputs, quoteId);
const expected = computeMessageDigest(msg, true);

if (digests.current !== expected) {
throw new CTSError(
'SIG_ALL digest computation mismatch - current digest does not match expected value',
);
}

return {
version: SIGALL_PREFIX,
type,
...(quoteId ? { quote: quoteId } : {}),
inputs: inputs.map((p) => ({ secret: p.secret, C: p.C })),
outputs,
digests,
};
}

Expand Down Expand Up @@ -323,14 +286,12 @@ function mergeSignatures(proofs: Proof[], pkg: SigAllSigningPackage): Proof[] {
*/
export type SigAllApi = {
/**
* Computes legacy and current SIG_ALL formats.
* Computes the SIG_ALL digests for a transaction, keyed by transcript version.
*
* @remarks
* Returns hex-encoded SHA256 digests for each format to support multi-format signing.
* @param inputs Proof array.
* @param outputs Array of SerializedBlindMessage (NUT-00 `BlindMessages`).
* @param quoteId Optional quote ID for melt transactions.
* @returns Object with legacy, and current digests (all hex strings)
* @returns Hex-encoded SHA256 digest per format.
* @experimental
*/
computeDigests: (
Expand Down Expand Up @@ -379,19 +340,17 @@ export type SigAllApi = {

/**
* @remarks
* Accepts a sigallA-prefixed base64url string and rehydrates it into a SigAllSigningPackage.
* Accepts a sigallA-prefixed base64url string and rehydrates it into a SigAllSigningPackage. Only
* known fields survive the round trip.
* @experimental
*/
deserializePackage: (
input: string,
options?: { validateDigest?: boolean },
) => SigAllSigningPackage;
deserializePackage: (input: string) => SigAllSigningPackage;

/**
* Signs a SigAllSigningPackage and returns it with signatures attached.
*
* @remarks
* Collects signatures by signing legacy and current SIG_ALL formats for backward compatibility.
* Signs the SIG_ALL transcripts recomputed from the package's own inputs, outputs and quote.
* Multiple parties can call this sequentially to aggregate signatures for multi-party signing.
* @param pkg The signing package (from extract*SigningPackage or another signer)
* @param privkey Private key to sign with.
Expand Down
12 changes: 4 additions & 8 deletions src/wallet/Wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ import {
signP2PKProofs as cryptoSignP2PKProofs,
hashToCurve,
isP2PKSigAll,
buildP2PKSigAllMessage,
buildP2PKSigAllMessageV0,
assertSigAllInputs,
buildLegacyP2PKSigAllMessage,
parseSecret,
} from '../crypto';
import { signMintQuoteAmended } from '../crypto/NUT20';
Expand Down Expand Up @@ -1483,14 +1482,11 @@ class Wallet {
this.failIfNullish(outputData, 'OutputData is required for SIG_ALL proof signing.');
assertSigAllInputs(normalizedProofs);

// SIG_ALL is in flux currently, so let's generate all known message formats
// and sign the first proof only against each message...
// SIG_ALL is in flux currently, so sign the first proof only against each
// supported message format...
const [first, ...rest] = normalizedProofs;
let signedFirst = first;
const messages = [
buildLegacyP2PKSigAllMessage(normalizedProofs, outputData, quoteId),
buildP2PKSigAllMessage(normalizedProofs, outputData, quoteId),
];
const messages = [buildP2PKSigAllMessageV0(normalizedProofs, outputData, quoteId)];
for (const msg of messages) {
signedFirst = cryptoSignP2PKProofs([signedFirst], privkey, this._logger, msg)[0];
}
Expand Down
Loading
Loading