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

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

Expand Down Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion js/sign/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
17 changes: 17 additions & 0 deletions js/sign/src/cli-sign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as fs from 'fs';
import { createRequire } from 'module';

import { Command } from 'commander';
import pc from 'picocolors';

import {
errorLog,
Expand Down Expand Up @@ -103,6 +104,22 @@ async function parseArguments(): Promise<void> {
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');
Expand Down
2 changes: 1 addition & 1 deletion js/sign/src/core/integrity-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
46 changes: 45 additions & 1 deletion js/sign/src/core/signed-web-bundle.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
}
Expand Down
46 changes: 5 additions & 41 deletions js/sign/src/signers/integrity-block-signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
type SignatureAttributes,
} from '../core/integrity-block.js';
import {
calcWebBundleHash,
checkIsValidKey,
generateDataToBeSigned,
getPublicKeyAttributeName,
getRawPublicKey,
isPureWebBundle,
Expand Down Expand Up @@ -88,7 +90,7 @@ export class IntegrityBlockSigner {
async signAndGetIntegrityBlock(): Promise<IntegrityBlock> {
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) {
Expand All @@ -101,7 +103,7 @@ export class IntegrityBlockSigner {
const attrCbor = encode(newAttributes);
checkDeterministic(attrCbor);

const dataToBeSigned = this.generateDataToBeSigned(
const dataToBeSigned = generateDataToBeSigned(
webBundleHash,
ibCbor,
attrCbor
Expand Down Expand Up @@ -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 */
Expand Down
79 changes: 79 additions & 0 deletions js/sign/src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
6 changes: 3 additions & 3 deletions js/sign/tests/integrity-block-signer_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions js/sign/tests/signed-web-bundle_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading