From 884d91ef03c6a4bf49a1c68db2c1f4bab3101ff7 Mon Sep 17 00:00:00 2001 From: Zahin Mohammad Date: Thu, 9 Apr 2026 22:47:00 -0400 Subject: [PATCH] fix(sdk-lib-mpc): replace date:null with tolerance window in OpenPGP calls Remove `date: null as unknown as undefined` from OpenPGP encrypt/decrypt calls (use default current-time checks) and replace it with `now + 24h` on verify calls only, to tolerate signatures from OVC devices whose clocks are up to 24 hours ahead. OpenPGP's date parameter shifts ALL temporal checks simultaneously, so a single shifted date cannot independently relax key-expiry checks without breaking self-signature validation on fresh keys. Ticket: WAL-379 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/tss/ecdsa-dkls/commsLayer.ts | 34 +++++++-- .../test/unit/tss/ecdsa/dklsComms.ts | 72 ++++++++++++++++++- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/commsLayer.ts b/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/commsLayer.ts index 3ec85a2906..120d82f0f8 100644 --- a/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/commsLayer.ts +++ b/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/commsLayer.ts @@ -1,6 +1,34 @@ import { SerializedMessages, AuthEncMessage, AuthEncMessages, PartyGpgKey, AuthMessage } from './types'; import * as pgp from 'openpgp'; +/** + * Tolerance window for OpenPGP date-based key validity checks (24 hours). + * + * Background: OpenPGP.js uses the `date` parameter to check key expiry at a + * given point in time. We previously passed `date: null` to disable this check + * entirely (see HSM-706) because OVC cold-signing flows for trust and SMC + * clients can involve significant clock skew between the signing device and the + * server — the device may be air-gapped and its clock can drift by hours. + * + * Note: this GPG expiry check is not strictly required for replay protection. + * The DKLS protocol has its own mechanism for preventing replay attacks + * (session-bound commitments and round-specific message validation), so the + * OpenPGP date check is a defense-in-depth measure rather than the primary + * replay mitigation. + * + * OpenPGP's `date` parameter shifts the reference time for ALL temporal + * checks simultaneously (key expiry, self-signature validity, signature + * freshness). This means a single shifted date cannot independently relax + * key-expiry checks without breaking self-signature validation on fresh keys. + * + * Therefore: + * - encrypt/decrypt omit `date` (use default = current time) for normal key + * expiry checking and self-signature validation. + * - verify uses `now + tolerance` so that signatures from OVC devices whose + * clocks are up to 24 hours ahead are not rejected as "from the future". + */ +export const SIGNATURE_DATE_TOLERANCE_MS = 24 * 60 * 60 * 1000; + /** * Detach signs a binary and encodes it in base64 * @param data binary to encode in base64 and sign @@ -49,7 +77,6 @@ export async function encryptAndDetachSignData( showVersion: false, showComment: false, }, - date: null as unknown as undefined, }); const signature = await pgp.sign({ message, @@ -90,13 +117,12 @@ export async function decryptAndVerifySignedData( showComment: false, }, format: 'binary', - date: null as unknown as undefined, }); const verificationResult = await pgp.verify({ message: await pgp.createMessage({ binary: decryptedMessage.data }), signature: await pgp.readSignature({ armoredSignature: encryptedAndSignedMessage.signature }), verificationKeys: publicKey, - date: null as unknown as undefined, + date: new Date(Date.now() + SIGNATURE_DATE_TOLERANCE_MS), }); await verificationResult.signatures[0].verified; return Buffer.from(decryptedMessage.data).toString('base64'); @@ -113,7 +139,7 @@ export async function verifySignedData(signedMessage: AuthMessage, publicArmor: message: await pgp.createMessage({ binary: Buffer.from(signedMessage.message, 'base64') }), signature: await pgp.readSignature({ armoredSignature: signedMessage.signature }), verificationKeys: publicKey, - date: null as unknown as undefined, + date: new Date(Date.now() + SIGNATURE_DATE_TOLERANCE_MS), }); try { await verificationResult.signatures[0].verified; diff --git a/modules/sdk-lib-mpc/test/unit/tss/ecdsa/dklsComms.ts b/modules/sdk-lib-mpc/test/unit/tss/ecdsa/dklsComms.ts index 1190f27caf..79648652f0 100644 --- a/modules/sdk-lib-mpc/test/unit/tss/ecdsa/dklsComms.ts +++ b/modules/sdk-lib-mpc/test/unit/tss/ecdsa/dklsComms.ts @@ -1,4 +1,9 @@ -import { decryptAndVerifySignedData, encryptAndDetachSignData } from '../../../../src/tss/ecdsa-dkls/commsLayer'; +import { + decryptAndVerifySignedData, + encryptAndDetachSignData, + verifySignedData, + SIGNATURE_DATE_TOLERANCE_MS, +} from '../../../../src/tss/ecdsa-dkls/commsLayer'; import * as openpgp from 'openpgp'; describe('DKLS Communication Layer', function () { @@ -94,4 +99,69 @@ describe('DKLS Communication Layer', function () { .toHex()}` ); }); + + describe('signature date tolerance', function () { + it('should confirm tolerance constant is 24 hours', function () { + SIGNATURE_DATE_TOLERANCE_MS.should.equal(24 * 60 * 60 * 1000); + }); + + it('should reject encryption to an expired key', async function () { + // Key created 48h ago with a 23h lifetime → expired 25h ago. + const expiredKey = await openpgp.generateKey({ + userIDs: [{ name: 'expired', email: 'expired@username.com' }], + curve: 'secp256k1', + date: new Date(Date.now() - 48 * 60 * 60 * 1000), + keyExpirationTime: 23 * 3600, + }); + + await encryptAndDetachSignData( + Buffer.from('ffffffff', 'base64'), + expiredKey.publicKey, + senderKey.privateKey + ).should.be.rejectedWith('Error encrypting message: Primary key is expired'); + }); + + it('should accept verification of a signature created by a device whose clock is ahead', async function () { + // Simulate a signature created by a device whose clock is 12 hours + // ahead. The verify tolerance (now + 24h) should accept it. + const futureDate = new Date(Date.now() + 12 * 60 * 60 * 1000); + const message = await openpgp.createMessage({ binary: Buffer.from('ffffffff', 'base64') }); + const privateKey = await openpgp.readPrivateKey({ armoredKey: senderKey.privateKey }); + const signature = await openpgp.sign({ + message, + signingKeys: privateKey, + format: 'armored', + detached: true, + date: futureDate, + config: { rejectCurves: new Set(), showVersion: false, showComment: false }, + }); + + const result = await verifySignedData( + { message: Buffer.from('ffffffff', 'base64').toString('base64'), signature }, + senderKey.publicKey + ); + result.should.equal(true); + }); + + it('should reject verification of a signature created more than 24h in the future', async function () { + // Simulate a signature from a device whose clock is 25 hours ahead. + const farFutureDate = new Date(Date.now() + 25 * 60 * 60 * 1000); + const message = await openpgp.createMessage({ binary: Buffer.from('ffffffff', 'base64') }); + const privateKey = await openpgp.readPrivateKey({ armoredKey: senderKey.privateKey }); + const signature = await openpgp.sign({ + message, + signingKeys: privateKey, + format: 'armored', + detached: true, + date: farFutureDate, + config: { rejectCurves: new Set(), showVersion: false, showComment: false }, + }); + + const result = await verifySignedData( + { message: Buffer.from('ffffffff', 'base64').toString('base64'), signature }, + senderKey.publicKey + ); + result.should.equal(false); + }); + }); });