diff --git a/js/sign/README.md b/js/sign/README.md index b6b23e8f..8116239e 100644 --- a/js/sign/README.md +++ b/js/sign/README.md @@ -4,8 +4,10 @@ This is a Node.js module for signing [Web Bundles](https://wpack-wg.github.io/bundled-responses/draft-ietf-wpack-bundled-responses.html) with [integrityblock](../../explainers/integrity-signature.md). -The module takes an existing bundle file and an ed25519 private key, and emits a -new bundle file with cryptographic signature added to the integrity block. +The module takes an existing bundle file and an ed25519 or ecdsa-p256 private key, and emits a +new bundle file with cryptographic signature(s) added to the integrity block. + +The module also support other operations on Integrity block like adding/removing/replacing signatures. ## Installation @@ -24,7 +26,9 @@ This plugin requires Node v22.13.0+. Please be aware that the APIs are not stable yet and are subject to change at any time. -Signing a web bundle file: +### Signing a Web Bundle + +The recommended way to sign a web bundle is using the `SignedWebBundle` class. ```javascript import * as fs from 'fs'; @@ -37,42 +41,70 @@ const privateKey = wbnSign.parsePemKey( fs.readFileSync('./path/to/privatekey.pem', 'utf-8') ); -// Option 1: With the default (`NodeCryptoSigningStrategy`) signing strategy. -const { signedWebBundle } = await new wbnSign.IntegrityBlockSigner(webBundle, { - key: privateKey, -}).sign(); +// Sign with a single key. +const signedWebBundle = await wbnSign.SignedWebBundle.fromWebBundle( + webBundle, + [new wbnSign.NodeCryptoSigningStrategy(privateKey)] +); + +// Get the signed bytes to save to a file. +const signedBytes = signedWebBundle.getSignedWebBundleBytes(); +fs.writeFileSync('./path/to/signed.swbn', signedBytes); + +// Get the Web Bundle ID (App ID). +console.log('Web Bundle ID:', signedWebBundle.getWebBundleId()); +``` + +### Advanced Usage: Multiple Signatures + +You can sign a bundle with multiple keys at once, or add signatures to an already signed bundle. -// Option 2: With specified signing strategy. -const { signedWebBundle } = await new wbnSign.IntegrityBlockSigner( +```javascript +// Sign with multiple keys at once. +const multiSigned = await wbnSign.SignedWebBundle.fromWebBundle( webBundle, - new wbnSign.NodeCryptoSigningStrategy(privateKey) -).sign(); + [strategy1, strategy2] +); + +// Or add a signature to an existing SignedWebBundle instance. +await multiSigned.addSignature(strategy3); -// Option 3: With ones own CustomSigningStrategy class implementing -// ISigningStrategy. -const { signedWebBundle } = await new wbnSign.IntegrityBlockSigner( +// You can also load an existing signed web bundle from bytes. +const existingSigned = wbnSign.SignedWebBundle.fromBytes( + fs.readFileSync('./path/to/already_signed.swbn') +); + +// And remove a signature by providing the public key. +existingSigned.removeSignature(publicKeyBytes); +``` + +### Custom Signing Strategies + +You can implement your own signing strategy by implementing the `ISigningStrategy` interface. + +```javascript +const customStrategy = new (class { + async sign(data: Uint8Array): Promise { + // Connect to an external signing service. + } + async getPublicKey(): Promise { + // Return the public key from the service. + } +})(); + +const signedWebBundle = await wbnSign.SignedWebBundle.fromWebBundle( webBundle, - new (class { - async sign(data: Uint8Array): Promise { - // E.g. connect to one's external signing service that signs the payload. - } - async getPublicKey(): Promise { - /** E.g. connect to one's external signing service that returns the public - * key.*/ - } - })() -).sign(); - -fs.writeFileSync(signedWebBundle); + [customStrategy] +); ``` +### Calculating Web Bundle ID + This library also exposes a helper class to calculate the Web Bundle's ID (or -App ID) from the private or public ed25519 key, which can then be used when +App ID) from the private or public key, which can then be used when bundling [Isolated Web Apps](https://github.com/WICG/isolated-web-apps/blob/main/README.md). -Calculating the web bundle ID for Isolated Web Apps: - ```javascript import * as fs from 'fs'; import * as wbnSign from 'wbn-sign'; @@ -211,6 +243,10 @@ environment variable named `WEB_BUNDLE_SIGNING_PASSPHRASE`. ## Release Notes +### 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. + ### v0.2.7 - The new command line interface that supports commands introduced for wbn-sign tool diff --git a/js/sign/package-lock.json b/js/sign/package-lock.json index 797c3b5f..28a23c28 100644 --- a/js/sign/package-lock.json +++ b/js/sign/package-lock.json @@ -1,12 +1,12 @@ { "name": "wbn-sign", - "version": "0.2.7", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wbn-sign", - "version": "0.2.7", + "version": "0.3.0", "license": "W3C-20150513", "dependencies": { "base32-encode": "^2.0.0", @@ -1539,9 +1539,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, diff --git a/js/sign/package.json b/js/sign/package.json index be33dd87..779dfc24 100644 --- a/js/sign/package.json +++ b/js/sign/package.json @@ -1,6 +1,6 @@ { "name": "wbn-sign", - "version": "0.2.7", + "version": "0.3.0", "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/core/integrity-block.ts b/js/sign/src/core/integrity-block.ts new file mode 100644 index 00000000..3dee8201 --- /dev/null +++ b/js/sign/src/core/integrity-block.ts @@ -0,0 +1,147 @@ +import assert from 'assert'; + +import { decode, encode } from 'cborg'; + +import { + INTEGRITY_BLOCK_MAGIC, + SIGNATURE_ATTRIBUTE_TO_TYPE_MAPPING, + VERSION_B2, + WEB_BUNDLE_ID_ATTRIBUTE_NAME, + type SignatureType, +} from '../utils/constants.js'; + +export type SignatureAttributes = { + [SignatureAttributeKey: string]: Uint8Array; +}; + +export type IntegritySignature = { + signatureAttributes: SignatureAttributes; + signature: Uint8Array; +}; + +export class IntegrityBlock { + private attributes: Map = new Map(); + private signatureStack: IntegritySignature[] = []; + + /** @internal */ + constructor() {} + + static fromCbor(integrityBlockBytes: Uint8Array): IntegrityBlock { + const integrityBlock = new IntegrityBlock(); + try { + const [magic, version, attributes, signatureStack] = decode( + integrityBlockBytes, + { useMaps: true } + ); + + assert(magic instanceof Uint8Array, 'Invalid magic bytes'); + assert.deepStrictEqual( + magic, + INTEGRITY_BLOCK_MAGIC, + 'Invalid magic bytes' + ); + + assert(version instanceof Uint8Array, 'Invalid version'); + assert.deepStrictEqual(version, VERSION_B2, 'Invalid version'); + + assert(attributes instanceof Map, 'Invalid attributes'); + assert( + attributes.has(WEB_BUNDLE_ID_ATTRIBUTE_NAME), + `Missing ${WEB_BUNDLE_ID_ATTRIBUTE_NAME} attribute` + ); + integrityBlock.setWebBundleId( + attributes.get(WEB_BUNDLE_ID_ATTRIBUTE_NAME)! + ); + + assert(signatureStack instanceof Array, 'Invalid signature stack'); + assert(signatureStack.length > 0, 'Invalid signature stack'); + + for (const signatureBlock of signatureStack) { + assert(signatureBlock instanceof Array, 'Invalid signature'); + assert.strictEqual(signatureBlock.length, 2, 'Invalid signature'); + + const [attributes, signature] = signatureBlock; + assert(attributes instanceof Map, 'Invalid signature attributes'); + assert(signature instanceof Uint8Array, 'Invalid signature'); + assert.equal(attributes.size, 1, 'Invalid signature attributes'); + + const [keyType, publicKey] = [...attributes][0]; + assert( + SIGNATURE_ATTRIBUTE_TO_TYPE_MAPPING.has(keyType), + 'Invalid signature attribute key type' + ); + assert( + publicKey instanceof Uint8Array, + 'Invalid signature attribute key' + ); + + integrityBlock.addIntegritySignature({ + signatureAttributes: { [keyType]: publicKey }, + signature: Buffer.from(signature), + }); + } + return integrityBlock; + } catch (err) { + throw new Error(`Invalid integrity block: ${(err as Error).message}`, { + cause: err, + }); + } + } + + getWebBundleId(): string { + return this.attributes.get(WEB_BUNDLE_ID_ATTRIBUTE_NAME)!; + } + + setWebBundleId(webBundleId: string) { + this.attributes.set(WEB_BUNDLE_ID_ATTRIBUTE_NAME, webBundleId); + } + + addIntegritySignature(is: IntegritySignature) { + this.signatureStack.push(is); + } + + removeIntegritySignature(publicKey: Uint8Array) { + this.signatureStack = this.signatureStack.filter((integritySignature) => { + // Uint8Arrays cannot be directly compared, but Buffer can + return !Buffer.from( + Object.values(integritySignature.signatureAttributes)[0] + ).equals(publicKey); + }); + } + + getSignatureStack(): IntegritySignature[] { + return this.signatureStack; + } + + toCbor(): Uint8Array { + return encode([ + INTEGRITY_BLOCK_MAGIC, + VERSION_B2, + this.attributes, + this.signatureStack.map((integritySig) => { + // The CBOR must have an array of length 2 containing the following: + // (0) attributes and (1) signature. The order is important. + return [integritySig.signatureAttributes, integritySig.signature]; + }), + ]); + } + + // Stripped CBOR does not include signatures and is a part of data which hash is signed + /** @internal */ + toStrippedCbor(): Uint8Array { + return encode([INTEGRITY_BLOCK_MAGIC, VERSION_B2, this.attributes, []]); + } + + private static parseSignatureAttributes( + attributes: SignatureAttributes + ): [SignatureType, Uint8Array] { + assert( + Object.entries(attributes).length == 1, + 'Invalid signature attributes' + ); + const [maybeType, publicKey] = Object.entries(attributes)[0]; + const type = SIGNATURE_ATTRIBUTE_TO_TYPE_MAPPING.get(maybeType); + assert(type != undefined, 'Invalid signature attributes'); + return [type, publicKey]; + } +} diff --git a/js/sign/src/core/signed-web-bundle.ts b/js/sign/src/core/signed-web-bundle.ts new file mode 100644 index 00000000..55d517a8 --- /dev/null +++ b/js/sign/src/core/signed-web-bundle.ts @@ -0,0 +1,116 @@ +import { assert } from 'console'; + +import { IntegrityBlockSigner } from '../signers/integrity-block-signer.js'; +import { ISigningStrategy } from '../signers/signing-strategy-interface.js'; +import { isSignedWebBundle } from '../utils/utils.js'; +import { WebBundleId } from '../web-bundle-id.js'; +import { IntegrityBlock } from './integrity-block.js'; + +export class SignedWebBundle { + private constructor( + private integrityBlock: IntegrityBlock, + private pureWebBundle: Uint8Array + ) {} + + // Use with raw Singed Web Bundle data, for example read from file with fs.readFile('bundle.swbn') + static fromBytes(signedWebBundle: Uint8Array): SignedWebBundle { + if (!isSignedWebBundle(signedWebBundle)) { + throw new Error('Not a signed web bundle.'); + } + + const offset = this.obtainIntegrityOffset(signedWebBundle); + + const integrityBlockBytes = signedWebBundle.slice(0, offset); + const integrityBlock = IntegrityBlock.fromCbor(integrityBlockBytes); + + const bundle = signedWebBundle.slice(offset); + + return new SignedWebBundle(integrityBlock, bundle); + } + + // Web bundle ID is derived from the first key if not provided + static async fromWebBundle( + webBundle: Uint8Array, + signingStrategies: Array, + options?: { webBundleId?: string } + ): Promise { + assert(signingStrategies.length > 0, 'No signing strategies provided'); + + const publicKey = await signingStrategies[0].getPublicKey(); + const webBundleId = + options?.webBundleId ?? new WebBundleId(publicKey).serialize(); + + const { signedWebBundle } = await new IntegrityBlockSigner( + webBundle, + webBundleId, + signingStrategies + ).sign(); + + return SignedWebBundle.fromBytes(signedWebBundle); + } + + async addSignature(signingStrategy: ISigningStrategy): Promise { + this.integrityBlock = await new IntegrityBlockSigner( + this.pureWebBundle, + this.integrityBlock, + [signingStrategy] + ).signAndGetIntegrityBlock(); + return this; + } + + removeSignature(publicKey: Uint8Array): this { + this.integrityBlock.removeIntegritySignature(publicKey); + return this; + } + + getIntegrityBlock(): IntegrityBlock { + return this.integrityBlock; + } + + getWebBundleId(): string { + return this.integrityBlock.getWebBundleId(); + } + + getSignedWebBundleBytes(): Uint8Array { + if (this.integrityBlock.getSignatureStack().length == 0) { + throw new Error( + 'Signed Web Bundle instance is in invalid state (no signatures).' + ); + } + if (!this.integrityBlock.getWebBundleId()) { + throw new Error( + 'Signed Web Bundle instance is in invalid state (bundle id not set)' + ); + } + return Buffer.concat([this.integrityBlock.toCbor(), this.pureWebBundle]); + } + + overrideBundleId(bundleId: string): this { + this.integrityBlock.setWebBundleId(bundleId); + return this; + } + + // private method, static to emphasize pure functional character + private static obtainIntegrityOffset(bundle: Uint8Array): number { + const bundleLengthFromInternalData = + SignedWebBundle.readWebBundleLength(bundle); + const offset = bundle.length - bundleLengthFromInternalData; + if (bundleLengthFromInternalData < 0 || offset <= 0) { + throw new Error( + 'SignedWebBundle::fromBytes: The provided bytes do not represent a signed web bundle.' + ); + } + return offset; + } + + // private method, static to emphasize pure functional character + private static readWebBundleLength(bundle: Uint8Array): number { + // The length of the web bundle is contained in the last 8 bytes of the web + // bundle, represented as BigEndian. + const buffer = Buffer.from(bundle.slice(-8)); + // Number is big enough to represent 4GB which is the limit for the web + // bundle size which can be contained in a Buffer, which is the format + // in which it's passed back to e.g. Webpack. + return Number(buffer.readBigUint64BE()); + } +} diff --git a/js/sign/src/signers/integrity-block-signer.ts b/js/sign/src/signers/integrity-block-signer.ts index f66f9f5b..051ddfb2 100644 --- a/js/sign/src/signers/integrity-block-signer.ts +++ b/js/sign/src/signers/integrity-block-signer.ts @@ -1,38 +1,91 @@ +import assert from 'assert'; import crypto, { KeyObject } from 'crypto'; import { encode } from 'cborg'; import { checkDeterministic } from '../cbor/deterministic.js'; -import { INTEGRITY_BLOCK_MAGIC, VERSION_B2 } from '../utils/constants.js'; +import { + IntegrityBlock, + type IntegritySignature, + type SignatureAttributes, +} from '../core/integrity-block.js'; import { checkIsValidKey, getPublicKeyAttributeName, getRawPublicKey, + isPureWebBundle, + verifySignature, } from '../utils/utils.js'; import { ISigningStrategy } from './signing-strategy-interface.js'; -type SignatureAttributes = { [SignatureAttributeKey: string]: Uint8Array }; - -type IntegritySignature = { - signatureAttributes: SignatureAttributes; - signature: Uint8Array; -}; - +// This class were previously exported, but now is going to be used only internally. +// Therefore its methods are marked as deprecated to discourage using them while still keeping backward compatible. +// Later @deprecated markers may change to @internal to make it fully internal. export class IntegrityBlockSigner { + private readonly integrityBlock: IntegrityBlock; + private readonly webBundle: Uint8Array; + private readonly signingStrategies: Array; + + /** + * @internal This class is only internal, use SignedWebBundle.from* instead + * First argument can be only a pure web bundle (without integrity block) + */ + constructor( + webBundle: Uint8Array, + integrityBlock: IntegrityBlock, + signingStrategies: Array + ); + /** + * @deprecated This class is only internal, use SignedWebBundle.from* instead + * Marked as deprecated, not internal for backward compatibility + * First argument can be only a pure web bundle (without integrity block) + */ constructor( - private readonly webBundle: Uint8Array, - private readonly webBundleId: string, - private readonly signingStrategies: Array - ) {} + webBundle: Uint8Array, + webBundleId: string, + signingStrategies: Array + ); + constructor( + webBundle: Uint8Array, + arg2: string | IntegrityBlock, + signingStrategies: Array + ) { + this.webBundle = webBundle; + assert(isPureWebBundle(this.webBundle), 'Wrong argument'); + // arg2: Web bundle id + if (typeof arg2 === 'string') { + this.integrityBlock = new IntegrityBlock() as IntegrityBlock; + this.integrityBlock.setWebBundleId(arg2); + } + // arg2: IntegrityBlock + else { + assert(arg2 instanceof IntegrityBlock, 'Wrong argument'); + this.integrityBlock = arg2; + } + this.signingStrategies = signingStrategies; + } + /** @deprecated This class is only internal, use SignedWebBundle instead. Sign() is going to change interface in next releases. */ async sign(): Promise<{ integrityBlock: Uint8Array; signedWebBundle: Uint8Array; }> { - const integrityBlock = this.obtainIntegrityBlock().integrityBlock; - integrityBlock.setWebBundleId(this.webBundleId); + const newIntegrityBlock = await this.signAndGetIntegrityBlock(); + const signedIbCbor = newIntegrityBlock.toCbor(); - const signatures = new Array(); + const signedWebBundle = Buffer.concat([signedIbCbor, this.webBundle]); + + return { + integrityBlock: signedIbCbor, + signedWebBundle, + }; + } + + // This method is expected to replace 'sign' which now keeps previous return type for backward compatibility + /** @internal */ + async signAndGetIntegrityBlock(): Promise { + const newSignatures = new Array(); + // Append new signatures to the old stack for (const signingStrategy of this.signingStrategies) { const publicKey = await signingStrategy.getPublicKey(); checkIsValidKey('public', publicKey); @@ -40,7 +93,7 @@ export class IntegrityBlockSigner { [getPublicKeyAttributeName(publicKey)]: getRawPublicKey(publicKey), }; - const ibCbor = integrityBlock.toCBOR(); + const ibCbor = this.integrityBlock.toStrippedCbor(); const attrCbor = encode(newAttributes); checkDeterministic(ibCbor); checkDeterministic(attrCbor); @@ -56,27 +109,23 @@ export class IntegrityBlockSigner { // The signatures are calculated independently, and thus not appended to // the integrity block here to not affect subsequent calculations. - signatures.push({ + newSignatures.push({ signature, signatureAttributes: newAttributes, }); } - for (const signature of signatures) { - integrityBlock.addIntegritySignature(signature); + for (const signature of newSignatures) { + this.integrityBlock.addIntegritySignature(signature); } - const signedIbCbor = integrityBlock.toCBOR(); + const signedIbCbor = this.integrityBlock.toCbor(); checkDeterministic(signedIbCbor); - return { - integrityBlock: signedIbCbor, - signedWebBundle: new Uint8Array( - Buffer.concat([signedIbCbor, this.webBundle]) - ), - }; + return this.integrityBlock; } + /** @deprecated This class is only internal, use SignedWebBundle instead*/ readWebBundleLength(): number { // The length of the web bundle is contained in the last 8 bytes of the web // bundle, represented as BigEndian. @@ -87,25 +136,15 @@ export class IntegrityBlockSigner { return Number(buffer.readBigUint64BE()); } - obtainIntegrityBlock(): { - integrityBlock: IntegrityBlock; - offset: number; - } { - const webBundleLength = this.readWebBundleLength(); - if (webBundleLength !== this.webBundle.length) { - throw new Error( - 'IntegrityBlockSigner: Re-signing signed bundles is not supported yet.' - ); - } - return { integrityBlock: new IntegrityBlock(), offset: 0 }; - } - + // TODO: Move this method to SignedWebBundle/WebBundle class, signer do not need this, especially externally + /** @deprecated This class is only internal, use SignedWebBundle instead*/ calcWebBundleHash(): Uint8Array { const hash = crypto.createHash('sha512'); const data = hash.update(this.webBundle); return new Uint8Array(data.digest()); } + /** @internal */ generateDataToBeSigned( webBundleHash: Uint8Array, integrityBlockCborBytes: Uint8Array, @@ -140,51 +179,16 @@ export class IntegrityBlockSigner { return new Uint8Array(buffer); } + /** @deprecated Moved to utils */ verifySignature( data: Uint8Array, signature: Uint8Array, publicKey: KeyObject ): void { - // For ECDSA P-256 keys the algorithm is implicitly selected as SHA-256. - const isVerified = crypto.verify( - /*algorithm=*/ undefined, - data, - publicKey, - signature - ); - - if (!isVerified) { + if (!verifySignature(data, signature, publicKey)) { throw new Error( 'IntegrityBlockSigner: Signature cannot be verified. Your keys might be corrupted or not corresponding each other.' ); } } } - -export class IntegrityBlock { - private attributes: Map = new Map(); - private signatureStack: IntegritySignature[] = []; - - constructor() {} - - setWebBundleId(webBundleId: string) { - this.attributes.set('webBundleId', webBundleId); - } - - addIntegritySignature(is: IntegritySignature) { - this.signatureStack.push(is); - } - - toCBOR(): Uint8Array { - return encode([ - INTEGRITY_BLOCK_MAGIC, - VERSION_B2, - this.attributes, - this.signatureStack.map((integritySig) => { - // The CBOR must have an array of length 2 containing the following: - // (0) attributes and (1) signature. The order is important. - return [integritySig.signatureAttributes, integritySig.signature]; - }), - ]); - } -} diff --git a/js/sign/src/utils/constants.ts b/js/sign/src/utils/constants.ts index d1b30fa0..ef9fce6b 100644 --- a/js/sign/src/utils/constants.ts +++ b/js/sign/src/utils/constants.ts @@ -10,6 +10,13 @@ export const PUBLIC_KEY_ATTRIBUTE_NAME_MAPPING = new Map( ] ); +export const WEB_BUNDLE_ID_ATTRIBUTE_NAME = 'webBundleId'; + +// Reversed map above, useful for parsing Integrity Block +export const SIGNATURE_ATTRIBUTE_TO_TYPE_MAPPING = new Map( + Array.from(PUBLIC_KEY_ATTRIBUTE_NAME_MAPPING, ([k, v]) => [v, k]) +); + export const INTEGRITY_BLOCK_MAGIC = new Uint8Array([ 0xf0, 0x9f, 0x96, 0x8b, 0xf0, 0x9f, 0x93, 0xa6, ]); // 🖋📦 diff --git a/js/sign/src/utils/utils.ts b/js/sign/src/utils/utils.ts index fd6e19e0..43e37713 100644 --- a/js/sign/src/utils/utils.ts +++ b/js/sign/src/utils/utils.ts @@ -1,7 +1,10 @@ import assert from 'assert'; import crypto, { KeyObject } from 'crypto'; +import { decode } from 'cborg'; + import { + INTEGRITY_BLOCK_MAGIC, PUBLIC_KEY_ATTRIBUTE_NAME_MAPPING, SignatureType, } from './constants.js'; @@ -9,7 +12,7 @@ import { // A helper function which can be used to parse string formatted keys to // KeyObjects. export function parsePemKey( - unparsedKey: Buffer, + unparsedKey: string | Buffer, passphrase?: string ): KeyObject { return crypto.createPrivateKey({ @@ -37,6 +40,37 @@ export function isAsymmetricKeyTypeSupported(key: crypto.KeyObject): boolean { return maybeGetSignatureType(key) !== null; } +// 'Pure' = not signed web bundles +export function isPureWebBundle(bundle: Uint8Array): boolean { + let parsedBundle: Uint8Array[]; + try { + parsedBundle = decode(bundle, { useMaps: true }) as Uint8Array[]; + if (new TextDecoder('utf-8').decode(parsedBundle[0]) !== '🌐📦') { + return false; + } + // WebBundles have their length in the last cbor section + const buffer = Buffer.from(bundle.slice(-8)); + if (bundle.length != Number(buffer.readBigUint64BE())) { + return false; + } + } catch { + return false; + } + return true; +} + +// Just checks magic bytes at the begging, does not check if valid/parsable +export function isSignedWebBundle(bundle: Uint8Array): boolean { + // First CBOR byte: Array of length ... + // Second CBOR byte: String of length ... + // and then 8 bytes of magic string + return ( + bundle.length >= 10 && + (bundle[1] & 0b00011111) == 8 && + Buffer.from(bundle.slice(2, 10)).equals(INTEGRITY_BLOCK_MAGIC) + ); +} + export function getSignatureType(key: crypto.KeyObject): SignatureType { const signatureType = maybeGetSignatureType(key); assert( @@ -87,3 +121,18 @@ export function checkIsValidKey( throw new Error(`Expected either "Ed25519" or "ECDSA P-256" key.`); } } + +export function verifySignature( + data: Uint8Array, + signature: Uint8Array, + publicKey: KeyObject +): boolean { + // For ECDSA P-256 keys the algorithm is implicitly selected as SHA-256. + const isVerified = crypto.verify( + /*algorithm=*/ undefined, + data, + publicKey, + signature + ); + return isVerified; +} diff --git a/js/sign/src/wbn-sign.ts b/js/sign/src/wbn-sign.ts index e14b7b74..64d6e2df 100644 --- a/js/sign/src/wbn-sign.ts +++ b/js/sign/src/wbn-sign.ts @@ -1,7 +1,9 @@ export { IntegrityBlock, - IntegrityBlockSigner, -} from './signers/integrity-block-signer.js'; + type IntegritySignature, +} from './core/integrity-block.js'; +export { SignedWebBundle } from './core/signed-web-bundle.js'; +export { IntegrityBlockSigner } from './signers/integrity-block-signer.js'; export { NodeCryptoSigningStrategy } from './signers/node-crypto-signing-strategy.js'; export { ISigningStrategy } from './signers/signing-strategy-interface.js'; export { WebBundleId, getBundleId } from './web-bundle-id.js'; diff --git a/js/sign/tests/integrity-block-signer_test.js b/js/sign/tests/integrity-block-signer_test.js index 3167013b..4c69daf3 100644 --- a/js/sign/tests/integrity-block-signer_test.js +++ b/js/sign/tests/integrity-block-signer_test.js @@ -22,6 +22,7 @@ MHcCAQEEIG6HAXvoG+dOP20rbyPuGC21od4DAZCKBkPy/1902xPnoAoGCCqGSM49 AwEHoUQDQgAEHIIHO9B+7XJoXTXf3aTWC7aoK1PW4Db5Z8gSGXIkHlLrucUI4lyx DttYYhi36vrg5nR6zrfdhe7+8F1MoTvLuw== -----END EC PRIVATE KEY-----`; + const TEST_ED25519_WEB_BUNDLE_ID = '4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic'; const TEST_ECDSA_P256_WEB_BUNDLE_ID = @@ -115,7 +116,7 @@ describe('Integrity Block Signer', () => { it('encodes an empty integrity block CBOR correctly.', () => { const integrityBlock = new wbnSign.IntegrityBlock(); - const cbor = integrityBlock.toCBOR(); + const cbor = integrityBlock.toCbor(); expect(cbor).toEqual( Uint8Array.from(Buffer.from(EMPTY_INTEGRITY_BLOCK_HEX, 'hex')) @@ -150,7 +151,7 @@ describe('Integrity Block Signer', () => { const dataToBeSigned = signer.generateDataToBeSigned( signer.calcWebBundleHash(), - new wbnSign.IntegrityBlock().toCBOR(), + new wbnSign.IntegrityBlock().toCbor(), cborg.encode({ [utils.getPublicKeyAttributeName(keypair.publicKey)]: rawPubKey, }) @@ -207,7 +208,7 @@ describe('Integrity Block Signer', () => { const dataToBeSigned = signer.generateDataToBeSigned( signer.calcWebBundleHash(), - ibWithoutSignatures.toCBOR(), + ibWithoutSignatures.toCbor(), cborg.encode(sigAttr) ); @@ -282,7 +283,7 @@ describe('Integrity Block Signer', () => { const dataToBeSigned = signer.generateDataToBeSigned( signer.calcWebBundleHash(), - ibWithoutSignatures.toCBOR(), + ibWithoutSignatures.toCbor(), cborg.encode(signatureAttributes) ); // For ECDSA P-256 keys the algorithm is implicitly selected as SHA-256. diff --git a/js/sign/tests/integrity-block_test.js b/js/sign/tests/integrity-block_test.js new file mode 100644 index 00000000..364e91b9 --- /dev/null +++ b/js/sign/tests/integrity-block_test.js @@ -0,0 +1,142 @@ +// @ts-check +/** @typedef {import('jasmine')} _ */ + +import { IntegrityBlock } from '../lib/wbn-sign.js'; + +describe('Integrity Block', () => { + it('fromCbor(toCbor(a)) == a', () => { + const ib = new IntegrityBlock(); + ib.setWebBundleId( + '4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic' + ); + ib.addIntegritySignature({ + signatureAttributes: { ed25519PublicKey: new Uint8Array(32).fill(1) }, + signature: Buffer.from(new Uint8Array(64).fill(2)), + }); + + const ib2 = IntegrityBlock.fromCbor(ib.toCbor()); + expect(ib2).toEqual(ib); + }); + + it('toCbor(fromCbor(bytes)) == bytes', () => { + const ib = new IntegrityBlock(); + ib.setWebBundleId( + 'amoiebz32b7o24tilu257xne2yf3nkblkploanxzm7ebeglseqpfeaacai' + ); + ib.addIntegritySignature({ + signatureAttributes: { + ecdsaP256SHA256PublicKey: new Uint8Array(33).fill(3), + }, + signature: Buffer.from(new Uint8Array(64).fill(4)), + }); + + const bytes = ib.toCbor(); + const ib2 = IntegrityBlock.fromCbor(bytes); + expect(ib2.toCbor()).toEqual(bytes); + }); + + it('fromCbor(toCbor(a)) == a with multiple signatures', () => { + const ib = new IntegrityBlock(); + ib.setWebBundleId( + '4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic' + ); + ib.addIntegritySignature({ + signatureAttributes: { ed25519PublicKey: new Uint8Array(32).fill(1) }, + signature: Buffer.from(new Uint8Array(64).fill(2)), + }); + ib.addIntegritySignature({ + signatureAttributes: { + ecdsaP256SHA256PublicKey: new Uint8Array(33).fill(3), + }, + signature: Buffer.from(new Uint8Array(64).fill(4)), + }); + + const ib2 = IntegrityBlock.fromCbor(ib.toCbor()); + expect(ib2).toEqual(ib); + expect(ib2.getSignatureStack().length).toEqual(2); + }); + + it('fails to parse with invalid magic', () => { + const ib = new IntegrityBlock(); + ib.setWebBundleId( + '4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic' + ); + ib.addIntegritySignature({ + signatureAttributes: { ed25519PublicKey: new Uint8Array(32).fill(1) }, + signature: Buffer.from(new Uint8Array(64).fill(2)), + }); + + const bytes = ib.toCbor(); + bytes[5] = 0x00; // Corrupt magic + expect(() => IntegrityBlock.fromCbor(bytes)).toThrowError( + /Invalid integrity block: Invalid magic bytes/ + ); + }); + + it('fails to parse with invalid version', () => { + const ib = new IntegrityBlock(); + ib.setWebBundleId( + '4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic' + ); + ib.addIntegritySignature({ + signatureAttributes: { ed25519PublicKey: new Uint8Array(32).fill(1) }, + signature: Buffer.from(new Uint8Array(64).fill(2)), + }); + + const bytes = ib.toCbor(); + // Version should be kept in bytes: 12-16 + bytes[14] = 0xff; + expect(() => IntegrityBlock.fromCbor(bytes)).toThrowError( + /Invalid integrity block: Invalid version/ + ); + }); + + it('fails to parse without webBundleId attribute', () => { + const ib = new IntegrityBlock(); + // Don't set webBundleId + ib.addIntegritySignature({ + signatureAttributes: { ed25519PublicKey: new Uint8Array(32).fill(1) }, + signature: Buffer.from(new Uint8Array(64).fill(2)), + }); + + const bytes = ib.toCbor(); + expect(() => IntegrityBlock.fromCbor(bytes)).toThrowError( + /Invalid integrity block: Missing webBundleId attribute/ + ); + }); + + it('fails to parse with unknown signature attribute key', () => { + const ib = new IntegrityBlock(); + ib.setWebBundleId( + '4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic' + ); + ib.addIntegritySignature({ + signatureAttributes: { unknownAttribute: new Uint8Array(32).fill(1) }, + signature: Buffer.from(new Uint8Array(64).fill(2)), + }); + + const bytes = ib.toCbor(); + expect(() => IntegrityBlock.fromCbor(bytes)).toThrowError( + /Invalid integrity block: .* key type/ + ); + }); + + it('fails to parse with multiple signature attributes', () => { + const ib = new IntegrityBlock(); + ib.setWebBundleId( + '4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic' + ); + ib.addIntegritySignature({ + signatureAttributes: { + ed25519PublicKey: new Uint8Array(32).fill(1), + extraAttribute: new Uint8Array(32).fill(2), + }, + signature: Buffer.from(new Uint8Array(64).fill(2)), + }); + + const bytes = ib.toCbor(); + expect(() => IntegrityBlock.fromCbor(bytes)).toThrowError( + /Invalid integrity block: Invalid signature attributes/ + ); + }); +}); diff --git a/js/sign/tests/signed-web-bundle_test.js b/js/sign/tests/signed-web-bundle_test.js new file mode 100644 index 00000000..bec9e8cf --- /dev/null +++ b/js/sign/tests/signed-web-bundle_test.js @@ -0,0 +1,110 @@ +// @ts-check +/** @typedef {import('jasmine')} _ */ +import fs from 'fs'; +import path from 'path'; +import url from 'url'; + +import { + getRawPublicKey, + NodeCryptoSigningStrategy, + parsePemKey, + SignedWebBundle, +} from '../lib/wbn-sign.js'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); +const TEST_ED25519_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIB8nP5PpWU7HiILHSfh5PYzb5GAcIfHZ+bw6tcd/LZXh +-----END PRIVATE KEY-----`; +const TEST_ED25519_PRIVATE_KEY_2 = `-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIKxzyXkSRaUIc6fpI+TYecPQjo4YJTSFPulQY/0lGjs1 +-----END PRIVATE KEY-----`; + +const TEST_ECDSA_P256_WEB_BUNDLE_ID = + 'amoiebz32b7o24tilu257xne2yf3nkblkploanxzm7ebeglseqpfeaacai'; +const TEST_ED25519_WEB_BUNDLE_ID_1 = + '4tkrnsmftl4ggvvdkfth3piainqragus2qbhf7rlz2a3wo3rh4wqaaic'; + +const [STRATEGY_KEY_1, STRATEGY_KEY_2] = [ + TEST_ED25519_PRIVATE_KEY, + TEST_ED25519_PRIVATE_KEY_2, +].map((key) => new NodeCryptoSigningStrategy(parsePemKey(key))); + +const UNSIGNED_BUNDLE_PATH = path.resolve(__dirname, 'testdata/unsigned.wbn'); +const UNSIGNED_WEB_BUNDLE_BYTES = Uint8Array.from( + fs.readFileSync(UNSIGNED_BUNDLE_PATH) +); + +describe('Signed Web Bundle - ', function () { + it('fromWebBundle() - bundle id got from first key by default', async function () { + const double_signed_bundle = await SignedWebBundle.fromWebBundle( + UNSIGNED_WEB_BUNDLE_BYTES, + [STRATEGY_KEY_1, STRATEGY_KEY_2] + ); + expect(double_signed_bundle.getWebBundleId()).toEqual( + TEST_ED25519_WEB_BUNDLE_ID_1 + ); + }); + + it('fromWebBundle() - bundle id successfully overridden', async function () { + const double_signed_bundle = await SignedWebBundle.fromWebBundle( + UNSIGNED_WEB_BUNDLE_BYTES, + [STRATEGY_KEY_1, STRATEGY_KEY_2], + { webBundleId: TEST_ECDSA_P256_WEB_BUNDLE_ID } + ); + expect(double_signed_bundle.getWebBundleId()).toEqual( + TEST_ECDSA_P256_WEB_BUNDLE_ID + ); + }); + + it('addSignature() - the same result as signing with two keys', async function () { + // I'm using only ed25519 keys on purpose: Ecdsa signing algorithm is not deterministic and adds a random nonce + // so I could not just simply verify if two integrity blocks are same after different sequence of operations + const bundle_signed_by_first_key_then_other = await ( + await SignedWebBundle.fromWebBundle(UNSIGNED_WEB_BUNDLE_BYTES, [ + STRATEGY_KEY_1, + ]) + ).addSignature(STRATEGY_KEY_2); + const bundle_signed_by_two_keys_at_once = + await SignedWebBundle.fromWebBundle(UNSIGNED_WEB_BUNDLE_BYTES, [ + STRATEGY_KEY_1, + STRATEGY_KEY_2, + ]); + const bundle_signed_by_second_key_then_other = await ( + await SignedWebBundle.fromWebBundle(UNSIGNED_WEB_BUNDLE_BYTES, [ + STRATEGY_KEY_2, + ]) + ).addSignature(STRATEGY_KEY_1); + const bundle_signed_by_two_keys_at_once_reversed_order = + await SignedWebBundle.fromWebBundle(UNSIGNED_WEB_BUNDLE_BYTES, [ + STRATEGY_KEY_2, + STRATEGY_KEY_1, + ]); + + expect(bundle_signed_by_first_key_then_other).toEqual( + bundle_signed_by_two_keys_at_once + ); + expect(bundle_signed_by_second_key_then_other).toEqual( + bundle_signed_by_two_keys_at_once_reversed_order + ); + expect(bundle_signed_by_first_key_then_other).not.toEqual( + bundle_signed_by_second_key_then_other + ); + }); + + it('removeSignature() - bundle signed with key A and B + removing A = bundle singed with key B', async function () { + const bundle_signed_by_second_key = await SignedWebBundle.fromWebBundle( + UNSIGNED_WEB_BUNDLE_BYTES, + [STRATEGY_KEY_2], + { webBundleId: TEST_ED25519_WEB_BUNDLE_ID_1 } + ); + const bundle_signed_by_two_keys_second_removed = ( + await SignedWebBundle.fromWebBundle(UNSIGNED_WEB_BUNDLE_BYTES, [ + STRATEGY_KEY_1, + STRATEGY_KEY_2, + ]) + ).removeSignature(getRawPublicKey(await STRATEGY_KEY_1.getPublicKey())); + expect(bundle_signed_by_two_keys_second_removed).toEqual( + bundle_signed_by_second_key + ); + }); +}); diff --git a/js/sign/tsconfig.json b/js/sign/tsconfig.json index 8aa65a7b..c5e2e7d2 100644 --- a/js/sign/tsconfig.json +++ b/js/sign/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "node", "esModuleInterop": true, "strict": true, + "stripInternal": true, "declaration": true, "newLine": "lf", "outDir": "lib"