From 93b0c73e4a7ebb207ae0e812b633a52e97496c3d Mon Sep 17 00:00:00 2001 From: Robert Ferens Date: Fri, 27 Feb 2026 12:21:50 +0000 Subject: [PATCH 1/3] New CLI New CLI - add/remove/replace/info commands --- js/sign/README.md | 111 ++++----- js/sign/package-lock.json | 14 +- js/sign/package.json | 3 +- js/sign/src/cli-sign.ts | 316 +++++++++++++++++++++++--- js/sign/src/core/integrity-block.ts | 44 ++++ js/sign/src/core/signed-web-bundle.ts | 8 + js/sign/src/utils/cli-utils.ts | 44 +--- js/sign/tests/cli_test.js | 154 +++++++++++++ 8 files changed, 556 insertions(+), 138 deletions(-) diff --git a/js/sign/README.md b/js/sign/README.md index 48f92837..aaad027b 100644 --- a/js/sign/README.md +++ b/js/sign/README.md @@ -124,80 +124,47 @@ const webBundleIdWithIWAOrigin = new wbnSign.WebBundleId( ## CLI -This package also includes 2 CLI tools +This package also includes 2 CLI tools: -- `wbn-sign` which lets you sign a web bundle easily without having to write any - additional JavaScript. -- `wbn-dump-id` which can be used to calculate the Web Bundle ID corresponding - to your signing key. +- `wbn-sign`: A comprehensive tool for signing bundles and managing signatures. +- `wbn-dump-id`: A simple utility to calculate the Web Bundle ID for a given key. -### Running wbn-sign -#### New usage form (>= 0.2.7) +### Running wbn-sign -The base usage from is: `wbn-sign [command] [options] ` +The base usage is: `wbn-sign [command] [options] ` -Currenly supported commands are: -``` -Usage: wbn-sign sign [options] - -Signs the given web bundle with private key(s). Produces signed web bundle output file. - -Arguments: - web_bundle a web bundle (file `*.wbn`) to sign - private_keys private keys (files `*.pem`) with which the web bundle will be signed. EcdsaP256 and ed25519 keys (encrypted and not encrypted) are supported. - -Options: - -o, --output signed web bundle output file (default: "signed.swbn") - --web-bundle-id web bundle ID. Derived from the first key if not specified. - -h, --help display help for command -``` +#### Commands: -For more details check `wbn-sign help [command]`. +- `sign `: Signs a web bundle with one or more private keys. +- `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. -Example commands: +For more details, run `wbn-sign help [command]`. -```bash -wbn-sign sign ~/path/to/webbundle.wbn ~/path/to/ed25519key.pem -o ~/path/to/signed-webbundle.swbn -``` +#### Examples: ```bash -wbn-sign sign ~/path/to/webbundle.wbn ~/path/to/ed25519key.pem ~/path/to/ecdsa_p256key.pem \ - --web-bundle-id amfcf7c4bmpbjbmq4h4yptcobves56hfdyr7tm3doxqvfmsk5ss6maacai \ - -o ~/path/to/signed-webbundle.swbn -``` +# Sign a web bundle with two keys. +wbn-sign sign webbundle.wbn key1.pem key2.pem -o signed.swbn +# Add a signature to an existing swbn. +wbn-sign add-signature signed.swbn key3.pem --in-place -#### Legacy usage (<0.2.6) -Previously the CLI tool used only options (no command). This usage form will be deprecated, but in the actual version is still supported. - -In `wbn-sign [options]` form followling options are available: -- (required) `--private-key ` (`-k `) - which takes the path to ed25519/ecdsaP256 private key. Can be specified multiple times. -- (required) `--input ` (`-i `) - which takes the path to the web bundle to be signed. -- (optional) `--output ` (`-o `) - which takes the path to the wanted signed web bundle output. Default: - `signed.swbn`. -- (required if more than one key is provided) - `--web-bundle-id ` - which takes the `web-bundle-id` to be associated with the web bundle. +# View information about a signed bundle. +wbn-sign info signed.swbn +``` ### Running wbn-dump-id -There are the following command-line flags available: - -- (required) `--key ` which takes the path to ed25519/ecdsaP256 public - or private key. -- (optional) `--with-iwa-scheme ` (`-s`) which dumps the Web Bundle ID - with isolated-app:// scheme. By default it only dumps the ID. Default: - `false`. -- (optional) `--with-key-type ` (`-t`) which also outputs the type of - the key used (ecdsa/ed25519). Default: `false`. - -Example command: +- `--key ` (required): Path to ed25519/ecdsaP256 public or private key. +- `--with-iwa-scheme` (`-s`): Dumps the Web Bundle ID with `isolated-app://` scheme. +- `--with-key-type` (`-t`): Outputs the type of the key used (ecdsa/ed25519). +Example: ```bash -wbn-dump-id -s -k ~/path/to/ed25519key.pem +wbn-dump-id -s -k path/to/ed25519key.pem ``` This would print the Web Bundle ID calculated from `ed25519key.pem` into the @@ -218,7 +185,9 @@ The environment variable set like this, can then be used in other scripts, for example in `--baseURL` when creating a web bundle with [wbn CLI tool](https://github.com/WICG/webpackage/tree/main/js/bundle#cli). -## Generating Ed25519 key +## Generating Keys + +### Ed25519 (recommended) An unencrypted ed25519 private key can be generated with: @@ -226,23 +195,35 @@ An unencrypted ed25519 private key can be generated with: openssl genpkey -algorithm Ed25519 -out ed25519key.pem ``` -For better security, one should prefer using passphrase-encrypted ed25519 -private keys. To encrypt an unencrypted private key, run: +**Note**: Although both are key types are secure, +we recommend using ed25519 as it is cosidered slightly more secure than ecdsa-p256. +The ecdsa algorithm, in opposite to deterimistic ed25519, is relying on strength of pseudo-random generator. +### ECDSA P-256 +```bash +openssl ecparam -name prime256v1 -genkey -noout -out ecdsap256key.pem ``` + +### Encryption +For better security, one should prefer using passphrase-encrypted +private keys. To encrypt an unencrypted private key (both supported types), run: + +```bash # encrypt the key (will ask for a passphrase, make sure to use a strong one) -openssl pkcs8 -in ed25519key.pem -topk8 -out encrypted_ed25519key.pem +openssl pkcs8 -in private_key.pem -topk8 -out encrypted_key.pem # delete the unencrypted key -rm ed25519key.pem +rm private_key.pem ``` -If you use an encrypted private key, you will be prompted for its passphrase as +If you use an encrypted private key to sign a bundle, you will be prompted for its passphrase as part of the signing process. If you want to use the CLI tool programmatically, then you can bypass the passphrase prompt by storing the passphrase in an environment variable named `WEB_BUNDLE_SIGNING_PASSPHRASE`. - ## Release Notes +### v0.3.1 +- Enhanced **CLI**: New commands `add-signature`, `remove-signature`, `replace-signature`, and `info`. + ### v0.3.0 - **Major architectural update**: Introduced `SignedWebBundle` as the primary interface for managing signed bundles. - Support for **multi-signatures**: Add, remove, and replace signatures in already signed web bundles. diff --git a/js/sign/package-lock.json b/js/sign/package-lock.json index 28a23c28..67e7131c 100644 --- a/js/sign/package-lock.json +++ b/js/sign/package-lock.json @@ -1,16 +1,17 @@ { "name": "wbn-sign", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wbn-sign", - "version": "0.3.0", + "version": "0.3.1", "license": "W3C-20150513", "dependencies": { "base32-encode": "^2.0.0", "cborg": "^4.2.14", + "colors": "^1.4.0", "commander": "^14.0.0", "read": "^2.0.0" }, @@ -1199,6 +1200,15 @@ "cborg": "lib/bin.js" } }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", diff --git a/js/sign/package.json b/js/sign/package.json index 779dfc24..38b29a58 100644 --- a/js/sign/package.json +++ b/js/sign/package.json @@ -1,6 +1,6 @@ { "name": "wbn-sign", - "version": "0.3.0", + "version": "0.3.1", "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", @@ -44,6 +44,7 @@ "dependencies": { "base32-encode": "^2.0.0", "cborg": "^4.2.14", + "colors": "^1.4.0", "commander": "^14.0.0", "read": "^2.0.0" }, diff --git a/js/sign/src/cli-sign.ts b/js/sign/src/cli-sign.ts index 6a5c6a04..d7a68236 100644 --- a/js/sign/src/cli-sign.ts +++ b/js/sign/src/cli-sign.ts @@ -1,4 +1,4 @@ -import { KeyObject } from 'crypto'; +import crypto, { KeyObject } from 'crypto'; import * as fs from 'fs'; import { createRequire } from 'module'; @@ -8,59 +8,285 @@ import { errorLog, greenConsoleLog, infoLog, + parseMaybeEncryptedKey, parseMaybeEncryptedKeyFromFile, warnLog, } from './utils/cli-utils.js'; import { - IntegrityBlockSigner, - NodeCryptoSigningStrategy, - WebBundleId, -} from './wbn-sign.js'; + getRawPublicKey, + isPureWebBundle, + isSignedWebBundle, +} from './utils/utils.js'; +import { NodeCryptoSigningStrategy, SignedWebBundle } from './wbn-sign.js'; const require = createRequire(import.meta.url); const { name, version } = require('../package.json'); +// Get output file path depending on options: bundle path if in-place and provided path otherwise. +function getOutputPath( + bundlePath: string, + options: { inPlace?: boolean; output?: string } +) { + if (options.inPlace && options.output != null) { + throw new Error( + "Options '--in-place' and '--output' are mutually exclusive." + ); + } + if (!options.inPlace && options.output == null) { + throw new Error("One of '--in-place' and '--output' options must be used."); + } + if (options?.output && fs.existsSync(options.output)) { + warnLog( + `The file in output path ${options.output} already exists. Overwriting.` + ); + } + return options.output ?? bundlePath; +} + +// That key may be either provided by Bytes encoded in base64 or path to file with key. This function checks that and parses key. +async function parseRemovalKey(keyInput: string): Promise { + if (keyInput.endsWith('.pem') || fs.existsSync(keyInput)) { + if (!fs.existsSync(keyInput)) { + throw new Error(`The key file "${keyInput}" does not exist.`); + } + const data = await fs.promises.readFile(keyInput); + try { + // Try parsing as private key (it handles encrypted ones too) + const privateKey = await parseMaybeEncryptedKey(data, keyInput); + return getRawPublicKey(privateKey); + } catch { + // Try parsing as public key + try { + const pubKeyObject = crypto.createPublicKey(data); + return getRawPublicKey(pubKeyObject); + } catch (err) { + throw new Error( + `Failed to parse key from file "${keyInput}". Ensure it is a valid PEM-encoded Ed25519 or ECDSA P-256 key.`, + { cause: err } + ); + } + } + } else { + // Assume Base64 string + // Base64 regex check (simplified, but sufficient for raw keys) + if (!/^[A-Za-z0-9+/=]+$/.test(keyInput)) { + throw new Error( + `The provided key "${keyInput}" is neither a valid file path nor a proper Base64-encoded string.` + ); + } + return new Uint8Array(Buffer.from(keyInput, 'base64')); + } +} + const program = new Command() .name(name) - .version(version) + .version(version, '-V, --version', 'Display version') .description( - `A simple CLI tool for managing signatures and keys of (signed) web bundles. -The primary use case is signing web bundles with private keys.` - ); + 'CLI tool for managing signatures and keys for web bundles. Operates on `wbn` and `swbn` files.' + ) + .helpOption('-h, --help', 'Display help'); async function parseArguments(): Promise { - // The main use case - signing web bundles + program.commandsGroup('General commands'); + program.helpCommand('help [command]', 'Display help for command'); + + program + .command('info') + .summary('Display integrity block information for a signed web bundle.') + .description( + 'Display integrity block information for a signed web bundle, including the Web Bundle ID and signatures. \n\n' + + 'WARNING: Experimental. The output format is subject to change. Do not use in scripts or automation.' + ) + .argument('', 'Path to a signed web bundle `*.swbn`') + .action(async (webBundlePath) => { + const webBundle = await fs.promises.readFile(webBundlePath); + const signedWebBundle = SignedWebBundle.fromBytes(webBundle); + + signedWebBundle.printInfo(); + }); + + program.commandsGroup('Signature management commands'); + program + .command('add-signature') + .summary('Add signatures to an existing signed web bundle.') + .description( + 'Add one or more signatures to the integrity block of a signed web bundle. ' + + 'This updates the bundle’s metadata with new signatures without altering ' + + 'the bundled resources.\n\n' + + 'For encrypted keys, you will be prompted for a password unless the ' + + 'WEB_BUNDLE_SIGNING_PASSPHRASE environment variable is set.' + ) + .argument('', 'Path to a signed web bundle (*.swbn).') + .argument( + '', + '*.pem files containing ecdsaP256 or ed25519 private keys.' + ) + .option( + '-i, --in-place', + 'Overwrite the input file with the new signatures. Incompatible with --output.', + false + ) + .option( + '-o, --output ', + 'Path for the new signed output file. Incompatible with --in-place.' + ) + .action(async (webBundlePath, keyFilesPaths, options) => { + const outputPath = getOutputPath(webBundlePath, options); + + const webBundle = await fs.promises.readFile(webBundlePath); + const signedWebBundle = SignedWebBundle.fromBytes(webBundle); + + for (const keyPath of keyFilesPaths) { + const privateKey = await parseMaybeEncryptedKeyFromFile(keyPath); + await signedWebBundle.addSignature( + new NodeCryptoSigningStrategy(privateKey) + ); + } + + await fs.promises.writeFile( + outputPath, + signedWebBundle.getSignedWebBundleBytes() + ); + greenConsoleLog( + `Signature${keyFilesPaths.length > 1 ? 's' : ''} added successfully.` + ); + }); + + program + .command('remove-signature') + .summary('Remove signatures from a signed web bundle.') + .description( + 'Remove one or more signatures from the integrity block of a signed web bundle. ' + + 'Signatures are identified by providing their associated public or private keys.\n\n' + + 'Tip: Use the `info` command to list the public keys currently present in a bundle.\n\n' + + 'For encrypted private keys, you will be prompted for a password unless ' + + 'the WEB_BUNDLE_SIGNING_PASSPHRASE environment variable is set.' + ) + .argument('', 'Path to a signed web bundle (*.swbn).') + .argument( + '', + 'Public keys (Base64 strings or .pem files) or private keys (.pem) ' + + 'used to identify signatures for removal.' + ) + .option( + '-i, --in-place', + 'Overwrite the input file. Incompatible with --output.', + false + ) + .option( + '-o, --output ', + 'Path for the new signed output file. Incompatible with --in-place.' + ) + .action(async (webBundlePath, keyInputs, options) => { + const outputPath = getOutputPath(webBundlePath, options); + + const webBundle = await fs.promises.readFile(webBundlePath); + const signedWebBundle = SignedWebBundle.fromBytes(webBundle); + + for (const keyInput of keyInputs) { + const publicKey = await parseRemovalKey(keyInput); + signedWebBundle.removeSignature(publicKey); + } + + await fs.promises.writeFile( + outputPath, + signedWebBundle.getSignedWebBundleBytes() + ); + greenConsoleLog( + `Signature${keyInputs.length > 1 ? 's' : ''} removed successfully.` + ); + }); + + program + .command('replace-signature') + .summary('Replace an existing signature with a new one.') + .description( + 'Replace a specific signature in the integrity block with a new signature. ' + + 'This is a convenience command equivalent to performing an `add-signature` ' + + 'followed by a `remove-signature`.\n\n' + + 'Tip: Use the `info` command to identify the public key of the signature you wish to replace.\n\n' + + 'For encrypted private keys, you will be prompted for a password unless ' + + 'the WEB_BUNDLE_SIGNING_PASSPHRASE environment variable is set.' + ) + .argument('', 'Path to a signed web bundle (*.swbn).') + .argument( + '', + 'The public key (Base64 or .pem) or private key (.pem) of the signature to replace.' + ) + .argument( + '', + 'The new *.pem file (ecdsaP256 or ed25519) to sign the bundle with.' + ) + .option( + '-i, --in-place', + 'Overwrite the input file. Incompatible with --output.', + false + ) + .option( + '-o, --output ', + 'Path for the new signed output file. Incompatible with --in-place.' + ) + .action(async (webBundlePath, oldKeyInput, newKeyPath, options) => { + const outputPath = getOutputPath(webBundlePath, options); + + const webBundle = await fs.promises.readFile(webBundlePath); + const signedWebBundle = SignedWebBundle.fromBytes(webBundle); + + const oldPublicKey = await parseRemovalKey(oldKeyInput); + const newPrivateKey = await parseMaybeEncryptedKeyFromFile(newKeyPath); + + await signedWebBundle.addSignature( + new NodeCryptoSigningStrategy(newPrivateKey) + ); + signedWebBundle.removeSignature(oldPublicKey); + + await fs.promises.writeFile( + outputPath, + signedWebBundle.getSignedWebBundleBytes() + ); + greenConsoleLog('Signature replaced successfully.'); + }); + program .command('sign') + .summary( + 'Sign a web bundle with private key(s). Produces signed web bundle.' + ) .description( - 'Signs the given web bundle with private key(s). Produces signed web bundle output file.' + `Sign a web bundle using one or more private keys to produce a signed web bundle (.swbn). Only .pem files are supported.` ) - .argument('', 'a web bundle (file `*.wbn`) to sign') + .argument('', 'The *.wbn file to sign') .argument( '', - 'private keys (files `*.pem`) with which the web bundle will be signed. EcdsaP256 and ed25519 keys (encrypted and not encrypted) are supported.' + '*.pem files containing ecdsaP256 or ed25519 private keys.' + + 'For encrypted keys, you will be prompted for a password' + + 'unless the WEB_BUNDLE_SIGNING_PASSPHRASE env var is set.' ) .option( '-o, --output ', - 'signed web bundle output file', + 'Path for the signed web bundle output file', /*defaultValue=*/ 'signed.swbn' ) .option( '--web-bundle-id ', - 'web bundle ID. Derived from the first key if not specified.' + 'Web bundle ID. Derived from the first key if not specified.' ) .showHelpAfterError() - .action(async (webBundle, privateKeys, options) => { + .action(async (webBundlePath, keyFilesPaths, options) => { if (!options.webBundleId) { infoLog( `The bundle id was not specified. It will be derived from the ${ - privateKeys.length > 1 ? 'first ' : '' + keyFilesPaths.length > 1 ? 'first ' : '' }given key.` ); } - await readFilesAndSignWebBundle( - webBundle, - privateKeys, + if (fs.existsSync(options.output)) { + warnLog(`'${options.output}' file already exists. Overwriting.`); + } + + await readVerifyAndSignWebBundle( + webBundlePath, + keyFilesPaths, options.output, options.webBundleId ); @@ -98,11 +324,11 @@ async function parseArguments(): Promise { .action(async (args, options, command) => { // Wrong command if (args.length > 0) { - // Help finishes the program internally + // Help quits the program, so not need to do it manually. program.help(); } - // Does it seem like old usage? If not just show help. + // Does it seem like backward-compatible format usage? If not just show help. if ( !('input' in options) && !('privateKey' in options) && @@ -130,7 +356,7 @@ async function parseArguments(): Promise { command.help(); } - await readFilesAndSignWebBundle( + await readVerifyAndSignWebBundle( options.input, options.privateKey, options.output, @@ -138,35 +364,53 @@ async function parseArguments(): Promise { ); }); - program.helpCommand(true); - - await program.parseAsync(process.argv); + // All errors that happen during command execution are caught here + try { + await program.parseAsync(process.argv); + } catch (err) { + if (err instanceof Error) { + errorLog(err.message); + } + process.exit(1); + } } -async function readFilesAndSignWebBundle( +async function readVerifyAndSignWebBundle( wbnFilePath: string, keyFilesPaths: string[], outputFilePath: string, maybeWebBundleId?: string ) { - const webBundle = fs.readFileSync(wbnFilePath); + const webBundle = await fs.promises.readFile(wbnFilePath); + if (isSignedWebBundle(webBundle)) { + throw new Error( + 'Web bundle already signed. Use `add-signature` command instead.' + ); + } + if (!isPureWebBundle(webBundle)) { + throw new Error('Not a web bundle.'); + } const privateKeys = new Array(); for (const privateKey of keyFilesPaths) { privateKeys.push(await parseMaybeEncryptedKeyFromFile(privateKey)); } - const webBundleId = - maybeWebBundleId ?? new WebBundleId(privateKeys[0]).serialize(); + const signingStrategies = privateKeys.map( + (privateKey) => new NodeCryptoSigningStrategy(privateKey) + ); - const signer = new IntegrityBlockSigner( + const signedWebBundle = await SignedWebBundle.fromWebBundle( Uint8Array.from(webBundle), - webBundleId, - privateKeys.map((privateKey) => new NodeCryptoSigningStrategy(privateKey)) + signingStrategies, + maybeWebBundleId ? { webBundleId: maybeWebBundleId } : undefined + ); + + greenConsoleLog(`${signedWebBundle.getWebBundleId()}`); + await fs.promises.writeFile( + outputFilePath, + signedWebBundle.getSignedWebBundleBytes() ); - const { signedWebBundle } = await signer.sign(); - greenConsoleLog(`${webBundleId}`); - fs.writeFileSync(outputFilePath, signedWebBundle); } export async function main() { diff --git a/js/sign/src/core/integrity-block.ts b/js/sign/src/core/integrity-block.ts index 3dee8201..bd005aa3 100644 --- a/js/sign/src/core/integrity-block.ts +++ b/js/sign/src/core/integrity-block.ts @@ -1,9 +1,11 @@ import assert from 'assert'; import { decode, encode } from 'cborg'; +import colors from 'colors'; import { INTEGRITY_BLOCK_MAGIC, + PUBLIC_KEY_ATTRIBUTE_NAME_MAPPING, SIGNATURE_ATTRIBUTE_TO_TYPE_MAPPING, VERSION_B2, WEB_BUNDLE_ID_ATTRIBUTE_NAME, @@ -88,6 +90,48 @@ export class IntegrityBlock { } } + printInfo() { + colors.enabled = process.stdout.isTTY; + + console.group('Integrity Block:'); + console.log( + 'Web bundle ID: ' + + (this.attributes.get(WEB_BUNDLE_ID_ATTRIBUTE_NAME)?.toString()?.green ?? + 'No key'.red.bold) + ); + + if (this.signatureStack.length == 0) { + console.log('No signatures'.red.bold); + } + + for (let i = 0; i < this.signatureStack.length; i++) { + const signature = this.signatureStack[i]; + try { + // May throw if attributes are invalid + const [keyType, publicKey] = IntegrityBlock.parseSignatureAttributes( + signature.signatureAttributes + ); + console.group(`Signature ${i}:`); + console.log( + `Key type: ${PUBLIC_KEY_ATTRIBUTE_NAME_MAPPING.get(keyType)!.slice(0, -'PublicKey'.length)!}` + ); + console.log(`Public key: ${Buffer.from(publicKey).toString('base64')}`); + console.log( + `Signature: ${Array.from(signature.signature) + .map((b) => b.toString(16).toUpperCase().padStart(2, '0')) + .join(' ')}` + ); + console.groupEnd(); // Key + } catch (err) { + if (err instanceof Error) { + // do not use errorLog, since printStatus prints to stdout by design + console.log(`Key ${i}: ${err.message}`.red); + } + } + } + console.groupEnd(); // Integrity block + } + getWebBundleId(): string { return this.attributes.get(WEB_BUNDLE_ID_ATTRIBUTE_NAME)!; } diff --git a/js/sign/src/core/signed-web-bundle.ts b/js/sign/src/core/signed-web-bundle.ts index 55d517a8..cfad6c98 100644 --- a/js/sign/src/core/signed-web-bundle.ts +++ b/js/sign/src/core/signed-web-bundle.ts @@ -2,6 +2,7 @@ import { assert } from 'console'; 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 { WebBundleId } from '../web-bundle-id.js'; import { IntegrityBlock } from './integrity-block.js'; @@ -63,6 +64,13 @@ export class SignedWebBundle { return this; } + printInfo(): void { + warnLog( + 'This feature is experimental, the form of output will change in the future. Do not use with scripts.' + ); + this.integrityBlock.printInfo(); + } + getIntegrityBlock(): IntegrityBlock { return this.integrityBlock; } diff --git a/js/sign/src/utils/cli-utils.ts b/js/sign/src/utils/cli-utils.ts index f793e5f1..01663401 100644 --- a/js/sign/src/utils/cli-utils.ts +++ b/js/sign/src/utils/cli-utils.ts @@ -2,6 +2,7 @@ import { KeyObject } from 'crypto'; import fs from 'fs'; import path from 'path'; +import colors from 'colors'; import read from 'read'; import { parsePemKey } from '../wbn-sign.js'; @@ -74,45 +75,20 @@ export async function readPassphrase(description: string): Promise { } // Logging module -//TODO: Replace with more professional js logging tools - -const COLORS: Record = { - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - magenta: '\x1b[35m', - cyan: '\x1b[36m', - //aka reset - default: '\x1b[0m', -}; - -function coloredConsoleLog( - colorEscCode: string, - text: string, - stream: NodeJS.WriteStream = process.stdout -): void { - const reset = COLORS.default; - - // Check if the stream is a TTY (interactive terminal) - // If the log is used for non-terminal (fd != 1), e.g., setting an environment - // variable, it shouldn't have any formatting. - if (stream.isTTY) { - stream.write(`${colorEscCode}${text}${reset}\n`); - } else { - stream.write(`${text}\n`); - } -} - +// TODO: Replace with more professional js logging tools export function greenConsoleLog(text: string): void { - coloredConsoleLog(COLORS.green, text); + colors.enabled = process.stdout.isTTY; + console.log(text.green); } export function infoLog(text: string): void { - coloredConsoleLog(COLORS.default, 'INFO: ' + text, process.stderr); + // Warn to print on stderr, not stdout + console.warn('INFO: ' + text); } export function warnLog(text: string): void { - coloredConsoleLog(COLORS.yellow, 'WARN: ' + text, process.stderr); + colors.enabled = process.stdout.isTTY; + console.warn(`WARN: ${text}`.yellow); } export function errorLog(text: string): void { - coloredConsoleLog(COLORS.red, 'ERROR: ' + text, process.stderr); + colors.enabled = process.stdout.isTTY; + console.error(`ERROR: ${text}`.red); } diff --git a/js/sign/tests/cli_test.js b/js/sign/tests/cli_test.js index 2425254b..937d7bf3 100644 --- a/js/sign/tests/cli_test.js +++ b/js/sign/tests/cli_test.js @@ -225,4 +225,158 @@ describe('CLI wbn-sign:', () => { "--web-bundle-id must be specified if there's more than 1 signing key" ); }); + + it('Add-signature command - success', async () => { + // First sign + await execPromise( + `node ${cliPath} sign -o ${outputPath} ${unsignedWbnPath} ${ed25519KeyPath}` + ); + + // Then add signature + const addOutputPath = path.join(tmpDir, 'added.swbn'); + const { stdout } = await execPromise( + `node ${cliPath} add-signature -o ${addOutputPath} ${outputPath} ${ecdsaP256KeyPath}` + ); + expect(stdout).toContain('Signature added successfully.'); + expect(fs.existsSync(addOutputPath)).toBeTrue(); + }); + + it('Remove-signature command - success', async () => { + // First sign with two keys + await execPromise( + `node ${cliPath} sign -o ${outputPath} ${unsignedWbnPath} ${ed25519KeyPath}` + ); + await execPromise( + `node ${cliPath} add-signature -i ${outputPath} ${ecdsaP256KeyPath}` + ); + + // Then remove one signature using public key (Base64) + const { stdout: infoOutput } = await execPromise( + `node ${cliPath} info ${outputPath}` + ); + const match = infoOutput.match(/Public key: (.*)/); + const publicKeyBase64 = match[1]; + + const removeOutputPath = path.join(tmpDir, 'removed.swbn'); + const { stdout } = await execPromise( + `node ${cliPath} remove-signature -o ${removeOutputPath} ${outputPath} ${publicKeyBase64}` + ); + expect(stdout).toContain('Signature removed successfully.'); + expect(fs.existsSync(removeOutputPath)).toBeTrue(); + }); + + it('Replace-signature command - success', async () => { + // First sign + await execPromise( + `node ${cliPath} sign -o ${outputPath} ${unsignedWbnPath} ${ed25519KeyPath}` + ); + + const { stdout: infoOutput } = await execPromise( + `node ${cliPath} info ${outputPath}` + ); + const match = infoOutput.match(/Public key: (.*)/); + const oldPublicKeyBase64 = match[1]; + + const replaceOutputPath = path.join(tmpDir, 'replaced.swbn'); + const { stdout } = await execPromise( + `node ${cliPath} replace-signature -o ${replaceOutputPath} ${outputPath} ${oldPublicKeyBase64} ${ecdsaP256KeyPath}` + ); + expect(stdout).toContain('Signature replaced successfully.'); + expect(fs.existsSync(replaceOutputPath)).toBeTrue(); + }); + + it('Remove-signature command - invalid inputs', async () => { + // First sign + await execPromise( + `node ${cliPath} sign -o ${outputPath} ${unsignedWbnPath} ${ed25519KeyPath}` + ); + + // Test non-existent file + try { + await execPromise( + `node ${cliPath} remove-signature -i ${outputPath} non-existent-key.pem` + ); + fail('Should have failed for non-existent file'); + } catch (error) { + expect(error.stderr).toContain( + 'The key file "non-existent-key.pem" does not exist.' + ); + } + + // Test invalid Base64 + try { + await execPromise( + `node ${cliPath} remove-signature -i ${outputPath} "invalid_base64_!@#"` + ); + fail('Should have failed for invalid Base64'); + } catch (error) { + expect(error.stderr).toContain( + 'is neither a valid file path nor a proper Base64-encoded string.' + ); + } + }); + + it('Deterministic Ed25519 tests - add, remove, replace consistency', async () => { + const key1Path = ed25519KeyPath; + const key2Path = path.resolve(__dirname, 'testdata/ed25519-key-2.pem'); + const bundleId1 = + '4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic'; + + // 1. Adding one by one vs two at once + const pathOneByOne = path.join(tmpDir, 'one-by-one.swbn'); + const pathTwoAtOnce = path.join(tmpDir, 'two-at-once.swbn'); + + await execPromise( + `node ${cliPath} sign -o ${pathOneByOne} ${unsignedWbnPath} ${key1Path}` + ); + await execPromise( + `node ${cliPath} add-signature -i ${pathOneByOne} ${key2Path}` + ); + + await execPromise( + `node ${cliPath} sign -o ${pathTwoAtOnce} ${unsignedWbnPath} ${key1Path} ${key2Path}` + ); + + expect(fs.readFileSync(pathOneByOne)).toEqual( + fs.readFileSync(pathTwoAtOnce) + ); + + // 2. Adding two and removing one vs adding one + const pathSignTwoRemoveOne = path.join(tmpDir, 'sign-two-remove-one.swbn'); + const pathSignOne = path.join(tmpDir, 'sign-one.swbn'); + + await execPromise( + `node ${cliPath} sign -o ${pathSignTwoRemoveOne} ${unsignedWbnPath} ${key1Path} ${key2Path}` + ); + await execPromise( + `node ${cliPath} remove-signature -i ${pathSignTwoRemoveOne} ${key2Path}` + ); + + await execPromise( + `node ${cliPath} sign -o ${pathSignOne} ${unsignedWbnPath} ${key1Path}` + ); + + expect(fs.readFileSync(pathSignTwoRemoveOne)).toEqual( + fs.readFileSync(pathSignOne) + ); + + // 3. Replace key1 with key2 vs sign with key2 (with same bundle ID) + const pathReplace = path.join(tmpDir, 'replace-deterministic.swbn'); + const pathSignKey2SameId = path.join(tmpDir, 'sign-key2-same-id.swbn'); + + await execPromise( + `node ${cliPath} sign -o ${pathReplace} ${unsignedWbnPath} ${key1Path}` + ); + await execPromise( + `node ${cliPath} replace-signature -i ${pathReplace} ${key1Path} ${key2Path}` + ); + + await execPromise( + `node ${cliPath} sign -o ${pathSignKey2SameId} --web-bundle-id ${bundleId1} ${unsignedWbnPath} ${key2Path}` + ); + + expect(fs.readFileSync(pathReplace)).toEqual( + fs.readFileSync(pathSignKey2SameId) + ); + }); }); From d59cfb7d5c44cb13be2e0a2abfbb0615835508d4 Mon Sep 17 00:00:00 2001 From: Robert Ferens Date: Thu, 2 Apr 2026 14:00:53 +0200 Subject: [PATCH 2/3] Apply suggestions from code review Co-authored-by: Zgroza (Luke) Klimek --- js/sign/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/js/sign/README.md b/js/sign/README.md index aaad027b..1f2f4ce2 100644 --- a/js/sign/README.md +++ b/js/sign/README.md @@ -195,9 +195,7 @@ An unencrypted ed25519 private key can be generated with: openssl genpkey -algorithm Ed25519 -out ed25519key.pem ``` -**Note**: Although both are key types are secure, -we recommend using ed25519 as it is cosidered slightly more secure than ecdsa-p256. -The ecdsa algorithm, in opposite to deterimistic ed25519, is relying on strength of pseudo-random generator. +**Note**: We recommend using Ed25519, as it is considered more secure than ECDSA P-256. Unlike ECDSA, which relies on a pseudo-random number generator and is vulnerable to entropy-related flaws, Ed25519 is deterministic and remains secure even if the system's random number generator is compromised. ### ECDSA P-256 ```bash From f2dc04b2409d5e800ea496418a47173e75e50e16 Mon Sep 17 00:00:00 2001 From: Robert Ferens Date: Thu, 2 Apr 2026 12:04:19 +0000 Subject: [PATCH 3/3] colors to picocolors --- js/sign/package-lock.json | 24 +++++++----------------- js/sign/package.json | 2 +- js/sign/src/core/integrity-block.ts | 15 ++++++++------- js/sign/src/utils/cli-utils.ts | 11 ++++------- js/sign/tsconfig.json | 3 ++- 5 files changed, 22 insertions(+), 33 deletions(-) diff --git a/js/sign/package-lock.json b/js/sign/package-lock.json index 67e7131c..afb72401 100644 --- a/js/sign/package-lock.json +++ b/js/sign/package-lock.json @@ -11,8 +11,8 @@ "dependencies": { "base32-encode": "^2.0.0", "cborg": "^4.2.14", - "colors": "^1.4.0", "commander": "^14.0.0", + "picocolors": "^1.1.1", "read": "^2.0.0" }, "bin": { @@ -1179,9 +1179,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1200,15 +1200,6 @@ "cborg": "lib/bin.js" } }, - "node_modules/colors": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -1909,13 +1900,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/js/sign/package.json b/js/sign/package.json index 38b29a58..1c53b4d1 100644 --- a/js/sign/package.json +++ b/js/sign/package.json @@ -44,8 +44,8 @@ "dependencies": { "base32-encode": "^2.0.0", "cborg": "^4.2.14", - "colors": "^1.4.0", "commander": "^14.0.0", + "picocolors": "^1.1.1", "read": "^2.0.0" }, "devDependencies": { diff --git a/js/sign/src/core/integrity-block.ts b/js/sign/src/core/integrity-block.ts index bd005aa3..adc27461 100644 --- a/js/sign/src/core/integrity-block.ts +++ b/js/sign/src/core/integrity-block.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import { decode, encode } from 'cborg'; -import colors from 'colors'; +import pc from 'picocolors'; import { INTEGRITY_BLOCK_MAGIC, @@ -91,17 +91,18 @@ export class IntegrityBlock { } printInfo() { - colors.enabled = process.stdout.isTTY; - console.group('Integrity Block:'); + const webBundleId = this.attributes + .get(WEB_BUNDLE_ID_ATTRIBUTE_NAME) + ?.toString(); + console.log( 'Web bundle ID: ' + - (this.attributes.get(WEB_BUNDLE_ID_ATTRIBUTE_NAME)?.toString()?.green ?? - 'No key'.red.bold) + (webBundleId ? pc.green(webBundleId) : pc.red('No key')) ); if (this.signatureStack.length == 0) { - console.log('No signatures'.red.bold); + console.log(pc.red('No signatures')); } for (let i = 0; i < this.signatureStack.length; i++) { @@ -125,7 +126,7 @@ export class IntegrityBlock { } catch (err) { if (err instanceof Error) { // do not use errorLog, since printStatus prints to stdout by design - console.log(`Key ${i}: ${err.message}`.red); + console.log(pc.red(`Key ${i}: ${err.message}`)); } } } diff --git a/js/sign/src/utils/cli-utils.ts b/js/sign/src/utils/cli-utils.ts index 01663401..a1ca9d93 100644 --- a/js/sign/src/utils/cli-utils.ts +++ b/js/sign/src/utils/cli-utils.ts @@ -2,7 +2,7 @@ import { KeyObject } from 'crypto'; import fs from 'fs'; import path from 'path'; -import colors from 'colors'; +import pc from 'picocolors'; import read from 'read'; import { parsePemKey } from '../wbn-sign.js'; @@ -77,18 +77,15 @@ export async function readPassphrase(description: string): Promise { // Logging module // TODO: Replace with more professional js logging tools export function greenConsoleLog(text: string): void { - colors.enabled = process.stdout.isTTY; - console.log(text.green); + console.log(pc.green(text)); } export function infoLog(text: string): void { // Warn to print on stderr, not stdout console.warn('INFO: ' + text); } export function warnLog(text: string): void { - colors.enabled = process.stdout.isTTY; - console.warn(`WARN: ${text}`.yellow); + console.warn(pc.yellow(`WARN: ${text}`)); } export function errorLog(text: string): void { - colors.enabled = process.stdout.isTTY; - console.error(`ERROR: ${text}`.red); + console.error(pc.red(`ERROR: ${text}`)); } diff --git a/js/sign/tsconfig.json b/js/sign/tsconfig.json index c5e2e7d2..99ac83a8 100644 --- a/js/sign/tsconfig.json +++ b/js/sign/tsconfig.json @@ -9,7 +9,8 @@ "stripInternal": true, "declaration": true, "newLine": "lf", - "outDir": "lib" + "outDir": "lib", + "types": ["node"] }, "include": ["src/**/*"] }