diff --git a/js/sign/README.md b/js/sign/README.md index 1f2f4ce2..8d84b88f 100644 --- a/js/sign/README.md +++ b/js/sign/README.md @@ -139,7 +139,7 @@ The base usage is: `wbn-sign [command] [options] ` - `add-signature `: Adds new signatures to an already signed bundle. - `remove-signature `: Removes signatures from a bundle. Keys can be public (Base64/.pem) or private (.pem). - `replace-signature `: Replaces an existing signature. -- `info `: Displays information about the integrity block, including the Web Bundle ID and public keys of signers. +- `info `: Displays information about the integrity block, including the Web Bundle ID, public keys of signers, their corresponding generated Web Bundle IDs, and whether each signature is correct. For more details, run `wbn-sign help [command]`. @@ -219,6 +219,9 @@ then you can bypass the passphrase prompt by storing the passphrase in an environment variable named `WEB_BUNDLE_SIGNING_PASSPHRASE`. ## Release Notes +### v0.3.2 +- Enhanced `info` command to display cryptographic validation status and the derived Web Bundle ID for each signature. + ### v0.3.1 - Enhanced **CLI**: New commands `add-signature`, `remove-signature`, `replace-signature`, and `info`. diff --git a/js/sign/package.json b/js/sign/package.json index 1c53b4d1..6359dcec 100644 --- a/js/sign/package.json +++ b/js/sign/package.json @@ -1,6 +1,6 @@ { "name": "wbn-sign", - "version": "0.3.1", + "version": "0.3.2", "description": "Tool to sign web bundles and manage signatures of signed web bundles.", "homepage": "https://github.com/WICG/webpackage/tree/main/js/sign", "main": "./lib/wbn-sign.cjs", diff --git a/js/sign/src/cli-sign.ts b/js/sign/src/cli-sign.ts index d7a68236..1392120b 100644 --- a/js/sign/src/cli-sign.ts +++ b/js/sign/src/cli-sign.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import { createRequire } from 'module'; import { Command } from 'commander'; +import pc from 'picocolors'; import { errorLog, @@ -103,6 +104,22 @@ async function parseArguments(): Promise { const signedWebBundle = SignedWebBundle.fromBytes(webBundle); signedWebBundle.printInfo(); + + const validations = signedWebBundle.validateSignatures(); + validations.forEach((val, i) => { + if (val.status === 'error') { + console.log(pc.red(`Signature ${i} validation failed: ${val.error}`)); + } else { + console.log( + `Signature ${i} derived Web Bundle ID: ${val.derivedBundleId}` + ); + console.log( + `Signature ${i} is correct: ${ + val.isValid ? pc.green('Yes') : pc.red('No') + }` + ); + } + }); }); program.commandsGroup('Signature management commands'); diff --git a/js/sign/src/core/integrity-block.ts b/js/sign/src/core/integrity-block.ts index adc27461..32999a06 100644 --- a/js/sign/src/core/integrity-block.ts +++ b/js/sign/src/core/integrity-block.ts @@ -177,7 +177,7 @@ export class IntegrityBlock { return encode([INTEGRITY_BLOCK_MAGIC, VERSION_B2, this.attributes, []]); } - private static parseSignatureAttributes( + static parseSignatureAttributes( attributes: SignatureAttributes ): [SignatureType, Uint8Array] { assert( diff --git a/js/sign/src/core/signed-web-bundle.ts b/js/sign/src/core/signed-web-bundle.ts index cfad6c98..21558cf1 100644 --- a/js/sign/src/core/signed-web-bundle.ts +++ b/js/sign/src/core/signed-web-bundle.ts @@ -1,12 +1,24 @@ import { assert } from 'console'; +import { encode } from 'cborg'; + import { IntegrityBlockSigner } from '../signers/integrity-block-signer.js'; import { ISigningStrategy } from '../signers/signing-strategy-interface.js'; import { warnLog } from '../utils/cli-utils.js'; -import { isSignedWebBundle } from '../utils/utils.js'; +import { + calcWebBundleHash, + generateDataToBeSigned, + isSignedWebBundle, + parseRawPublicKey, + verifySignature, +} from '../utils/utils.js'; import { WebBundleId } from '../web-bundle-id.js'; import { IntegrityBlock } from './integrity-block.js'; +export type SignatureValidationResult = + | { status: 'success'; derivedBundleId: string; isValid: boolean } + | { status: 'error'; error: string }; + export class SignedWebBundle { private constructor( private integrityBlock: IntegrityBlock, @@ -75,6 +87,38 @@ export class SignedWebBundle { return this.integrityBlock; } + validateSignatures(): SignatureValidationResult[] { + const webBundleHash = calcWebBundleHash(this.pureWebBundle); + const ibCbor = this.integrityBlock.toStrippedCbor(); + + return this.integrityBlock.getSignatureStack().map((signature) => { + try { + const [keyType, publicKey] = IntegrityBlock.parseSignatureAttributes( + signature.signatureAttributes + ); + const keyObject = parseRawPublicKey(keyType, publicKey); + const derivedBundleId = new WebBundleId(keyObject).serialize(); + const attrCbor = encode(signature.signatureAttributes); + const dataToBeSigned = generateDataToBeSigned( + webBundleHash, + ibCbor, + attrCbor + ); + const isValid = verifySignature( + dataToBeSigned, + signature.signature, + keyObject + ); + return { status: 'success', isValid, derivedBundleId }; + } catch (err) { + return { + status: 'error', + error: err instanceof Error ? err.message : String(err), + }; + } + }); + } + getWebBundleId(): string { return this.integrityBlock.getWebBundleId(); } diff --git a/js/sign/src/signers/integrity-block-signer.ts b/js/sign/src/signers/integrity-block-signer.ts index 3e938696..b62c25b4 100644 --- a/js/sign/src/signers/integrity-block-signer.ts +++ b/js/sign/src/signers/integrity-block-signer.ts @@ -9,7 +9,9 @@ import { type SignatureAttributes, } from '../core/integrity-block.js'; import { + calcWebBundleHash, checkIsValidKey, + generateDataToBeSigned, getPublicKeyAttributeName, getRawPublicKey, isPureWebBundle, @@ -88,7 +90,7 @@ export class IntegrityBlockSigner { async signAndGetIntegrityBlock(): Promise { const ibCbor = this.integrityBlock.toStrippedCbor(); checkDeterministic(ibCbor); - const webBundleHash = this.calcWebBundleHash(); + const webBundleHash = calcWebBundleHash(this.webBundle); // Append new signatures to the old stack for (const signingStrategy of this.signingStrategies) { @@ -101,7 +103,7 @@ export class IntegrityBlockSigner { const attrCbor = encode(newAttributes); checkDeterministic(attrCbor); - const dataToBeSigned = this.generateDataToBeSigned( + const dataToBeSigned = generateDataToBeSigned( webBundleHash, ibCbor, attrCbor @@ -136,47 +138,9 @@ export class IntegrityBlockSigner { return Number(buffer.readBigUint64BE()); } - // TODO: Move this method to SignedWebBundle/WebBundle class, signer do not need this, especially externally /** @deprecated This method will not be supported in a future release. */ calcWebBundleHash(): Uint8Array { - const hash = crypto.createHash('sha512'); - const data = hash.update(this.webBundle); - return new Uint8Array(data.digest()); - } - - /** @internal */ - generateDataToBeSigned( - webBundleHash: Uint8Array, - integrityBlockCborBytes: Uint8Array, - newAttributesCborBytes: Uint8Array - ): Uint8Array { - // The order is critical and must be the following: - // (0) hash of the bundle, - // (1) integrity block, and - // (2) attributes. - const dataParts = [ - webBundleHash, - integrityBlockCborBytes, - newAttributesCborBytes, - ]; - - const bigEndianNumLength = 8; - - const totalLength = dataParts.reduce((previous, current) => { - return previous + current.length; - }, /*one big endian num per part*/ dataParts.length * bigEndianNumLength); - const buffer = Buffer.alloc(totalLength); - - let offset = 0; - dataParts.forEach((d) => { - buffer.writeBigInt64BE(BigInt(d.length), offset); - offset += bigEndianNumLength; - - Buffer.from(d).copy(buffer, offset); - offset += d.length; - }); - - return new Uint8Array(buffer); + return calcWebBundleHash(this.webBundle); } /** @deprecated Moved to utils */ diff --git a/js/sign/src/utils/utils.ts b/js/sign/src/utils/utils.ts index de574521..6b517444 100644 --- a/js/sign/src/utils/utils.ts +++ b/js/sign/src/utils/utils.ts @@ -136,3 +136,82 @@ export function verifySignature( ); return isVerified; } + +export function parseRawPublicKey( + type: SignatureType, + rawPublicKey: Uint8Array +): KeyObject { + if (type === SignatureType.Ed25519) { + const jwk = { + kty: 'OKP', + crv: 'Ed25519', + x: Buffer.from(rawPublicKey).toString('base64url'), + }; + return crypto.createPublicKey({ key: jwk, format: 'jwk' }); + } else if (type === SignatureType.EcdsaP256SHA256) { + // Node.js doesn't have a built-in helper to parse raw ECDSA public key points synchronously + // without manual ASN.1 wrapping. As a cleaner alternative, we uncompress the point, slice + // the X and Y coordinates manually, and import it using the standardized JWK format. + const uncompressedPub = crypto.ECDH.convertKey( + rawPublicKey, + 'prime256v1', + /*inputEncoding=*/ undefined, + /*outputEncoding=*/ undefined, + 'uncompressed' + ) as Buffer; + + // uncompressedPub is a 65-byte Buffer. + // Byte 0 is the prefix (0x04), bytes 1-32 are X, bytes 33-64 are Y. + const x = uncompressedPub.subarray(1, 33); + const y = uncompressedPub.subarray(33, 65); + + const jwk = { + kty: 'EC', + crv: 'P-256', + x: Buffer.from(x).toString('base64url'), + y: Buffer.from(y).toString('base64url'), + }; + return crypto.createPublicKey({ key: jwk, format: 'jwk' }); + } + throw new Error('Unsupported signature type.'); +} + +export function calcWebBundleHash(webBundle: Uint8Array): Uint8Array { + const hash = crypto.createHash('sha512'); + const data = hash.update(webBundle); + return new Uint8Array(data.digest()); +} + +export function generateDataToBeSigned( + webBundleHash: Uint8Array, + integrityBlockCborBytes: Uint8Array, + newAttributesCborBytes: Uint8Array +): Uint8Array { + // The order is critical and must be the following: + // (0) hash of the bundle, + // (1) integrity block, and + // (2) attributes. + const dataParts = [ + webBundleHash, + integrityBlockCborBytes, + newAttributesCborBytes, + ]; + + const bigEndianNumLength = 8; + + const totalLength = dataParts.reduce((previous, current) => { + return previous + current.length; + }, /*one big endian num per part*/ dataParts.length * bigEndianNumLength); + const buffer = Buffer.alloc(totalLength); + + let offset = 0; + dataParts.forEach((d) => { + buffer.writeBigInt64BE(BigInt(d.length), offset); + offset += bigEndianNumLength; + + Buffer.from(d).copy(buffer, offset); + offset += d.length; + }); + + return new Uint8Array(buffer); +} diff --git a/js/sign/tests/integrity-block-signer_test.js b/js/sign/tests/integrity-block-signer_test.js index 4c69daf3..d5e6fbd1 100644 --- a/js/sign/tests/integrity-block-signer_test.js +++ b/js/sign/tests/integrity-block-signer_test.js @@ -149,7 +149,7 @@ describe('Integrity Block Signer', () => { const signer = initSignerWithTestWebBundleAndKeys([keypair.privateKey]); const rawPubKey = wbnSign.getRawPublicKey(keypair.publicKey); - const dataToBeSigned = signer.generateDataToBeSigned( + const dataToBeSigned = utils.generateDataToBeSigned( signer.calcWebBundleHash(), new wbnSign.IntegrityBlock().toCbor(), cborg.encode({ @@ -206,7 +206,7 @@ describe('Integrity Block Signer', () => { const ibWithoutSignatures = new wbnSign.IntegrityBlock(); ibWithoutSignatures.setWebBundleId(webBundleId); - const dataToBeSigned = signer.generateDataToBeSigned( + const dataToBeSigned = utils.generateDataToBeSigned( signer.calcWebBundleHash(), ibWithoutSignatures.toCbor(), cborg.encode(sigAttr) @@ -281,7 +281,7 @@ describe('Integrity Block Signer', () => { expect(Object.keys(signatureAttributes).length).toEqual(1); expect(signatureAttributes[attrName]).toEqual(rawPubKey); - const dataToBeSigned = signer.generateDataToBeSigned( + const dataToBeSigned = utils.generateDataToBeSigned( signer.calcWebBundleHash(), ibWithoutSignatures.toCbor(), cborg.encode(signatureAttributes) diff --git a/js/sign/tests/signed-web-bundle_test.js b/js/sign/tests/signed-web-bundle_test.js index bec9e8cf..ab93d5e7 100644 --- a/js/sign/tests/signed-web-bundle_test.js +++ b/js/sign/tests/signed-web-bundle_test.js @@ -107,4 +107,23 @@ describe('Signed Web Bundle - ', function () { bundle_signed_by_second_key ); }); + + it('validateSignatures() - correctly validates signatures and returns bundle ID', async function () { + const double_signed_bundle = await SignedWebBundle.fromWebBundle( + UNSIGNED_WEB_BUNDLE_BYTES, + [STRATEGY_KEY_1, STRATEGY_KEY_2] + ); + + const validations = double_signed_bundle.validateSignatures(); + expect(validations.length).toEqual(2); + + expect(validations[0].status).toEqual('success'); + expect(validations[0].isValid).toBe(true); + expect(validations[0].derivedBundleId).toEqual( + TEST_ED25519_WEB_BUNDLE_ID_1 + ); + + expect(validations[1].status).toEqual('success'); + expect(validations[1].isValid).toBe(true); + }); }); diff --git a/js/sign/tests/utils_test.js b/js/sign/tests/utils_test.js new file mode 100644 index 00000000..b455d160 --- /dev/null +++ b/js/sign/tests/utils_test.js @@ -0,0 +1,87 @@ +import crypto from 'crypto'; + +import { SignatureType } from '../lib/utils/constants.js'; +import * as utils from '../lib/utils/utils.js'; + +describe('Utils', () => { + describe('parseRawPublicKey', () => { + it('correctly parses an Ed25519 raw public key', () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); + // Export to raw bytes + const spki = publicKey.export({ type: 'spki', format: 'der' }); + const rawPublicKey = new Uint8Array(spki.subarray(-32)); + + const parsedKey = utils.parseRawPublicKey( + SignatureType.Ed25519, + rawPublicKey + ); + + expect(parsedKey.type).toBe('public'); + expect(parsedKey.asymmetricKeyType).toBe('ed25519'); + // Ensure the exported keys match + expect(parsedKey.export({ type: 'spki', format: 'der' })).toEqual(spki); + }); + + it('correctly parses an ECDSA P-256 raw public key (compressed)', () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + const spki = publicKey.export({ type: 'spki', format: 'der' }); + const uncompressedKey = spki.subarray(-65); + const rawPublicKey = crypto.ECDH.convertKey( + uncompressedKey, + 'prime256v1', + undefined, + undefined, + 'compressed' + ); + + const parsedKey = utils.parseRawPublicKey( + SignatureType.EcdsaP256SHA256, + new Uint8Array(rawPublicKey) + ); + + expect(parsedKey.type).toBe('public'); + expect(parsedKey.asymmetricKeyType).toBe('ec'); + // Check if it reconstructs exactly the same uncompressed SPKI format + expect(parsedKey.export({ type: 'spki', format: 'der' })).toEqual(spki); + }); + }); + + describe('calcWebBundleHash', () => { + it('calculates SHA-512 hash correctly', () => { + const payload = new Uint8Array([1, 2, 3, 4, 5]); + const expectedHash = crypto.createHash('sha512').update(payload).digest(); + const hash = utils.calcWebBundleHash(payload); + + expect(Buffer.from(hash)).toEqual(expectedHash); + }); + }); + + describe('generateDataToBeSigned', () => { + it('generates the correct payload for signing', () => { + const hash = new Uint8Array([0xaa, 0xbb]); + const ib = new Uint8Array([0x01, 0x02, 0x03]); + const attr = new Uint8Array([0x04]); + + const data = utils.generateDataToBeSigned(hash, ib, attr); + + // We expect 3 lengths (8 bytes each) followed by the actual data + const expectedLength = 8 + 2 + 8 + 3 + 8 + 1; // 30 bytes + expect(data.length).toBe(expectedLength); + + const view = new DataView(data.buffer); + // First part + expect(Number(view.getBigInt64(0, false))).toBe(2); + expect(data.slice(8, 10)).toEqual(hash); + + // Second part + expect(Number(view.getBigInt64(10, false))).toBe(3); + expect(data.slice(18, 21)).toEqual(ib); + + // Third part + expect(Number(view.getBigInt64(21, false))).toBe(1); + expect(data.slice(29, 30)).toEqual(attr); + }); + }); +});