Skip to content

Commit 9dc6b0b

Browse files
committed
feat(wbn-sign): add signature validation and web bundle ID to info command
- Display validation status and derived Web Bundle ID for each signature in `wbn-sign info` - Extract cryptographic utilities (hashing, data generation) into utils.ts - Move validation logic into SignedWebBundle and CLI layer - Add unit test coverage for utilities and validations
1 parent fc8eb22 commit 9dc6b0b

10 files changed

Lines changed: 253 additions & 48 deletions

js/sign/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ The base usage is: `wbn-sign [command] [options] <arguments...>`
139139
- `add-signature <signed_web_bundle> <private_keys...>`: Adds new signatures to an already signed bundle.
140140
- `remove-signature <signed_web_bundle> <keys...>`: Removes signatures from a bundle. Keys can be public (Base64/.pem) or private (.pem).
141141
- `replace-signature <signed_web_bundle> <old_key> <new_private_key>`: Replaces an existing signature.
142-
- `info <web_bundle>`: Displays information about the integrity block, including the Web Bundle ID and public keys of signers.
142+
- `info <web_bundle>`: 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.
143143

144144
For more details, run `wbn-sign help [command]`.
145145

@@ -219,6 +219,9 @@ then you can bypass the passphrase prompt by storing the passphrase in an
219219
environment variable named `WEB_BUNDLE_SIGNING_PASSPHRASE`.
220220
## Release Notes
221221

222+
### v0.3.2
223+
- Enhanced `info` command to display cryptographic validation status and the derived Web Bundle ID for each signature.
224+
222225
### v0.3.1
223226
- Enhanced **CLI**: New commands `add-signature`, `remove-signature`, `replace-signature`, and `info`.
224227

js/sign/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "wbn-sign",
3-
"version": "0.3.1",
3+
"version": "0.3.2",
44
"description": "Tool to sign web bundles and manage signatures of signed web bundles.",
55
"homepage": "https://github.com/WICG/webpackage/tree/main/js/sign",
66
"main": "./lib/wbn-sign.cjs",

js/sign/src/cli-sign.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as fs from 'fs';
33
import { createRequire } from 'module';
44

55
import { Command } from 'commander';
6+
import pc from 'picocolors';
67

78
import {
89
errorLog,
@@ -103,6 +104,24 @@ async function parseArguments(): Promise<void> {
103104
const signedWebBundle = SignedWebBundle.fromBytes(webBundle);
104105

105106
signedWebBundle.printInfo();
107+
108+
const validations = signedWebBundle.validateSignatures();
109+
validations.forEach((val, i) => {
110+
if (val.status === 'error') {
111+
console.log(
112+
pc.red(`Signature ${i} validation failed: ${val.error}`)
113+
);
114+
} else {
115+
console.log(
116+
`Signature ${i} derived Web Bundle ID: ${val.derivedBundleId}`
117+
);
118+
console.log(
119+
`Signature ${i} is correct: ${
120+
val.isValid ? pc.green('Yes') : pc.red('No')
121+
}`
122+
);
123+
}
124+
});
106125
});
107126

108127
program.commandsGroup('Signature management commands');

js/sign/src/core/integrity-block.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export class IntegrityBlock {
177177
return encode([INTEGRITY_BLOCK_MAGIC, VERSION_B2, this.attributes, []]);
178178
}
179179

180-
private static parseSignatureAttributes(
180+
static parseSignatureAttributes(
181181
attributes: SignatureAttributes
182182
): [SignatureType, Uint8Array] {
183183
assert(

js/sign/src/core/signed-web-bundle.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,24 @@
11
import { assert } from 'console';
22

3+
import { encode } from 'cborg';
4+
35
import { IntegrityBlockSigner } from '../signers/integrity-block-signer.js';
46
import { ISigningStrategy } from '../signers/signing-strategy-interface.js';
57
import { warnLog } from '../utils/cli-utils.js';
6-
import { isSignedWebBundle } from '../utils/utils.js';
8+
import {
9+
calcWebBundleHash,
10+
generateDataToBeSigned,
11+
isSignedWebBundle,
12+
parseRawPublicKey,
13+
verifySignature,
14+
} from '../utils/utils.js';
715
import { WebBundleId } from '../web-bundle-id.js';
816
import { IntegrityBlock } from './integrity-block.js';
917

18+
export type SignatureValidationResult =
19+
| { status: 'success'; derivedBundleId: string; isValid: boolean }
20+
| { status: 'error'; error: string };
21+
1022
export class SignedWebBundle {
1123
private constructor(
1224
private integrityBlock: IntegrityBlock,
@@ -75,6 +87,38 @@ export class SignedWebBundle {
7587
return this.integrityBlock;
7688
}
7789

90+
validateSignatures(): SignatureValidationResult[] {
91+
const webBundleHash = calcWebBundleHash(this.pureWebBundle);
92+
const ibCbor = this.integrityBlock.toStrippedCbor();
93+
94+
return this.integrityBlock.getSignatureStack().map((signature) => {
95+
try {
96+
const [keyType, publicKey] = IntegrityBlock.parseSignatureAttributes(
97+
signature.signatureAttributes
98+
);
99+
const keyObject = parseRawPublicKey(keyType, publicKey);
100+
const derivedBundleId = new WebBundleId(keyObject).serialize();
101+
const attrCbor = encode(signature.signatureAttributes);
102+
const dataToBeSigned = generateDataToBeSigned(
103+
webBundleHash,
104+
ibCbor,
105+
attrCbor
106+
);
107+
const isValid = verifySignature(
108+
dataToBeSigned,
109+
signature.signature,
110+
keyObject
111+
);
112+
return { status: 'success', isValid, derivedBundleId };
113+
} catch (err) {
114+
return {
115+
status: 'error',
116+
error: err instanceof Error ? err.message : String(err),
117+
};
118+
}
119+
});
120+
}
121+
78122
getWebBundleId(): string {
79123
return this.integrityBlock.getWebBundleId();
80124
}

js/sign/src/signers/integrity-block-signer.ts

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import {
99
type SignatureAttributes,
1010
} from '../core/integrity-block.js';
1111
import {
12+
calcWebBundleHash,
1213
checkIsValidKey,
14+
generateDataToBeSigned,
1315
getPublicKeyAttributeName,
1416
getRawPublicKey,
1517
isPureWebBundle,
@@ -88,7 +90,7 @@ export class IntegrityBlockSigner {
8890
async signAndGetIntegrityBlock(): Promise<IntegrityBlock> {
8991
const ibCbor = this.integrityBlock.toStrippedCbor();
9092
checkDeterministic(ibCbor);
91-
const webBundleHash = this.calcWebBundleHash();
93+
const webBundleHash = calcWebBundleHash(this.webBundle);
9294

9395
// Append new signatures to the old stack
9496
for (const signingStrategy of this.signingStrategies) {
@@ -101,7 +103,7 @@ export class IntegrityBlockSigner {
101103
const attrCbor = encode(newAttributes);
102104
checkDeterministic(attrCbor);
103105

104-
const dataToBeSigned = this.generateDataToBeSigned(
106+
const dataToBeSigned = generateDataToBeSigned(
105107
webBundleHash,
106108
ibCbor,
107109
attrCbor
@@ -136,47 +138,9 @@ export class IntegrityBlockSigner {
136138
return Number(buffer.readBigUint64BE());
137139
}
138140

139-
// TODO: Move this method to SignedWebBundle/WebBundle class, signer do not need this, especially externally
140141
/** @deprecated This method will not be supported in a future release. */
141142
calcWebBundleHash(): Uint8Array {
142-
const hash = crypto.createHash('sha512');
143-
const data = hash.update(this.webBundle);
144-
return new Uint8Array(data.digest());
145-
}
146-
147-
/** @internal */
148-
generateDataToBeSigned(
149-
webBundleHash: Uint8Array,
150-
integrityBlockCborBytes: Uint8Array,
151-
newAttributesCborBytes: Uint8Array
152-
): Uint8Array {
153-
// The order is critical and must be the following:
154-
// (0) hash of the bundle,
155-
// (1) integrity block, and
156-
// (2) attributes.
157-
const dataParts = [
158-
webBundleHash,
159-
integrityBlockCborBytes,
160-
newAttributesCborBytes,
161-
];
162-
163-
const bigEndianNumLength = 8;
164-
165-
const totalLength = dataParts.reduce((previous, current) => {
166-
return previous + current.length;
167-
}, /*one big endian num per part*/ dataParts.length * bigEndianNumLength);
168-
const buffer = Buffer.alloc(totalLength);
169-
170-
let offset = 0;
171-
dataParts.forEach((d) => {
172-
buffer.writeBigInt64BE(BigInt(d.length), offset);
173-
offset += bigEndianNumLength;
174-
175-
Buffer.from(d).copy(buffer, offset);
176-
offset += d.length;
177-
});
178-
179-
return new Uint8Array(buffer);
143+
return calcWebBundleHash(this.webBundle);
180144
}
181145

182146
/** @deprecated Moved to utils */

js/sign/src/utils/utils.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,82 @@ export function verifySignature(
136136
);
137137
return isVerified;
138138
}
139+
140+
export function parseRawPublicKey(
141+
type: SignatureType,
142+
rawPublicKey: Uint8Array
143+
): KeyObject {
144+
if (type === SignatureType.Ed25519) {
145+
const jwk = {
146+
kty: 'OKP',
147+
crv: 'Ed25519',
148+
x: Buffer.from(rawPublicKey).toString('base64url'),
149+
};
150+
return crypto.createPublicKey({ key: jwk, format: 'jwk' });
151+
} else if (type === SignatureType.EcdsaP256SHA256) {
152+
// Node.js doesn't have a built-in helper to parse raw ECDSA public key points synchronously
153+
// without manual ASN.1 wrapping. As a cleaner alternative, we uncompress the point, slice
154+
// the X and Y coordinates manually, and import it using the standardized JWK format.
155+
const uncompressedPub = crypto.ECDH.convertKey(
156+
rawPublicKey,
157+
'prime256v1',
158+
/*inputEncoding=*/ undefined,
159+
/*outputEncoding=*/ undefined,
160+
'uncompressed'
161+
) as Buffer;
162+
163+
// uncompressedPub is a 65-byte Buffer.
164+
// Byte 0 is the prefix (0x04), bytes 1-32 are X, bytes 33-64 are Y.
165+
const x = uncompressedPub.subarray(1, 33);
166+
const y = uncompressedPub.subarray(33, 65);
167+
168+
const jwk = {
169+
kty: 'EC',
170+
crv: 'P-256',
171+
x: Buffer.from(x).toString('base64url'),
172+
y: Buffer.from(y).toString('base64url'),
173+
};
174+
return crypto.createPublicKey({ key: jwk, format: 'jwk' });
175+
}
176+
throw new Error('Unsupported signature type.');
177+
}
178+
179+
export function calcWebBundleHash(webBundle: Uint8Array): Uint8Array {
180+
const hash = crypto.createHash('sha512');
181+
const data = hash.update(webBundle);
182+
return new Uint8Array(data.digest());
183+
}
184+
185+
export function generateDataToBeSigned(
186+
webBundleHash: Uint8Array,
187+
integrityBlockCborBytes: Uint8Array,
188+
newAttributesCborBytes: Uint8Array
189+
): Uint8Array {
190+
// The order is critical and must be the following:
191+
// (0) hash of the bundle,
192+
// (1) integrity block, and
193+
// (2) attributes.
194+
const dataParts = [
195+
webBundleHash,
196+
integrityBlockCborBytes,
197+
newAttributesCborBytes,
198+
];
199+
200+
const bigEndianNumLength = 8;
201+
202+
const totalLength = dataParts.reduce((previous, current) => {
203+
return previous + current.length;
204+
}, /*one big endian num per part*/ dataParts.length * bigEndianNumLength);
205+
const buffer = Buffer.alloc(totalLength);
206+
207+
let offset = 0;
208+
dataParts.forEach((d) => {
209+
buffer.writeBigInt64BE(BigInt(d.length), offset);
210+
offset += bigEndianNumLength;
211+
212+
Buffer.from(d).copy(buffer, offset);
213+
offset += d.length;
214+
});
215+
216+
return new Uint8Array(buffer);
217+
}

js/sign/tests/integrity-block-signer_test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ describe('Integrity Block Signer', () => {
149149
const signer = initSignerWithTestWebBundleAndKeys([keypair.privateKey]);
150150
const rawPubKey = wbnSign.getRawPublicKey(keypair.publicKey);
151151

152-
const dataToBeSigned = signer.generateDataToBeSigned(
152+
const dataToBeSigned = utils.generateDataToBeSigned(
153153
signer.calcWebBundleHash(),
154154
new wbnSign.IntegrityBlock().toCbor(),
155155
cborg.encode({
@@ -206,7 +206,7 @@ describe('Integrity Block Signer', () => {
206206
const ibWithoutSignatures = new wbnSign.IntegrityBlock();
207207
ibWithoutSignatures.setWebBundleId(webBundleId);
208208

209-
const dataToBeSigned = signer.generateDataToBeSigned(
209+
const dataToBeSigned = utils.generateDataToBeSigned(
210210
signer.calcWebBundleHash(),
211211
ibWithoutSignatures.toCbor(),
212212
cborg.encode(sigAttr)
@@ -281,7 +281,7 @@ describe('Integrity Block Signer', () => {
281281
expect(Object.keys(signatureAttributes).length).toEqual(1);
282282
expect(signatureAttributes[attrName]).toEqual(rawPubKey);
283283

284-
const dataToBeSigned = signer.generateDataToBeSigned(
284+
const dataToBeSigned = utils.generateDataToBeSigned(
285285
signer.calcWebBundleHash(),
286286
ibWithoutSignatures.toCbor(),
287287
cborg.encode(signatureAttributes)

js/sign/tests/signed-web-bundle_test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,21 @@ describe('Signed Web Bundle - ', function () {
107107
bundle_signed_by_second_key
108108
);
109109
});
110+
111+
it('validateSignatures() - correctly validates signatures and returns bundle ID', async function () {
112+
const double_signed_bundle = await SignedWebBundle.fromWebBundle(
113+
UNSIGNED_WEB_BUNDLE_BYTES,
114+
[STRATEGY_KEY_1, STRATEGY_KEY_2]
115+
);
116+
117+
const validations = double_signed_bundle.validateSignatures();
118+
expect(validations.length).toEqual(2);
119+
120+
expect(validations[0].status).toEqual('success');
121+
expect(validations[0].isValid).toBe(true);
122+
expect(validations[0].derivedBundleId).toEqual(TEST_ED25519_WEB_BUNDLE_ID_1);
123+
124+
expect(validations[1].status).toEqual('success');
125+
expect(validations[1].isValid).toBe(true);
126+
});
110127
});

0 commit comments

Comments
 (0)