diff --git a/.changeset/two-pumas-type.md b/.changeset/two-pumas-type.md new file mode 100644 index 00000000..4ed0ef83 --- /dev/null +++ b/.changeset/two-pumas-type.md @@ -0,0 +1,5 @@ +--- +'@sigstore/sign': minor +--- + +Initial release diff --git a/README.md b/README.md index cb343b3e..b654bc11 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ JavaScript libraries for interacting with [Sigstore][6] services. * [`sigstore`](./packages/client) - Client library implementing Sigstore signing/verification workflows. * [`@sigstore/bundle`](./packages/bundle) - TypeScript types and utility functions for working with Sigstore bundles. * [`@sigstore/cli`](./packages/cli) - Command line interface for signing/verifying artifacts with Sigstore. +* [`@sigstore/sign`](./packages/sign) - Library for generating Sigstore signatures. * [`@sigstore/tuf`](./packages/tuf) - Library for interacting with the Sigstore TUF repository. * [`@sigstore/rekor-types`](./packages/rekor-types) - TypeScript types for the Sigstore Rekor REST API. * [`@sigstore/mock`](./packages/mock) - Mocking library for Sigstore services. diff --git a/package-lock.json b/package-lock.json index ffd2d01a..57b9b0db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3095,6 +3095,10 @@ "resolved": "packages/rekor-types", "link": true }, + "node_modules/@sigstore/sign": { + "resolved": "packages/sign", + "link": true + }, "node_modules/@sigstore/tuf": { "resolved": "packages/tuf", "link": true @@ -13451,7 +13455,7 @@ }, "packages/bundle": { "name": "@sigstore/bundle", - "version": "0.0.0", + "version": "1.0.0", "license": "Apache-2.0", "dependencies": { "@sigstore/protobuf-specs": "^0.2.0" @@ -13462,7 +13466,7 @@ }, "packages/cli": { "name": "@sigstore/cli", - "version": "0.1.0", + "version": "0.1.1", "license": "Apache-2.0", "dependencies": { "@oclif/color": "^1.0.9", @@ -13470,7 +13474,7 @@ "@oclif/plugin-help": "^5", "open": "^8.4.2", "openid-client": "^5.4.3", - "sigstore": "^1.6.0" + "sigstore": "^1.8.0" }, "bin": { "sigstore": "bin/run" @@ -13486,12 +13490,12 @@ }, "packages/client": { "name": "sigstore", - "version": "1.7.0", + "version": "1.8.0", "license": "Apache-2.0", "dependencies": { - "@sigstore/bundle": "^0.0.0", + "@sigstore/bundle": "^1.0.0", "@sigstore/protobuf-specs": "^0.2.0", - "@sigstore/tuf": "^1.0.1", + "@sigstore/tuf": "^1.0.3", "make-fetch-happen": "^11.0.1" }, "bin": { @@ -13543,7 +13547,7 @@ }, "packages/mock": { "name": "@sigstore/mock", - "version": "0.1.0", + "version": "0.1.1", "license": "Apache-2.0", "dependencies": { "@peculiar/webcrypto": "^1.4.3", @@ -13606,9 +13610,28 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "packages/sign": { + "name": "@sigstore/sign", + "version": "0.0.0", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^1.0.0", + "@sigstore/protobuf-specs": "^0.2.0", + "make-fetch-happen": "^11.0.1" + }, + "devDependencies": { + "@sigstore/jest": "^0.0.0", + "@sigstore/mock": "^0.1.0", + "@sigstore/rekor-types": "^1.0.0", + "@types/make-fetch-happen": "^10.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "packages/tuf": { "name": "@sigstore/tuf", - "version": "1.0.2", + "version": "1.0.3", "license": "Apache-2.0", "dependencies": { "@sigstore/protobuf-specs": "^0.2.0", @@ -16037,7 +16060,7 @@ "oclif": "^3", "open": "^8.4.2", "openid-client": "^5.4.3", - "sigstore": "^1.6.0", + "sigstore": "^1.8.0", "tslib": "^2.6.0" } }, @@ -16094,6 +16117,18 @@ "openapi-typescript-codegen": "^0.25.0" } }, + "@sigstore/sign": { + "version": "file:packages/sign", + "requires": { + "@sigstore/bundle": "^1.0.0", + "@sigstore/jest": "^0.0.0", + "@sigstore/mock": "^0.1.0", + "@sigstore/protobuf-specs": "^0.2.0", + "@sigstore/rekor-types": "^1.0.0", + "@types/make-fetch-happen": "^10.0.0", + "make-fetch-happen": "^11.0.1" + } + }, "@sigstore/tuf": { "version": "file:packages/tuf", "requires": { @@ -22175,11 +22210,11 @@ "sigstore": { "version": "file:packages/client", "requires": { - "@sigstore/bundle": "^0.0.0", + "@sigstore/bundle": "^1.0.0", "@sigstore/jest": "^0.0.0", "@sigstore/protobuf-specs": "^0.2.0", "@sigstore/rekor-types": "^1.0.0", - "@sigstore/tuf": "^1.0.1", + "@sigstore/tuf": "^1.0.3", "@tufjs/repo-mock": "^1.1.0", "@types/make-fetch-happen": "^10.0.0", "make-fetch-happen": "^11.0.1" diff --git a/packages/bundle/src/__tests__/serialized.test.ts b/packages/bundle/src/__tests__/serialized.test.ts index 952707cb..71c747c5 100644 --- a/packages/bundle/src/__tests__/serialized.test.ts +++ b/packages/bundle/src/__tests__/serialized.test.ts @@ -32,6 +32,47 @@ import { import type { Bundle } from '../bundle'; +describe('envelopeToJSON', () => { + const dsseEnvelope: Envelope = { + payload: Buffer.from('payload'), + payloadType: 'application/vnd.in-toto+json', + signatures: [ + { + keyid: 'keyid', + sig: Buffer.from('signature'), + }, + ], + }; + + it('matches the serialized form of the Envelope', () => { + const json = envelopeToJSON(dsseEnvelope); + + expect(json).toBeTruthy(); + expect(json.payload).toEqual(dsseEnvelope.payload.toString('base64')); + expect(json.payloadType).toEqual(dsseEnvelope.payloadType); + expect(json.signatures).toHaveLength(dsseEnvelope.signatures.length); + const signature = json.signatures[0]; + const expectedSignature = dsseEnvelope.signatures[0]; + expect(signature).toBeTruthy(); + expect(signature?.keyid).toEqual(expectedSignature.keyid); + expect(signature?.sig).toEqual(expectedSignature.sig.toString('base64')); + }); +}); + +describe('envelopeFromJSON', () => { + const envelope = { + payload: Buffer.from('ABC'), + payloadType: 'application/json', + signatures: [{ sig: Buffer.from('BAR'), keyid: '' }], + }; + + it('matches the deserialized form of the Envelope', () => { + const json = envelopeToJSON(envelope); + const deserializedEnvelope = envelopeFromJSON(json); + expect(deserializedEnvelope).toEqual(envelope); + }); +}); + describe('bundleToJSON', () => { const tlogEntries = [ { diff --git a/packages/sign/LICENSE b/packages/sign/LICENSE new file mode 100644 index 00000000..e9e7c167 --- /dev/null +++ b/packages/sign/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/sign/README.md b/packages/sign/README.md new file mode 100644 index 00000000..177a9a93 --- /dev/null +++ b/packages/sign/README.md @@ -0,0 +1,184 @@ +# @sigstore/sign · [![npm version](https://img.shields.io/npm/v/@sigstore/sign.svg?style=flat)](https://www.npmjs.com/package/sigstore) [![CI Status](https://github.com/sigstore/sigstore-js/workflows/CI/badge.svg)](https://github.com/sigstore/sigstore-js/actions/workflows/ci.yml) [![Smoke Test Status](https://github.com/sigstore/sigstore-js/workflows/smoke-test/badge.svg)](https://github.com/sigstore/sigstore-js/actions/workflows/smoke-test.yml) + +A library for generating [Sigstore][1] signatures. + +## Features + +- Support for keyless signature generation with [Fulcio][2]-issued signing + certificates +- Support for ambient OIDC credential detection in CI/CD environments +- Support for recording signatures to the [Rekor][3] transparency log +- Support for requesting timestamped countersignature from a [Timestamp + Authority][4] + +## Prerequisites + +- Node.js version >= 14.17.0 + +## Installation + +``` +npm install @sigstore/sign +``` + +## Overview + +This library provides the building blocks for composing custom Sigstore signing +workflows. + +### BundleBuilder + +The top-level component is the `BundleBuilder` which has responsibility for +taking some artifact and returning a [Sigstore bundle][5] containing the +signature for that artifact and the various materials necessary to verify that +signature. + +```typescript +interface BundleBuilder { + create: (artifact: Artifact) => Promise; +} +``` + +The artifact to be signed is simply an array of bytes and an optional mimetype. +The type is necessary when the signature is packaged as a [DSSE][6] envelope. + +```typescript +type Artifact = { + data: Buffer; + type?: string; +}; +``` + +There are two `BundleBuilder` implementations provided as part of this package: + +- [`DSSEBundleBuilder`](./src/bundler/dsse.ts) - Combines the verification material and + artifact signature into a [`dsse_envelope`][7] -style Sigstore bundle +- [`MessageBundleBuilder`](./src/bundler/message.ts) - Combines the verification + material and artifact signature into a [`message_signature`][8]-style Sigstore + bundle. + +### Signer + +Every `BundleBuilder` must be instantiated with a `Signer` implementation. The +`Signer` is responsible for taking a `Buffer` and returning an `Signature`. + +```typescript +interface Signer { + sign: (data: Buffer) => Promise; +} +``` + +The returned `Signature` contains a signature and the public key which can be +used to verify that signature -- the key may either take the form of a x509 +certificate or public key. + +```typescript +type Signature = { + signature: Buffer; + key: KeyMaterial; +}; + +type KeyMaterial = + | { + $case: 'x509Certificate'; + certificate: string; + } + | { + $case: 'publicKey'; + publicKey: string; + hint?: string; + }; +``` + +This package provides the [`FulcioSigner`](./src/signer/fulcio/index.ts) +which implements the `Signer` interface and signs the artifact with an +ephemeral keypair. It will also retrieve an OIDC token from the configured +`IdentityProvider` and then request a signing certificate from Fulcio which binds +the ephemeral key to the identity embedded in the token. This signing +certificate is returned as part of the `Signature`. + +### Witness + +The `BundleBuilder` may also be configured with zero-or-more `Witness` +instances. Each `Witness` receives the artifact signature and the public key +and returns an `VerificationMaterial` which represents some sort of +counter-signature for the artifact's signature. + +```typescript +interface Witness { + testify: ( + signature: SignatureBundle, + publicKey: string + ) => Promise; +} +``` + +The returned `VerificationMaterial` may contain either Rekor transparency log +entries or RFC3161 timestamps. + +```typescript +type VerificationMaterial = { + tlogEntries?: TransparencyLogEntry[]; + rfc3161Timestamps?: RFC3161SignedTimestamp[]; +}; +``` + +The entries in the returned `VerificationMaterial` are automatically added to +the Sigstore `Bundle` by the `BundleBuilder`. + +The package provides two different `Witness` implementations: + +- [`RekorWitness`](./src/witness/tlog/index.ts) - Adds an entry to the Rekor + transparency log and returns a `TransparencyLogEntry` to be included in the + `Bundle` +- [`TSAWitness`](./src/witness/tsa/index.ts) - Requests an RFC3161 timestamp + over the artifact signature and returns an `RFC3161SignedTimestamp` to be + included in the `Bundle` + +## Usage Example + +```typescript +const { + CIContextProvider, + DSSEBundleBuilder, + FulcioSigner, + RekorWitness, + TSAWitness, +} = require('@sigstore/sign'); + +// Set-up the signer +const signer = new FulcioSigner({ + fulcioBaseURL: 'https://fulcio.sigstore.dev', + identityProvider: new CIContextProvider('sigstore'), +}); + +// Set-up the witnesses +const rekorWitness = new RekorWitness({ + rekorBaseURL: 'https://rekor.sigstore.dev', +}); + +const tsaWitness = new TSAWitness({ + tsaBaseURL: 'https://tsa.github.com', +}); + +// Instantiate a bundle builder +const bundler = new DSSEBundleBuilder({ + signer, + witnesses: [rekorWitness, tsaWitness], +}); + +// Sign a thing +const artifact = { + data: Buffer.from('something to be signed'), +}; +const bundle = await bundler.create(artifact); +``` + +[1]: https://www.sigstore.dev +[2]: https://github.com/sigstore/fulcio +[3]: https://github.com/sigstore/rekor +[4]: https://github.com/sigstore/timestamp-authority +[5]: https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto +[6]: https://github.com/secure-systems-lab/dsse +[7]: https://github.com/sigstore/protobuf-specs/blob/5ef54068bb534152474c5685f5cd248f38549fbd/protos/sigstore_bundle.proto#L80 +[8]: https://github.com/sigstore/protobuf-specs/blob/5ef54068bb534152474c5685f5cd248f38549fbd/protos/sigstore_bundle.proto#L74 diff --git a/packages/sign/jest.config.js b/packages/sign/jest.config.js new file mode 100644 index 00000000..55d5b1ac --- /dev/null +++ b/packages/sign/jest.config.js @@ -0,0 +1,23 @@ +/* +Copyright 2022 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const base = require('../../jest.config.base') + +module.exports = { + ...base, + displayName: 'sign', + setupFilesAfterEnv: ['@sigstore/jest/all'], + testPathIgnorePatterns: ['/dist/'], +}; diff --git a/packages/sign/package.json b/packages/sign/package.json new file mode 100644 index 00000000..f9a73dcb --- /dev/null +++ b/packages/sign/package.json @@ -0,0 +1,42 @@ +{ + "name": "@sigstore/sign", + "version": "0.0.0", + "description": "Sigstore signing library", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "clean": "shx rm -rf dist *.tsbuildinfo", + "build": "tsc --build", + "test": "jest" + }, + "files": [ + "dist" + ], + "author": "bdehamer@github.com", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/sigstore/sigstore-js.git" + }, + "bugs": { + "url": "https://github.com/sigstore/sigstore-js/issues" + }, + "homepage": "https://github.com/sigstore/sigstore-js/tree/main/packages/sign#readme", + "publishConfig": { + "provenance": true + }, + "devDependencies": { + "@sigstore/jest": "^0.0.0", + "@sigstore/mock": "^0.1.0", + "@sigstore/rekor-types": "^1.0.0", + "@types/make-fetch-happen": "^10.0.0" + }, + "dependencies": { + "@sigstore/bundle": "^1.0.0", + "@sigstore/protobuf-specs": "^0.2.0", + "make-fetch-happen": "^11.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } +} diff --git a/packages/sign/src/__tests__/bundler/base.test.ts b/packages/sign/src/__tests__/bundler/base.test.ts new file mode 100644 index 00000000..5ce052ea --- /dev/null +++ b/packages/sign/src/__tests__/bundler/base.test.ts @@ -0,0 +1,331 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import * as sigstore from '@sigstore/bundle'; +import { HashAlgorithm } from '@sigstore/protobuf-specs'; +import assert from 'assert'; +import { Artifact, BaseBundleBuilder } from '../../bundler/base'; +import { toMessageSignatureBundle } from '../../bundler/bundle'; +import { Signature, Signer } from '../../signer'; +import { crypto, pem } from '../../util'; +import { VerificationMaterial, Witness } from '../../witness'; + +describe('BaseBundleBuilder', () => { + const artifact: Artifact = { + data: Buffer.from('data'), + }; + + // Signature fixture to return from fake Signer + const signature = Buffer.from('signature'); + const key = 'publickey'; + + const publicKeySignature = { + key: { + $case: 'publicKey', + publicKey: key, + hint: 'hint', + }, + signature: signature, + } satisfies Signature; + + const fakePublicKeySigner = { + sign: jest.fn().mockResolvedValue(publicKeySignature), + } satisfies Signer; + + const certificateSignature = { + key: { + $case: 'x509Certificate', + certificate: key, + }, + signature: signature, + } satisfies Signature; + + const fakeCertificateSigner = { + sign: jest.fn().mockResolvedValue(certificateSignature), + } satisfies Signer; + + // VerificationMaterial fixture to return from fake Witness + const timestamp = Buffer.from('timestamp'); + const timestampAffidavit: VerificationMaterial = { + rfc3161Timestamps: [{ signedTimestamp: timestamp }], + }; + + const fakeTSAWitness: Witness = { + testify: jest.fn().mockResolvedValue(timestampAffidavit), + }; + + const tlogAffidavit: VerificationMaterial = { + tlogEntries: [ + { + logIndex: '1234', + logId: { + keyId: Buffer.from('keyId'), + }, + kindVersion: { + kind: 'hashedrekord', + version: '0.0.1', + }, + integratedTime: '123456789', + inclusionProof: undefined, + inclusionPromise: { + signedEntryTimestamp: Buffer.from('set'), + }, + canonicalizedBody: Buffer.from('body'), + }, + ], + }; + + const fakeRekorWitness: Witness = { + testify: jest.fn().mockResolvedValue(tlogAffidavit), + }; + + class FakeBundleBuilder extends BaseBundleBuilder { + protected override async package( + artifact: Artifact, + signature: Signature + ): Promise { + return toMessageSignatureBundle(artifact, signature); + } + } + + describe('create', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when configured with a public key signer', () => { + const subject = new FakeBundleBuilder({ + signer: fakePublicKeySigner, + witnesses: [fakeTSAWitness, fakeRekorWitness], + }); + + it('invokes the signer', async () => { + await subject.create(artifact); + expect(fakePublicKeySigner.sign).toBeCalledWith(artifact.data); + }); + + it('invokes the witnesses', async () => { + await subject.create(artifact); + + const expectedContent: sigstore.Bundle['content'] = { + $case: 'messageSignature', + messageSignature: { + messageDigest: { + algorithm: HashAlgorithm.SHA2_256, + digest: crypto.hash(artifact.data), + }, + signature: expect.anything(), + }, + }; + + expect(fakeTSAWitness.testify).toBeCalledWith( + expectedContent, + publicKeySignature.key.publicKey + ); + + expect(fakeRekorWitness.testify).toBeCalledWith( + expectedContent, + publicKeySignature.key.publicKey + ); + }); + + it('returns a valid bundle', async () => { + const bundle = await subject.create(artifact); + + expect(bundle).toBeTruthy(); + expect(bundle.mediaType).toEqual( + 'application/vnd.dev.sigstore.bundle+json;version=0.1' + ); + expect(bundle.content).toBeTruthy(); + expect(bundle.verificationMaterial).toBeTruthy(); + + // Content - MessageSignature + assert(bundle.content?.$case === 'messageSignature'); + expect(bundle.content.messageSignature).toBeTruthy(); + expect(bundle.content.messageSignature.signature).toEqual(signature); + expect(bundle.content.messageSignature.messageDigest).toBeTruthy(); + expect( + bundle.content.messageSignature?.messageDigest?.algorithm + ).toEqual(HashAlgorithm.SHA2_256); + expect(bundle.content.messageSignature?.messageDigest?.digest).toEqual( + crypto.hash(artifact.data) + ); + + // VerificationMaterial - Content + expect(bundle.verificationMaterial?.content).toBeTruthy(); + assert(bundle.verificationMaterial?.content?.$case === 'publicKey'); + expect(bundle.verificationMaterial?.content?.publicKey).toBeTruthy(); + expect(bundle.verificationMaterial?.content?.publicKey.hint).toEqual( + publicKeySignature.key.hint + ); + + // VerificationMaterial - TimestampVerificationData + expect( + bundle.verificationMaterial.timestampVerificationData + ).toBeTruthy(); + expect( + bundle.verificationMaterial.timestampVerificationData + ?.rfc3161Timestamps + ).toBeTruthy(); + expect( + bundle.verificationMaterial.timestampVerificationData + ?.rfc3161Timestamps + ).toHaveLength(1); + expect( + bundle.verificationMaterial.timestampVerificationData + ?.rfc3161Timestamps?.[0].signedTimestamp + ).toEqual(timestamp); + + // VerificationMaterial - TlogEntries + expect(bundle.verificationMaterial.tlogEntries).toBeTruthy(); + expect(bundle.verificationMaterial.tlogEntries).toHaveLength(1); + expect(bundle.verificationMaterial.tlogEntries).toEqual( + tlogAffidavit.tlogEntries + ); + }); + }); + + describe('when configured with a certificate signer', () => { + const subject = new FakeBundleBuilder({ + signer: fakeCertificateSigner, + witnesses: [fakeRekorWitness, fakeTSAWitness], + }); + + it('invokes the signer', async () => { + await subject.create(artifact); + expect(fakeCertificateSigner.sign).toBeCalledWith(artifact.data); + }); + + it('invokes the witness', async () => { + await subject.create(artifact); + + const expectedContent: sigstore.Bundle['content'] = { + $case: 'messageSignature', + messageSignature: { + messageDigest: { + algorithm: HashAlgorithm.SHA2_256, + digest: crypto.hash(artifact.data), + }, + signature: expect.anything(), + }, + }; + + expect(fakeTSAWitness.testify).toBeCalledWith( + expectedContent, + publicKeySignature.key.publicKey + ); + + expect(fakeRekorWitness.testify).toBeCalledWith( + expectedContent, + publicKeySignature.key.publicKey + ); + }); + + it('returns a valid bundle', async () => { + const bundle = await subject.create(artifact); + + expect(bundle).toBeTruthy(); + expect(bundle.mediaType).toEqual( + 'application/vnd.dev.sigstore.bundle+json;version=0.1' + ); + expect(bundle.content).toBeTruthy(); + expect(bundle.verificationMaterial).toBeTruthy(); + + // Content - MessageSignature + assert(bundle.content?.$case === 'messageSignature'); + expect(bundle.content.messageSignature).toBeTruthy(); + expect(bundle.content.messageSignature.signature).toEqual(signature); + expect(bundle.content.messageSignature.messageDigest).toBeTruthy(); + expect( + bundle.content.messageSignature?.messageDigest?.algorithm + ).toEqual(HashAlgorithm.SHA2_256); + expect(bundle.content.messageSignature?.messageDigest?.digest).toEqual( + crypto.hash(artifact.data) + ); + + // VerificationMaterial - Content + expect(bundle.verificationMaterial?.content).toBeTruthy(); + assert( + bundle.verificationMaterial?.content?.$case === 'x509CertificateChain' + ); + expect( + bundle.verificationMaterial?.content?.x509CertificateChain + ).toBeTruthy(); + expect( + bundle.verificationMaterial?.content?.x509CertificateChain + .certificates + ).toHaveLength(1); + expect( + bundle.verificationMaterial?.content?.x509CertificateChain + .certificates[0].rawBytes + ).toEqual(pem.toDER(key)); + + // VerificationMaterial - TimestampVerificationData + expect( + bundle.verificationMaterial.timestampVerificationData + ).toBeTruthy(); + expect( + bundle.verificationMaterial.timestampVerificationData + ?.rfc3161Timestamps + ).toBeTruthy(); + expect( + bundle.verificationMaterial.timestampVerificationData + ?.rfc3161Timestamps + ).toHaveLength(1); + expect( + bundle.verificationMaterial.timestampVerificationData + ?.rfc3161Timestamps?.[0].signedTimestamp + ).toEqual(timestamp); + + // VerificationMaterial - TlogEntries + expect(bundle.verificationMaterial.tlogEntries).toBeTruthy(); + expect(bundle.verificationMaterial.tlogEntries).toHaveLength(1); + expect(bundle.verificationMaterial.tlogEntries).toEqual( + tlogAffidavit.tlogEntries + ); + }); + }); + + describe('when configured with multiple witnesses of the same type', () => { + const subject = new FakeBundleBuilder({ + signer: fakePublicKeySigner, + witnesses: [ + fakeTSAWitness, + fakeRekorWitness, + fakeTSAWitness, + fakeRekorWitness, + ], + }); + + it('invokes the witnesses the correct number of times', async () => { + await subject.create(artifact); + + expect(fakeTSAWitness.testify).toBeCalledTimes(2); + expect(fakeRekorWitness.testify).toBeCalledTimes(2); + }); + + it('returns a bundle with all the verification material', async () => { + const bundle = await subject.create(artifact); + + expect(bundle).toBeTruthy(); + expect(bundle.verificationMaterial?.tlogEntries).toHaveLength(2); + expect( + bundle.verificationMaterial?.timestampVerificationData + ?.rfc3161Timestamps + ).toHaveLength(2); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/bundler/bundle.test.ts b/packages/sign/src/__tests__/bundler/bundle.test.ts new file mode 100644 index 00000000..f542bc8e --- /dev/null +++ b/packages/sign/src/__tests__/bundler/bundle.test.ts @@ -0,0 +1,177 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { HashAlgorithm } from '@sigstore/protobuf-specs'; +import assert from 'assert'; +import { toDSSEBundle, toMessageSignatureBundle } from '../../bundler/bundle'; +import { crypto, pem } from '../../util'; + +import type { Artifact } from '../../bundler/base'; +import type { Signature } from '../../signer'; + +const sigBytes = Buffer.from('signature'); +const certificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----`; + +const artifact = { + type: 'application/json', + data: Buffer.from('data'), +} satisfies Artifact; + +describe('toMessageSignatureBundle', () => { + const signature = { + key: { + $case: 'x509Certificate', + certificate: certificate, + }, + signature: sigBytes, + } satisfies Signature; + + it('returns a valid message signature bundle', () => { + const b = toMessageSignatureBundle(artifact, signature); + + expect(b).toBeTruthy(); + expect(b.mediaType).toEqual( + 'application/vnd.dev.sigstore.bundle+json;version=0.1' + ); + + assert(b.content?.$case === 'messageSignature'); + expect(b.content.messageSignature).toBeTruthy(); + expect(b.content.messageSignature.messageDigest.algorithm).toEqual( + HashAlgorithm.SHA2_256 + ); + expect(b.content.messageSignature.messageDigest.digest).toEqual( + crypto.hash(artifact.data) + ); + expect(b.content.messageSignature.signature).toEqual(sigBytes); + + expect(b.verificationMaterial).toBeTruthy(); + assert(b.verificationMaterial.content?.$case === 'x509CertificateChain'); + expect( + b.verificationMaterial.content?.x509CertificateChain.certificates + ).toHaveLength(1); + expect( + b.verificationMaterial.content?.x509CertificateChain.certificates[0] + .rawBytes + ).toEqual(pem.toDER(certificate)); + }); +}); + +describe('toDSSEBundle', () => { + describe('when a public key w/ hint is provided', () => { + const signature = { + key: { + $case: 'publicKey', + publicKey: certificate, + hint: 'hint', + }, + signature: sigBytes, + } satisfies Signature; + + it('returns a valid DSSE bundle', () => { + const b = toDSSEBundle(artifact, signature); + + expect(b).toBeTruthy(); + expect(b.mediaType).toEqual( + 'application/vnd.dev.sigstore.bundle+json;version=0.1' + ); + + assert(b.content?.$case === 'dsseEnvelope'); + expect(b.content.dsseEnvelope).toBeTruthy(); + expect(b.content.dsseEnvelope.payloadType).toEqual(artifact.type); + expect(b.content.dsseEnvelope.payload).toEqual(artifact.data); + expect(b.content.dsseEnvelope.signatures).toHaveLength(1); + expect(b.content.dsseEnvelope.signatures[0].sig).toEqual(sigBytes); + expect(b.content.dsseEnvelope.signatures[0].keyid).toEqual( + signature.key.hint + ); + + expect(b.verificationMaterial).toBeTruthy(); + assert(b.verificationMaterial.content?.$case === 'publicKey'); + expect(b.verificationMaterial.content?.publicKey).toBeTruthy(); + expect(b.verificationMaterial.content?.publicKey.hint).toEqual( + signature.key.hint + ); + }); + }); + + describe('when a public key w/o hint is provided', () => { + const signature = { + key: { + $case: 'publicKey', + publicKey: certificate, + }, + signature: sigBytes, + } satisfies Signature; + + it('returns a valid DSSE bundle', () => { + const b = toDSSEBundle(artifact, signature); + + expect(b).toBeTruthy(); + expect(b.mediaType).toEqual( + 'application/vnd.dev.sigstore.bundle+json;version=0.1' + ); + + assert(b.content?.$case === 'dsseEnvelope'); + expect(b.content.dsseEnvelope).toBeTruthy(); + expect(b.content.dsseEnvelope.payloadType).toEqual(artifact.type); + expect(b.content.dsseEnvelope.payload).toEqual(artifact.data); + expect(b.content.dsseEnvelope.signatures).toHaveLength(1); + expect(b.content.dsseEnvelope.signatures[0].sig).toEqual(sigBytes); + expect(b.content.dsseEnvelope.signatures[0].keyid).toEqual(''); + + expect(b.verificationMaterial).toBeTruthy(); + assert(b.verificationMaterial.content?.$case === 'publicKey'); + expect(b.verificationMaterial.content?.publicKey).toBeTruthy(); + expect(b.verificationMaterial.content?.publicKey.hint).toEqual(''); + }); + }); + + describe('when a certificate chain provided', () => { + const signature = { + key: { + $case: 'x509Certificate', + certificate: certificate, + }, + signature: sigBytes, + } satisfies Signature; + + it('returns a valid DSSE bundle', () => { + const b = toDSSEBundle(artifact, signature); + + expect(b).toBeTruthy(); + expect(b.mediaType).toEqual( + 'application/vnd.dev.sigstore.bundle+json;version=0.1' + ); + + assert(b.content?.$case === 'dsseEnvelope'); + expect(b.content.dsseEnvelope).toBeTruthy(); + expect(b.content.dsseEnvelope.payloadType).toEqual(artifact.type); + expect(b.content.dsseEnvelope.payload).toEqual(artifact.data); + expect(b.content.dsseEnvelope.signatures).toHaveLength(1); + expect(b.content.dsseEnvelope.signatures[0].sig).toEqual(sigBytes); + expect(b.content.dsseEnvelope.signatures[0].keyid).toEqual(''); + + expect(b.verificationMaterial).toBeTruthy(); + assert(b.verificationMaterial.content?.$case === 'x509CertificateChain'); + expect( + b.verificationMaterial.content?.x509CertificateChain.certificates + ).toHaveLength(1); + expect( + b.verificationMaterial.content?.x509CertificateChain.certificates[0] + .rawBytes + ).toEqual(pem.toDER(certificate)); + }); + }); +}); diff --git a/packages/sign/src/__tests__/bundler/dsse.test.ts b/packages/sign/src/__tests__/bundler/dsse.test.ts new file mode 100644 index 00000000..c65efa25 --- /dev/null +++ b/packages/sign/src/__tests__/bundler/dsse.test.ts @@ -0,0 +1,134 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import assert from 'assert'; +import { DSSEBundleBuilder } from '../../bundler/dsse'; +import { dsse } from '../../util'; + +import type { Artifact } from '../../bundler'; +import type { Signature, Signer } from '../../signer'; + +describe('DSSENotary', () => { + // Signature fixture to return from fake signer + const sigBytes = Buffer.from('signature'); + const key = 'publickey'; + + const signature = { + key: { + $case: 'publicKey', + publicKey: key, + hint: 'hint', + }, + signature: sigBytes, + } satisfies Signature; + + const signer = { + sign: jest.fn().mockResolvedValue(signature), + } satisfies Signer; + + describe('constructor', () => { + it('should create a new instance', () => { + const notary = new DSSEBundleBuilder({ signer: signer, witnesses: [] }); + expect(notary).toBeDefined(); + }); + }); + + describe('notarize', () => { + const subject = new DSSEBundleBuilder({ signer: signer, witnesses: [] }); + + describe('when the artifact type is NOT provided', () => { + const artifact: Artifact = { + data: Buffer.from('artifact'), + }; + + it('invokes the signer', async () => { + await subject.create(artifact); + + const expectedBlob = dsse.preAuthEncoding('', artifact.data); + expect(signer.sign).toHaveBeenCalledWith(expectedBlob); + }); + + it('returns a bundle', async () => { + const b = await subject.create(artifact); + + expect(b).toBeTruthy(); + expect(b.mediaType).toEqual( + 'application/vnd.dev.sigstore.bundle+json;version=0.1' + ); + + assert(b.content?.$case === 'dsseEnvelope'); + expect(b.content?.dsseEnvelope).toBeTruthy(); + expect(b.content?.dsseEnvelope.payload).toEqual(artifact.data); + expect(b.content.dsseEnvelope.payloadType).toEqual(''); + expect(b.content.dsseEnvelope.signatures).toHaveLength(1); + expect(b.content.dsseEnvelope.signatures[0].keyid).toEqual( + signature.key.hint + ); + expect(b.content.dsseEnvelope.signatures[0].sig).toEqual( + signature.signature + ); + + expect(b.verificationMaterial).toBeTruthy(); + assert(b.verificationMaterial.content?.$case === 'publicKey'); + expect(b.verificationMaterial.content?.publicKey).toBeTruthy(); + expect(b.verificationMaterial.content?.publicKey.hint).toEqual( + signature.key.hint + ); + }); + }); + + describe('when the artifact type is provided', () => { + const artifact = { + data: Buffer.from('artifact'), + type: 'text/plain', + } satisfies Artifact; + + it('invokes the signer', async () => { + await subject.create(artifact); + + const expectedBlob = dsse.preAuthEncoding(artifact.type, artifact.data); + expect(signer.sign).toHaveBeenCalledWith(expectedBlob); + }); + + it('returns a bundle', async () => { + const b = await subject.create(artifact); + + expect(b).toBeTruthy(); + expect(b.mediaType).toEqual( + 'application/vnd.dev.sigstore.bundle+json;version=0.1' + ); + + assert(b.content?.$case === 'dsseEnvelope'); + expect(b.content?.dsseEnvelope).toBeTruthy(); + expect(b.content?.dsseEnvelope.payload).toEqual(artifact.data); + expect(b.content.dsseEnvelope.payloadType).toEqual(artifact.type); + expect(b.content.dsseEnvelope.signatures).toHaveLength(1); + expect(b.content.dsseEnvelope.signatures[0].keyid).toEqual( + signature.key.hint + ); + expect(b.content.dsseEnvelope.signatures[0].sig).toEqual( + signature.signature + ); + + expect(b.verificationMaterial).toBeTruthy(); + assert(b.verificationMaterial.content?.$case === 'publicKey'); + expect(b.verificationMaterial.content?.publicKey).toBeTruthy(); + expect(b.verificationMaterial.content?.publicKey.hint).toEqual( + signature.key.hint + ); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/bundler/message.test.ts b/packages/sign/src/__tests__/bundler/message.test.ts new file mode 100644 index 00000000..5912577a --- /dev/null +++ b/packages/sign/src/__tests__/bundler/message.test.ts @@ -0,0 +1,90 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { HashAlgorithm } from '@sigstore/protobuf-specs'; +import assert from 'assert'; +import { MessageBundleBuilder } from '../../bundler/message'; +import { crypto } from '../../util'; + +import type { Artifact } from '../../bundler'; +import type { Signature, Signer } from '../../signer'; + +describe('MessageNotary', () => { + // Signature fixture to return from fake Signer + const sigBytes = Buffer.from('signature'); + const key = 'publickey'; + + const signature = { + key: { + $case: 'publicKey', + publicKey: key, + hint: 'hint', + }, + signature: sigBytes, + } satisfies Signature; + + const signer = { + sign: jest.fn().mockResolvedValue(signature), + } satisfies Signer; + + describe('constructor', () => { + it('should create a new instance', () => { + const notary = new MessageBundleBuilder({ + signer: signer, + witnesses: [], + }); + expect(notary).toBeDefined(); + }); + }); + + describe('notarize', () => { + const artifact: Artifact = { + data: Buffer.from('artifact'), + }; + + const subject = new MessageBundleBuilder({ signer: signer, witnesses: [] }); + + it('invokes the signer', async () => { + await subject.create(artifact); + expect(signer.sign).toHaveBeenCalled(); + }); + + it('returns a bundle', async () => { + const b = await subject.create(artifact); + + expect(b).toBeTruthy(); + expect(b.mediaType).toEqual( + 'application/vnd.dev.sigstore.bundle+json;version=0.1' + ); + + assert(b.content?.$case === 'messageSignature'); + expect(b.content.messageSignature).toBeTruthy(); + expect(b.content.messageSignature.messageDigest.algorithm).toEqual( + HashAlgorithm.SHA2_256 + ); + expect(b.content.messageSignature.messageDigest.digest).toEqual( + crypto.hash(artifact.data) + ); + expect(b.content.messageSignature.signature).toEqual(sigBytes); + + expect(b.verificationMaterial).toBeTruthy(); + assert(b.verificationMaterial.content?.$case === 'publicKey'); + expect(b.verificationMaterial.content?.publicKey).toBeTruthy(); + expect(b.verificationMaterial.content?.publicKey.hint).toEqual( + signature.key.hint + ); + }); + }); +}); diff --git a/packages/sign/src/__tests__/external/error.test.ts b/packages/sign/src/__tests__/external/error.test.ts new file mode 100644 index 00000000..836f45dd --- /dev/null +++ b/packages/sign/src/__tests__/external/error.test.ts @@ -0,0 +1,54 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { fromPartial } from '@total-typescript/shoehorn'; +import assert from 'assert'; +import fetch from 'make-fetch-happen'; +import { checkStatus, HTTPError } from '../../external/error'; + +type Response = Awaited>; + +describe('checkStatus', () => { + describe('when the response is OK', () => { + const response: Response = fromPartial({ + status: 200, + statusText: 'OK', + ok: true, + }); + + it('returns the response', () => { + expect(checkStatus(response)).toEqual(response); + }); + }); + + describe('when the response is not OK', () => { + const response: Response = fromPartial({ + status: 404, + statusText: 'Not Found', + ok: false, + }); + + it('throws an error', () => { + expect.assertions(2); + try { + checkStatus(response); + } catch (e) { + assert(e instanceof HTTPError); + expect(e.message).toEqual('HTTP Error: 404 Not Found'); + expect(e.statusCode).toEqual(404); + } + }); + }); +}); diff --git a/packages/sign/src/__tests__/external/fulcio.test.ts b/packages/sign/src/__tests__/external/fulcio.test.ts new file mode 100644 index 00000000..549a5e68 --- /dev/null +++ b/packages/sign/src/__tests__/external/fulcio.test.ts @@ -0,0 +1,90 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import nock from 'nock'; + +import { + Fulcio, + SigningCertificateRequest, + SigningCertificateResponse, +} from '../../external/fulcio'; + +describe('Fulcio', () => { + const baseURL = 'http://localhost:8000'; + const subject = new Fulcio({ baseURL }); + + it('should create an instance', () => { + expect(subject).toBeTruthy(); + }); + + describe('#createSigningCertificate', () => { + const identityToken = `a.b.c`; + const certRequest = { + credentials: { + oidcIdentityToken: identityToken, + }, + publicKeyRequest: { + publicKey: { + algorithm: 'ECDSA', + content: + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tCg==', + }, + proofOfPossession: 'MEUCIEntw6QwoyDHb52HUIUVDnqFeGBI4oaCBMCoOtcbVKQ=', + }, + } satisfies SigningCertificateRequest; + + describe('when the certificate request is valid', () => { + const certificateResponse: SigningCertificateResponse = { + signedCertificateEmbeddedSct: { + chain: { + certificates: [ + `-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----`, + ], + }, + }, + }; + + beforeEach(() => { + nock(baseURL) + .matchHeader('Content-Type', 'application/json') + .matchHeader('User-Agent', new RegExp('sigstore-js\\/\\d+.\\d+.\\d+')) + .post('/api/v2/signingCert', certRequest) + .reply(200, certificateResponse); + }); + + it('returns the signing certificate', async () => { + const result = await subject.createSigningCertificate(certRequest); + expect(result).toEqual(certificateResponse); + }); + }); + + describe('when the certificate request is invalid', () => { + const responseBody = { + code: 400, + message: 'Invalid certificate request', + }; + + beforeEach(() => { + nock(baseURL).post('/api/v2/signingCert').reply(400, responseBody); + }); + + it('returns an error', async () => { + await expect( + subject.createSigningCertificate(certRequest) + ).rejects.toThrow('HTTP Error: 400 Bad Request'); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/external/rekor.test.ts b/packages/sign/src/__tests__/external/rekor.test.ts new file mode 100644 index 00000000..c5b24667 --- /dev/null +++ b/packages/sign/src/__tests__/external/rekor.test.ts @@ -0,0 +1,433 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import nock from 'nock'; +import { Rekor } from '../../external/rekor'; + +import type { ProposedHashedRekordEntry } from '../../external/rekor'; + +describe('Rekor', () => { + const baseURL = 'http://localhost:8080'; + const subject = new Rekor({ baseURL }); + + it('should create an instance', () => { + expect(subject).toBeTruthy(); + }); + + describe('#createEntry', () => { + const proposedEntry: ProposedHashedRekordEntry = { + apiVersion: '0.0.1', + kind: 'hashedrekord', + spec: { + data: { + hash: { + algorithm: 'sha256', + value: + '1c025a6e48ceb8bf10e01b367089732326eabe3541d03d348724c79040382c65', + }, + }, + signature: { + content: + 'MEUCIDB2SWDabztSC8RrlfRCWUf04LBN0E2CEwiDZJLacDS8AiEA3bQHMBpodxA3dvJ+JK1SALkuzju/w4oCg3S89c8CtN8=', + publicKey: { + content: + 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', + }, + }, + }, + }; + + describe('when the entry is successfully added', () => { + const uuid = + '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6'; + const responseBody = { + [uuid]: { + body: 'Zm9vCg==', + integratedTime: 1654015743, + logID: + 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', + logIndex: 2513258, + verification: { + signedEntryTimestamp: + 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=', + }, + }, + }; + + beforeEach(() => { + nock(baseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .matchHeader('User-Agent', new RegExp('sigstore-js\\/\\d+.\\d+.\\d+')) + .post('/api/v1/log/entries') + .reply(201, responseBody); + }); + + it('returns the new entry', async () => { + const result = await subject.createEntry(proposedEntry); + + expect(result.uuid).toBe(uuid); + expect(result.body).toBe(responseBody[uuid].body); + expect(result.integratedTime).toBe(responseBody[uuid].integratedTime); + expect(result.logID).toBe(responseBody[uuid].logID); + expect(result.logIndex).toBe(responseBody[uuid].logIndex); + expect(result.verification).toEqual(responseBody[uuid].verification); + }); + }); + + describe('when a matching entry already exists', () => { + const responseBody = { + code: 409, + message: 'An equivalent entry already exists', + }; + + beforeEach(() => { + nock(baseURL).post('/api/v1/log/entries').reply(409, responseBody); + }); + + it('returns an error', async () => { + await expect(subject.createEntry(proposedEntry)).rejects.toThrow( + 'HTTP Error: 409 Conflict' + ); + }); + }); + }); + + describe('#getEntry', () => { + describe('when the entry exists', () => { + const uuid = + '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6'; + const responseBody = { + [uuid]: { + body: 'Zm9vCg==', + integratedTime: 1654015743, + logID: + 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', + logIndex: 2513258, + verification: { + signedEntryTimestamp: + 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=', + }, + }, + }; + + beforeEach(() => { + nock(baseURL) + .get(`/api/v1/log/entries/${uuid}`) + .reply(200, responseBody); + }); + + it('returns the requested entry', async () => { + const result = await subject.getEntry(uuid); + + expect(result.uuid).toBe(uuid); + expect(result.body).toBe(responseBody[uuid].body); + expect(result.integratedTime).toBe(responseBody[uuid].integratedTime); + expect(result.logID).toBe(responseBody[uuid].logID); + expect(result.logIndex).toBe(responseBody[uuid].logIndex); + expect(result.verification).toEqual(responseBody[uuid].verification); + }); + }); + + describe('when the entry does not exist', () => { + const responseBody = { + code: 404, + message: 'Entry not found', + }; + + beforeEach(() => { + nock(baseURL).get('/api/v1/log/entries/foo').reply(404, responseBody); + }); + + it('returns an error', async () => { + await expect(subject.getEntry('foo')).rejects.toThrow( + 'HTTP Error: 404 Not Found' + ); + }); + }); + + describe('when there are multiple entries in the response', () => { + const uuid = + '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6'; + const responseBody = { + [uuid]: { body: 'foo' }, + '456': { body: 'bar' }, + }; + + beforeEach(() => { + nock(baseURL) + .get(`/api/v1/log/entries/${uuid}`) + .reply(200, responseBody); + }); + + it('returns an error', async () => { + await expect(subject.getEntry(uuid)).rejects.toThrow( + 'Received multiple entries in Rekor response' + ); + }); + }); + }); + + describe('#searchIndex', () => { + describe('when matching entries exist', () => { + const sha = + 'sha256:04c0c13721a28c60f38daf09a05326c301a2cf57ad2beb953eb29d61383db47e'; + const responseBody = ['deadbeef', 'abcd1234']; + + beforeEach(() => { + nock(baseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/index/retrieve', { hash: sha }) + .reply(200, responseBody); + }); + + it('returns matching entries', async () => { + const response = await subject.searchIndex({ hash: sha }); + expect(response).toEqual(responseBody); + }); + }); + + describe('when no matching entries exist', () => { + const responseBody = [] as string[]; + + beforeEach(() => { + nock(baseURL).post('/api/v1/index/retrieve').reply(200, responseBody); + }); + + it('returns an empty array', async () => { + const sha = + 'sha256:04c0c13721a28c60f38daf09a05326c301a2cf57ad2beb953eb29d61383db47e'; + + const response = await subject.searchIndex({ hash: sha }); + + expect(response).toEqual([]); + }); + }); + + describe('when an error occurs', () => { + const responseBody = { + code: 422, + message: 'Invalid query', + }; + + beforeEach(() => { + nock(baseURL).get('/api/v1/log/entries/foo').reply(422, responseBody); + }); + + it('returns an error', async () => { + await expect(subject.getEntry('foo')).rejects.toThrow( + 'HTTP Error: 422 Unprocessable Entity' + ); + }); + }); + }); + + describe('#searchLog', () => { + const searchLogURL = '/api/v1/log/entries/retrieve'; + + const searchLogEntryUUID = + '5c1419ace1915869eef4426701d0763232da44d453e31ffc06d60ea61de36e25'; + const searchLogIndex = 3244486; + + const searchLogEntry = { + kind: 'hashedrekord', + apiVersion: '0.0.1', + spec: { + data: { + hash: { + value: + 'c0afcf83aee6ee83b2ecfa226db62853fd77bcaf550e1b4bf277acbe38a67bca', + algorithm: 'sha256', + }, + }, + signature: { + content: + 'MEYCIQDXxhsvLwEXG9HFrYyF/FgGbo0Ja25ADoaUhs+4DqaCQgIhAJziarK6mxXB5coQu75jpHSbXAGQyDE8tu8sTrcXndGV', + publicKey: { + content: + 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNvRENDQWlhZ0F3SUJBZ0lVZTR4VXljdWRlbW9sVG5tenpCVnM0MVNNQU9Jd0NnWUlLb1pJemowRUF3TXcKTnpFVk1CTUdBMVVFQ2hNTWMybG5jM1J2Y21VdVpHVjJNUjR3SEFZRFZRUURFeFZ6YVdkemRHOXlaUzFwYm5SbApjbTFsWkdsaGRHVXdIaGNOTWpJd09ESXlNVGd3T1RBeVdoY05Nakl3T0RJeU1UZ3hPVEF5V2pBQU1Ga3dFd1lICktvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUVDV3dEM1F6WUwwN2JDY0JrZURWU3hUOE9FQnM1MnJXcS9aZGYKMEZFSlZneng3cGpyZFFmOExBOEp6dkZkVnhKeXBGSDVVcythMkRRRWVUdytlZUNRcEtPQ0FVVXdnZ0ZCTUE0RwpBMVVkRHdFQi93UUVBd0lIZ0RBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREF6QWRCZ05WSFE0RUZnUVVodUt1CklsWFVHa3VrM2prbWJ3SlhpeDh1cFdrd0h3WURWUjBqQkJnd0ZvQVUzOVBwejFZa0VaYjVxTmpwS0ZXaXhpNFkKWkQ4d0h3WURWUjBSQVFIL0JCVXdFNEVSWW5KcFlXNUFaR1ZvWVcxbGNpNWpiMjB3TEFZS0t3WUJCQUdEdnpBQgpBUVFlYUhSMGNITTZMeTluYVhSb2RXSXVZMjl0TDJ4dloybHVMMjloZFhSb01JR0tCZ29yQmdFRUFkWjVBZ1FDCkJId0VlZ0I0QUhZQUNHQ1M4Q2hTLzJoRjBkRnJKNFNjUldjWXJCWTl3empTYmVhOElnWTJiM0lBQUFHQ3hyNWkKZ3dBQUJBTUFSekJGQWlCOEFPQ2t2VnA1bGRhVkVuMk96NlAzUE5YOGd2cGt2WmlqUEZ2SHY1ZHMvZ0loQUtWNAplK1lpKzVpa2VCYTU3UlV1alMrV3RIdklSeEJBVVRNK2hTOE5XWHFjTUFvR0NDcUdTTTQ5QkFNREEyZ0FNR1VDCk1RQ3JIK3krYkhDeCtBR0lsc3JOeXhOa2VsS3hSNDAwbEpzd1FBVDVOVk5aUmVSUDBhbDJKN1dmSHhyZVhqS0oKT2ZBQ01FTnpzUjhIeVZpOEJuZDBPd1YzOGswMGRnZW9PTGoycTgwZVdEbWJWN1ptVklyeDRpc2M4aFVnNHFHLwpKN2x4TEE9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==', + }, + }, + }, + }; + + const searchLogResponseBody = [ + { + [searchLogEntryUUID]: { + body: 'eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJjMGFmY2Y4M2FlZTZlZTgzYjJlY2ZhMjI2ZGI2Mjg1M2ZkNzdiY2FmNTUwZTFiNGJmMjc3YWNiZTM4YTY3YmNhIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FWUNJUURYeGhzdkx3RVhHOUhGcll5Ri9GZ0dibzBKYTI1QURvYVVocys0RHFhQ1FnSWhBSnppYXJLNm14WEI1Y29RdTc1anBIU2JYQUdReURFOHR1OHNUcmNYbmRHViIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTnZSRU5EUVdsaFowRjNTVUpCWjBsVlpUUjRWWGxqZFdSbGJXOXNWRzV0ZW5wQ1ZuTTBNVk5OUVU5SmQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcEpkMDlFU1hsTlZHZDNUMVJCZVZkb1kwNU5ha2wzVDBSSmVVMVVaM2hQVkVGNVYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZEVjNkRU0xRjZXVXd3TjJKRFkwSnJaVVJXVTNoVU9FOUZRbk0xTW5KWGNTOWFaR1lLTUVaRlNsWm5lbmczY0dweVpGRm1PRXhCT0VwNmRrWmtWbmhLZVhCR1NEVlZjeXRoTWtSUlJXVlVkeXRsWlVOUmNFdFBRMEZWVlhkblowWkNUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZvZFV0MUNrbHNXRlZIYTNWck0ycHJiV0ozU2xocGVEaDFjRmRyZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDBoM1dVUldVakJTUVZGSUwwSkNWWGRGTkVWU1dXNUtjRmxYTlVGYVIxWnZXVmN4YkdOcE5XcGlNakIzVEVGWlMwdDNXVUpDUVVkRWRucEJRZ3BCVVZGbFlVaFNNR05JVFRaTWVUbHVZVmhTYjJSWFNYVlpNamwwVERKNGRsb3liSFZNTWpsb1pGaFNiMDFKUjB0Q1oyOXlRbWRGUlVGa1dqVkJaMUZEQ2tKSWQwVmxaMEkwUVVoWlFVTkhRMU00UTJoVEx6Sm9SakJrUm5KS05GTmpVbGRqV1hKQ1dUbDNlbXBUWW1WaE9FbG5XVEppTTBsQlFVRkhRM2h5TldrS1ozZEJRVUpCVFVGU2VrSkdRV2xDT0VGUFEydDJWbkExYkdSaFZrVnVNazk2TmxBelVFNVlPR2QyY0d0MldtbHFVRVoyU0hZMVpITXZaMGxvUVV0V05BcGxLMWxwS3pWcGEyVkNZVFUzVWxWMWFsTXJWM1JJZGtsU2VFSkJWVlJOSzJoVE9FNVhXSEZqVFVGdlIwTkRjVWRUVFRRNVFrRk5SRUV5WjBGTlIxVkRDazFSUTNKSUsza3JZa2hEZUN0QlIwbHNjM0pPZVhoT2EyVnNTM2hTTkRBd2JFcHpkMUZCVkRWT1ZrNWFVbVZTVURCaGJESktOMWRtU0hoeVpWaHFTMG9LVDJaQlEwMUZUbnB6VWpoSWVWWnBPRUp1WkRCUGQxWXpPR3N3TUdSblpXOVBUR295Y1Rnd1pWZEViV0pXTjFwdFZrbHllRFJwYzJNNGFGVm5OSEZITHdwS04yeDRURUU5UFFvdExTMHRMVVZPUkNCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2c9PSJ9fX19', + integratedTime: 1661191742, + logID: + 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', + logIndex: searchLogIndex, + verification: { + inclusionProof: { + hashes: [ + 'a95557f9140686a8232d87372d63f77d238b9b592546793122fee610a98bdc4a', + '755c2d4215b65ff1e89922eaf3d0b0c2a256bb73f01c9987d0baf7da6bd05ce9', + 'a40ef3cc1d74b6c51d7f10bd27c3fdaf32a0ef5ff3b7d1a920e2cab3fd7b15fa', + 'f13869de51fd02503c70fc7b60a0a45f63e5ac7f5e09e1f8481fb3c27dde3787', + '8f940f0ea5c5a2f851c0bb5a736a1be56a2fb823c5e805521ba7e354d0046e4a', + '7158f9f9011dae7f3ac2bd2ca20b3dc8994498ba16cf58d5be94586db4b7049b', + '7905df410a38ed98d7d3f84a2ec4aea550e1b958ccd767c0daefcee7a2b9037a', + '497906c322703a983d84cb7618b74ba0804b245c63ef0a276cf1db8b9cee487a', + 'a547f0e05745e0594c9569c04e5d7eff0aed47f88eaf50e373b051ed465bd61d', + '0d7e8078654eb19540634cb8e1c5a864424e4b03e65f7fb139746a279fd0b3cc', + 'a64451821ac29b58a6ced95c615e426fc339d3607b0001c4298c474b7fdaed59', + 'b71e915675080279e58cdd0daa859a47da7e824ffc55df612becfcfb2fd9aae8', + '6d494b237648126525b08f975c736a55d1f7a64472fcc2782bbc16733c608d7b', + 'efb36cfc54705d8cd921a621a9389ffa03956b15d68bfabadac2b4853852079b', + ], + logIndex: searchLogIndex, + rootHash: + 'ba3be49521f0cf6eed57b0e1ffe4b47a2f5b96bf43e9b6eb5ec3130f80e53c8d', + treeSize: 3244712, + }, + signedEntryTimestamp: + 'MEUCIQDLNuDkFSpqhFBpp3o1ApYu4WH0f4tP2OWZC/o+f79cQAIgFC6uELUYcUpPNACyu89pNynxmmrWMdzu08CNNYl83/c=', + }, + }, + }, + ]; + + describe('when matching by entry', () => { + const entries = [searchLogEntry]; + const responseBody = searchLogResponseBody; + + beforeEach(() => { + nock(baseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post(searchLogURL, { entries }) + .reply(200, responseBody); + }); + + it('returns matching entries', async () => { + const response = await subject.searchLog({ + entries: entries as ProposedHashedRekordEntry[], + }); + + expect(response).toHaveLength(1); + expect(response[0].uuid).toEqual( + Object.keys(searchLogResponseBody[0])[0] + ); + expect(response[0].logIndex).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].logIndex + ); + expect(response[0].body).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].body + ); + expect(response[0].integratedTime).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].integratedTime + ); + expect(response[0].logID).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].logID + ); + expect(response[0].verification).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].verification + ); + }); + }); + + describe('when matching by log index', () => { + const logIndexes = [searchLogIndex]; + const responseBody = searchLogResponseBody; + + beforeEach(() => { + nock(baseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post(searchLogURL, { logIndexes }) + .reply(200, responseBody); + }); + + it('returns matching entries', async () => { + const response = await subject.searchLog({ logIndexes }); + expect(response).toHaveLength(1); + expect(response[0].uuid).toEqual( + Object.keys(searchLogResponseBody[0])[0] + ); + expect(response[0].logIndex).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].logIndex + ); + expect(response[0].body).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].body + ); + expect(response[0].integratedTime).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].integratedTime + ); + expect(response[0].logID).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].logID + ); + expect(response[0].verification).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].verification + ); + }); + }); + + describe('when matching by UUID', () => { + const entryUUIDs = [searchLogEntryUUID]; + + const responseBody = searchLogResponseBody; + + beforeEach(() => { + nock(baseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post(searchLogURL, { entryUUIDs }) + .reply(200, responseBody); + }); + + it('returns matching entries', async () => { + const response = await subject.searchLog({ entryUUIDs }); + expect(response).toHaveLength(1); + expect(response[0].uuid).toEqual( + Object.keys(searchLogResponseBody[0])[0] + ); + expect(response[0].logIndex).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].logIndex + ); + expect(response[0].body).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].body + ); + expect(response[0].integratedTime).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].integratedTime + ); + expect(response[0].logID).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].logID + ); + expect(response[0].verification).toEqual( + searchLogResponseBody[0][searchLogEntryUUID].verification + ); + }); + }); + + describe('when no matching entries exist', () => { + const responseBody = [] as string[]; + + beforeEach(() => { + nock(baseURL).post(searchLogURL).reply(200, responseBody); + }); + + it('returns an empty array', async () => { + const entryUUIDs = [searchLogEntryUUID]; + + const response = await subject.searchLog({ entryUUIDs }); + + expect(response).toEqual([]); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/external/tsa.test.ts b/packages/sign/src/__tests__/external/tsa.test.ts new file mode 100644 index 00000000..2b01e062 --- /dev/null +++ b/packages/sign/src/__tests__/external/tsa.test.ts @@ -0,0 +1,67 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import nock from 'nock'; +import { TimestampAuthority, TimestampRequest } from '../../external/tsa'; + +describe('TimestampAuthority', () => { + const baseURL = 'http://localhost:8000'; + const subject = new TimestampAuthority({ baseURL }); + + it('should create an instance', () => { + expect(subject).toBeTruthy(); + }); + + describe('#createTimestamp', () => { + const timestampRequest = { + artifactHash: 'artifacthash', + hashAlgorithm: 'sha256', + } satisfies TimestampRequest; + + describe('when the timestamp request is valid', () => { + const timestamp = Buffer.from('timestamp'); + + beforeEach(() => { + nock(baseURL) + .matchHeader('Content-Type', 'application/json') + .matchHeader('User-Agent', new RegExp('sigstore-js\\/\\d+.\\d+.\\d+')) + .post('/api/v1/timestamp', timestampRequest) + .reply(200, timestamp); + }); + + it('returns the timestamp', async () => { + const result = await subject.createTimestamp(timestampRequest); + expect(result).toEqual(timestamp); + }); + }); + + describe('when the timestamp request is invalid', () => { + const responseBody = { + code: 400, + message: 'Error generating timestamp response', + }; + + beforeEach(() => { + nock(baseURL).post('/api/v1/timestamp').reply(400, responseBody); + }); + + it('returns an error', async () => { + await expect(subject.createTimestamp(timestampRequest)).rejects.toThrow( + 'HTTP Error: 400 Bad Request' + ); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/identity/ci.test.ts b/packages/sign/src/__tests__/identity/ci.test.ts new file mode 100644 index 00000000..bf3aff27 --- /dev/null +++ b/packages/sign/src/__tests__/identity/ci.test.ts @@ -0,0 +1,87 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import nock from 'nock'; +import { CIContextProvider } from '../../identity/ci'; + +describe('CIContextProvider', () => { + const subject = new CIContextProvider('sigstore'); + + it('creates an instance', () => { + expect(subject).toBeTruthy(); + }); + + describe('#getToken', () => { + describe('when the GHA environment variables are set', () => { + const requestURL = 'http://localhost:8080'; + const requestToken = 'abc123'; + + const oidcToken = 'x.y.z'; + + beforeEach(() => { + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = requestURL; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = requestToken; + + nock(requestURL) + .get('/') + .query({ audience: 'sigstore' }) + .reply(200, { value: oidcToken }); + }); + + afterEach(() => { + delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + }); + + it('returns the token', async () => { + const token = await subject.getToken(); + expect(token).toBe(oidcToken); + }); + }); + + describe('when the GHA environment variables are NOT set', () => { + it('returns undefined', async () => { + const token = subject.getToken(); + await expect(token).rejects.toBe('CI: no tokens available'); + }); + }); + }); + + describe('#getEnv', () => { + describe('when the sigstore environment variables are set', () => { + const token = 'hunter2'; + + beforeEach(() => { + process.env.SIGSTORE_ID_TOKEN = token; + }); + + afterEach(() => { + delete process.env.SIGSTORE_ID_TOKEN; + }); + + it('returns the token', async () => { + const token = await subject.getToken(); + expect(token).toBe(token); + }); + }); + + describe('when the sigstore environment variables are NOT set', () => { + it('returns undefined', async () => { + const token = subject.getToken(); + await expect(token).rejects.toBe('CI: no tokens available'); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/index.test.ts b/packages/sign/src/__tests__/index.test.ts new file mode 100644 index 00000000..752c936c --- /dev/null +++ b/packages/sign/src/__tests__/index.test.ts @@ -0,0 +1,96 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { fromPartial } from '@total-typescript/shoehorn'; +import { + CIContextProvider, + DSSEBundleBuilder, + FulcioSigner, + InternalError, + MessageBundleBuilder, + RekorWitness, + TSAWitness, +} from '..'; + +import type { + Artifact, + Bundle, + BundleBuilder, + BundleBuilderOptions, + FulcioSignerOptions, + IdentityProvider, + RekorWitnessOptions, + Signature, + SignatureBundle, + Signer, + TSAWitnessOptions, + VerificationMaterial, + Witness, +} from '..'; + +// This test is a bit of a hack to ensure that the types are exported +it('exports types', async () => { + const verificationMaterial: VerificationMaterial = fromPartial({}); + expect(verificationMaterial).toBeDefined(); + + const artifact: Artifact = fromPartial({}); + expect(artifact).toBeDefined(); + + const bundle: Bundle = fromPartial({}); + expect(bundle).toBeDefined(); + + const signature: Signature = fromPartial({}); + expect(signature).toBeDefined(); + + const signatureBundle: SignatureBundle = fromPartial({}); + expect(signatureBundle).toBeDefined(); + + const bundler: BundleBuilder = fromPartial({}); + expect(bundler).toBeDefined(); + + const bundlerOptions: BundleBuilderOptions = fromPartial({}); + expect(bundlerOptions).toBeDefined(); + + const witness: Witness = fromPartial({}); + expect(witness).toBeDefined(); + + const rekorWitnessOptions: RekorWitnessOptions = fromPartial({}); + expect(rekorWitnessOptions).toBeDefined(); + + const tsaWitnessOptions: TSAWitnessOptions = fromPartial({}); + expect(tsaWitnessOptions).toBeDefined(); + + const signer: Signer = fromPartial({}); + expect(signer).toBeDefined(); + + const fulcioSignerOptions: FulcioSignerOptions = fromPartial({}); + expect(fulcioSignerOptions).toBeDefined(); + + const identityProvider: IdentityProvider = fromPartial({}); + expect(identityProvider).toBeDefined(); +}); + +it('exports classes', () => { + expect(CIContextProvider).toBeInstanceOf(Function); + expect(DSSEBundleBuilder).toBeInstanceOf(Function); + expect(MessageBundleBuilder).toBeInstanceOf(Function); + expect(FulcioSigner).toBeInstanceOf(Function); + expect(RekorWitness).toBeInstanceOf(Function); + expect(TSAWitness).toBeInstanceOf(Function); +}); + +it('exports errors', () => { + expect(InternalError).toBeInstanceOf(Function); +}); diff --git a/packages/sign/src/__tests__/integration.test.ts b/packages/sign/src/__tests__/integration.test.ts new file mode 100644 index 00000000..00b791ca --- /dev/null +++ b/packages/sign/src/__tests__/integration.test.ts @@ -0,0 +1,103 @@ +import { mockFulcio, mockRekor, mockTSA } from '@sigstore/mock'; +import assert from 'assert'; +import { + DSSEBundleBuilder, + FulcioSigner, + MessageBundleBuilder, + RekorWitness, + TSAWitness, +} from '..'; + +describe('artifact signing', () => { + const fulcioURL = 'https://fulcio.example.com'; + const rekorURL = 'https://rekor.example.com'; + const tsaURL = 'https://tsa.example.com'; + + const subject = 'foo@bar.com'; + const oidcPayload = { sub: subject, iss: '' }; + const oidc = `.${Buffer.from(JSON.stringify(oidcPayload)).toString( + 'base64' + )}.}`; + + const idp = { getToken: () => Promise.resolve(oidc) }; + + const signer = new FulcioSigner({ + fulcioBaseURL: fulcioURL, + identityProvider: idp, + }); + const rekorWitness = new RekorWitness({ rekorBaseURL: rekorURL }); + const tsaWitness = new TSAWitness({ tsaBaseURL: tsaURL }); + + beforeEach(async () => { + await mockFulcio({ baseURL: fulcioURL }); + await mockRekor({ baseURL: rekorURL }); + await mockTSA({ baseURL: tsaURL }); + }); + + describe('when building a message signature bundle', () => { + const data = Buffer.from('hello, world'); + const bundler = new MessageBundleBuilder({ + signer, + witnesses: [rekorWitness, tsaWitness], + }); + + it('returns the signed bundle', async () => { + const bundle = await bundler.create({ data }); + + expect(bundle).toBeDefined(); + assert(bundle.content.$case === 'messageSignature'); + expect(bundle.content.messageSignature.signature).toBeDefined(); + expect(bundle.content.messageSignature.messageDigest).toBeDefined(); + + assert( + bundle.verificationMaterial.content.$case === 'x509CertificateChain' + ); + expect( + bundle.verificationMaterial.content.x509CertificateChain.certificates + ).toHaveLength(1); + + expect(bundle.verificationMaterial.tlogEntries).toHaveLength(1); + expect(bundle.verificationMaterial.tlogEntries[0].kindVersion.kind).toBe( + 'hashedrekord' + ); + + expect( + bundle.verificationMaterial.timestampVerificationData?.rfc3161Timestamps + ).toHaveLength(1); + }); + }); + + describe('when building a DSSE envelope bundle', () => { + const data = Buffer.from('hello, world'); + const bundler = new DSSEBundleBuilder({ + signer, + witnesses: [rekorWitness, tsaWitness], + }); + + it('returns the signed bundle', async () => { + const bundle = await bundler.create({ data, type: 'text/plain' }); + + expect(bundle).toBeDefined(); + assert(bundle.content.$case === 'dsseEnvelope'); + expect(bundle.content.dsseEnvelope.payloadType).toBe('text/plain'); + expect(bundle.content.dsseEnvelope.payload).toBe(data); + expect(bundle.content.dsseEnvelope.signatures).toHaveLength(1); + + assert( + bundle.verificationMaterial.content.$case === 'x509CertificateChain' + ); + expect( + bundle.verificationMaterial.content.x509CertificateChain.certificates + ).toHaveLength(1); + + expect(bundle.verificationMaterial.tlogEntries).toHaveLength(1); + expect(bundle.verificationMaterial.tlogEntries[0].kindVersion.kind).toBe( + 'intoto' + ); + + expect( + bundle.verificationMaterial.timestampVerificationData?.rfc3161Timestamps + ).toHaveLength(1); + }); + }); +}); diff --git a/packages/sign/src/__tests__/signer/fulcio/ca.test.ts b/packages/sign/src/__tests__/signer/fulcio/ca.test.ts new file mode 100644 index 00000000..ab1c69a0 --- /dev/null +++ b/packages/sign/src/__tests__/signer/fulcio/ca.test.ts @@ -0,0 +1,129 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import nock from 'nock'; +import { InternalError } from '../../../error'; +import { CAClient } from '../../../signer/fulcio/ca'; + +describe('CAClient', () => { + const fulcioBaseURL = 'http://localhost:8080'; + + describe('constructor', () => { + it('should create a new instance', () => { + const client = new CAClient({ fulcioBaseURL }); + expect(client).toBeDefined(); + }); + }); + + describe('createSigningCertificate', () => { + const subject = new CAClient({ fulcioBaseURL }); + + const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----\n`; + const rootCertificate = `-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----\n`; + const certChain = [leafCertificate, rootCertificate]; + + // Request data + const identityToken = 'a.b.c'; + + const publicKey = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtZO/hiYFB3WveI+iYoN4I6w17rSA +tbn02XdfIl+ZhQqUZv88dgDB86bfKyoOokA7fagAEOulkquhKKoOxdOySQ== +-----END PUBLIC KEY-----`; + const challenge = Buffer.from('challenge'); + + const certRequest = { + credentials: { + oidcIdentityToken: identityToken, + }, + publicKeyRequest: { + publicKey: { + algorithm: 'ECDSA', + content: publicKey, + }, + proofOfPossession: challenge.toString('base64'), + }, + }; + + describe('when Fulcio returns a valid response (with embedded SCT)', () => { + beforeEach(() => { + nock(fulcioBaseURL) + .matchHeader('Content-Type', 'application/json') + .matchHeader('User-Agent', new RegExp('sigstore-js\\/\\d+.\\d+.\\d+')) + .post('/api/v2/signingCert', certRequest) + .reply(201, { + signedCertificateEmbeddedSct: { + chain: { certificates: certChain }, + }, + }); + }); + + it('returns the certificate chain', async () => { + const result = await subject.createSigningCertificate( + identityToken, + publicKey, + challenge + ); + + expect(result).toEqual([leafCertificate, rootCertificate]); + }); + }); + + describe('when Fulcio returns a valid response (with detached SCT)', () => { + beforeEach(() => { + nock(fulcioBaseURL) + .matchHeader('Content-Type', 'application/json') + .matchHeader('User-Agent', new RegExp('sigstore-js\\/\\d+.\\d+.\\d+')) + .post('/api/v2/signingCert', certRequest) + .reply(201, { + signedCertificateDetachedSct: { + chain: { certificates: certChain }, + signedCertificateTimestamp: 'sct', + }, + }); + }); + + it('returns the certificate chain', async () => { + const result = await subject.createSigningCertificate( + identityToken, + publicKey, + challenge + ); + + expect(result).toEqual([leafCertificate, rootCertificate]); + }); + }); + + describe('when Fulcio returns an error response', () => { + beforeEach(() => { + nock(fulcioBaseURL) + .matchHeader('Accept', 'application/pem-certificate-chain') + .matchHeader('Content-Type', 'application/json') + .matchHeader('Authorization', `Bearer ${identityToken}`) + .matchHeader('User-Agent', new RegExp('sigstore-js\\/\\d+.\\d+.\\d+')) + .post('/api/v1/signingCert', certRequest) + .reply(500, {}); + }); + + it('throws an error', async () => { + await expect( + subject.createSigningCertificate(identityToken, publicKey, challenge) + ).rejects.toThrowWithCode( + InternalError, + 'CA_CREATE_SIGNING_CERTIFICATE_ERROR' + ); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/signer/fulcio/ephemeral.test.ts b/packages/sign/src/__tests__/signer/fulcio/ephemeral.test.ts new file mode 100644 index 00000000..19efe2c5 --- /dev/null +++ b/packages/sign/src/__tests__/signer/fulcio/ephemeral.test.ts @@ -0,0 +1,40 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import assert from 'assert'; +import { EphemeralSigner } from '../../../signer/fulcio/ephemeral'; + +describe('EphemeralSigner', () => { + describe('constructor', () => { + it('should create a new instance', () => { + const client = new EphemeralSigner(); + expect(client).toBeDefined(); + }); + }); + + describe('sign', () => { + const subject = new EphemeralSigner(); + const message = Buffer.from('message'); + + it('returns the signature', async () => { + const signature = await subject.sign(message); + expect(signature).toBeDefined(); + expect(signature.signature).toBeDefined(); + expect(signature.key.$case).toEqual('publicKey'); + assert(signature.key.$case === 'publicKey'); + expect(signature.key.publicKey).toBeDefined(); + }); + }); +}); diff --git a/packages/sign/src/__tests__/signer/fulcio/index.test.ts b/packages/sign/src/__tests__/signer/fulcio/index.test.ts new file mode 100644 index 00000000..a1fbe204 --- /dev/null +++ b/packages/sign/src/__tests__/signer/fulcio/index.test.ts @@ -0,0 +1,173 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import assert from 'assert'; +import nock from 'nock'; +import { InternalError } from '../../../error'; +import { FulcioSigner, FulcioSignerOptions } from '../../../signer/fulcio'; + +import type { IdentityProvider } from '../../../identity'; +import type { Signer } from '../../../signer'; + +describe('KeylessSigner', () => { + const fulcioBaseURL = 'http://localhost:8080'; + const signature = 'signature'; + const publicKey = 'publickey'; + + const keyHolder: Signer = { + sign: () => + Promise.resolve({ + signature: Buffer.from('signature'), + key: { $case: 'publicKey', publicKey }, + }), + }; + + const options: FulcioSignerOptions = { + fulcioBaseURL, + identityProvider: { getToken: jest.fn() }, + keyHolder: keyHolder, + }; + + describe('constructor', () => { + it('should create a new instance', () => { + const client = new FulcioSigner({ ...options, keyHolder: undefined }); + expect(client).toBeDefined(); + }); + }); + + describe('sign', () => { + // Data to be signed + const payload = Buffer.from('Hello, world!'); + + // JWT to be returned by the identity provider + const jwtPayload = { + iss: 'https://example.com', + sub: 'foo@bar.com', + }; + const jwt = `.${Buffer.from(JSON.stringify(jwtPayload)).toString( + 'base64' + )}.`; + + // Mock identity provider returns a JWT + const tokenProvider: IdentityProvider = { + getToken: jest.fn().mockResolvedValue(jwt), + }; + + const subject = new FulcioSigner({ + ...options, + identityProvider: tokenProvider, + }); + + describe('when the identity provider returns an error', () => { + const errorProvider: IdentityProvider = { + getToken: jest.fn().mockRejectedValue(new Error('oops')), + }; + + const subject = new FulcioSigner({ + ...options, + identityProvider: errorProvider, + }); + + it('throws an error', async () => { + await expect(subject.sign(payload)).rejects.toThrowWithCode( + InternalError, + 'IDENTITY_TOKEN_READ_ERROR' + ); + }); + }); + + describe('when the child signer returns an invalid key', () => { + const keyHolder: Signer = { + sign: () => + Promise.resolve({ + signature: Buffer.from('signature'), + key: { $case: 'x509Certificate', certificate: 'cert' }, + }), + }; + + const subject = new FulcioSigner({ + ...options, + identityProvider: tokenProvider, + keyHolder: keyHolder, + }); + + it('throws an error', async () => { + await expect(subject.sign(payload)).rejects.toThrowWithCode( + InternalError, + 'CA_CREATE_SIGNING_CERTIFICATE_ERROR' + ); + }); + }); + + describe('when Fulcio returns a valid response', () => { + // Fulcio output + const leafCertificate = `-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----`; + const rootCertificate = `-----BEGIN CERTIFICATE-----\nxyz\n-----END CERTIFICATE-----`; + + // Expected Fulcio request + const expctedRequest = { + credentials: { + oidcIdentityToken: jwt, + }, + publicKeyRequest: { + publicKey: { + algorithm: 'ECDSA', + content: publicKey, + }, + proofOfPossession: Buffer.from(signature).toString('base64'), + }, + }; + + beforeEach(() => { + nock(fulcioBaseURL) + .matchHeader('Content-Type', 'application/json') + .post('/api/v2/signingCert', expctedRequest) + .reply(200, { + signedCertificateEmbeddedSct: { + chain: { certificates: [leafCertificate, rootCertificate] }, + }, + }); + }); + + it('returns a signature', async () => { + const result = await subject.sign(payload); + + expect(result).toBeTruthy(); + expect(result.signature).toBeTruthy(); + assert(result.key.$case === 'x509Certificate'); + expect(result.key.certificate).toEqual; + }); + }); + + describe('when Fulcio returns an invalid response', () => { + beforeEach(() => { + nock(fulcioBaseURL) + .matchHeader('Content-Type', 'application/json') + .post('/api/v2/signingCert') + .reply(500, {}); + }); + + it('throws an error', async () => { + try { + await subject.sign(payload); + throw new Error('Expected an error to be thrown'); + } catch (e) { + assert(e instanceof InternalError); + expect(e.code).toEqual('CA_CREATE_SIGNING_CERTIFICATE_ERROR'); + } + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/util/crypto.test.ts b/packages/sign/src/__tests__/util/crypto.test.ts new file mode 100644 index 00000000..510637ec --- /dev/null +++ b/packages/sign/src/__tests__/util/crypto.test.ts @@ -0,0 +1,26 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { hash } from '../../util/crypto'; + +describe('hash', () => { + it('returns the SHA256 digest of the blob', () => { + const blob = Buffer.from('hello world'); + const digest = hash(blob); + expect(digest.toString('hex')).toBe( + 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9' + ); + }); +}); diff --git a/packages/sign/src/__tests__/util/dsse.test.ts b/packages/sign/src/__tests__/util/dsse.test.ts new file mode 100644 index 00000000..481a4508 --- /dev/null +++ b/packages/sign/src/__tests__/util/dsse.test.ts @@ -0,0 +1,26 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { preAuthEncoding } from '../../util/dsse'; + +describe('preAuthEncoding', () => { + const payloadType = 'text/plain'; + const payload = Buffer.from('Hello, World!', 'utf8'); + + it('should return the correct pre-auth encoding', () => { + const pae = preAuthEncoding(payloadType, payload); + expect(pae).toEqual(Buffer.from('DSSEv1 10 text/plain 13 Hello, World!')); + }); +}); diff --git a/packages/sign/src/__tests__/util/encoding.test.ts b/packages/sign/src/__tests__/util/encoding.test.ts new file mode 100644 index 00000000..a430af2f --- /dev/null +++ b/packages/sign/src/__tests__/util/encoding.test.ts @@ -0,0 +1,55 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import * as encoding from '../../util/encoding'; + +describe('encoding', () => { + const testData = [ + // Example w/ padding + { + decoded: 'hello world', + encoded: 'aGVsbG8gd29ybGQ=', + urlEncoded: 'aGVsbG8gd29ybGQ', + }, + // Example w/o padding + { + decoded: 'abstractiveness', + encoded: 'YWJzdHJhY3RpdmVuZXNz', + urlEncoded: 'YWJzdHJhY3RpdmVuZXNz', + }, + // Example with URL-unsafe chars + { + decoded: 'a??~}~z', + encoded: 'YT8/fn1+eg==', + urlEncoded: 'YT8_fn1-eg', + }, + ]; + + describe('base64Encode', () => { + it('encodes a string to base64', () => { + testData.forEach((entry) => { + expect(encoding.base64Encode(entry.decoded)).toBe(entry.encoded); + }); + }); + }); + + describe('base64Decode', () => { + it('decodes a base64 string', () => { + testData.forEach((entry) => { + expect(encoding.base64Decode(entry.encoded)).toBe(entry.decoded); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/util/json.test.ts b/packages/sign/src/__tests__/util/json.test.ts new file mode 100644 index 00000000..9a55a918 --- /dev/null +++ b/packages/sign/src/__tests__/util/json.test.ts @@ -0,0 +1,80 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { canonicalize } from '../../util/json'; + +describe('canonicalize', () => { + // Test data from https://github.com/cyberphone/json-canonicalization/tree/master/testdata + const input = [ + // array + [56, { d: true, '10': null, '1': [] }], + // non-ascii keys + { + peach: 'This sorting order', + péché: 'is wrong according to French', + pêche: 'but canonicalization MUST', + sin: 'ignore locale', + }, + // structure + { + '1': { f: { f: 'hi', F: 5 }, '\n': 56.0 }, + '10': {}, + '': 'empty', + a: {}, + '111': [{ e: 'yes', E: 'no' }], + A: {}, + }, + // unicode + { + 'Unnormalized Unicode': 'A\u030a', + }, + // values + { + numbers: [ + // eslint-disable-next-line @typescript-eslint/no-loss-of-precision + 333333333.33333329, 1e30, 4.5, 2e-3, 0.000000000000000000000000001, + ], + string: '\u20ac$\u000F\u000aA\'\u0042\u0022\u005c\\"/', + literals: [null, true, false], + }, + // weird + { + '\u20ac': 'Euro Sign', + '\r': 'Carriage Return', + '\u000a': 'Newline', + '1': 'One', + '\u0080': 'Control\u007f', + '\ud83d\ude02': 'Smiley', + '\u00f6': 'Latin Small Letter O With Diaeresis', + '\ufb33': 'Hebrew Letter Dalet With Dagesh', + '': 'Browser Challenge', + }, + ]; + + const output = [ + '[56,{"1":[],"10":null,"d":true}]', + '{"peach":"This sorting order","péché":"is wrong according to French","pêche":"but canonicalization MUST","sin":"ignore locale"}', + '{"":"empty","1":{"\\n":56,"f":{"F":5,"f":"hi"}},"10":{},"111":[{"E":"no","e":"yes"}],"A":{},"a":{}}', + '{"Unnormalized Unicode":"AÌŠ"}', + `{"literals":[null,true,false],"numbers":[333333333.3333333,1e+30,4.5,0.002,1e-27],"string":"€$\\u000f\\nA'B\\"\\\\\\\\\\"/"}`, + '{"\\n":"Newline","\\r":"Carriage Return","1":"One","":"Browser Challenge","€":"Control","ö":"Latin Small Letter O With Diaeresis","€":"Euro Sign","😂":"Smiley","דּ":"Hebrew Letter Dalet With Dagesh"}', + ]; + + it('returns the proper canonicalized object', () => { + input.forEach((_, i) => { + expect(canonicalize(input[i])).toEqual(output[i]); + }); + }); +}); diff --git a/packages/sign/src/__tests__/util/oidc.test.ts b/packages/sign/src/__tests__/util/oidc.test.ts new file mode 100644 index 00000000..62460e77 --- /dev/null +++ b/packages/sign/src/__tests__/util/oidc.test.ts @@ -0,0 +1,56 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { extractJWTSubject } from '../../util/oidc'; + +describe('extractJWTSubject', () => { + describe('when the JWT is issued by accounts.google.com', () => { + const payload = { + iss: 'https://accounts.google.com', + email: 'foo@bar.com', + }; + + const jwt = `.${Buffer.from(JSON.stringify(payload)).toString('base64')}.`; + + it('should return the email address', () => { + expect(extractJWTSubject(jwt)).toBe(payload.email); + }); + }); + + describe('when the JWT is issued by sigstore.dev', () => { + const payload = { + iss: 'https://oauth2.sigstore.dev/auth', + email: 'foo@bar.com', + }; + + const jwt = `.${Buffer.from(JSON.stringify(payload)).toString('base64')}.`; + + it('should return the email address', () => { + expect(extractJWTSubject(jwt)).toBe(payload.email); + }); + }); + + describe('when the JWT is a generic JWT', () => { + const payload = { + iss: 'https://example.com', + sub: 'foo@bar.com', + }; + const jwt = `.${Buffer.from(JSON.stringify(payload)).toString('base64')}.`; + + it('should return the subject', () => { + expect(extractJWTSubject(jwt)).toBe(payload.sub); + }); + }); +}); diff --git a/packages/sign/src/__tests__/util/pem.test.ts b/packages/sign/src/__tests__/util/pem.test.ts new file mode 100644 index 00000000..df78c2fa --- /dev/null +++ b/packages/sign/src/__tests__/util/pem.test.ts @@ -0,0 +1,39 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { toDER } from '../../util/pem'; + +describe('pem', () => { + describe('toDER', () => { + describe('when the object is a certificate', () => { + const pem = + '-----BEGIN CERTIFICATE-----\nABCD\n-----END CERTIFICATE-----\n'; + it('returns a DER-encoded certificate', () => { + const der = toDER(pem); + expect(der).toEqual(Buffer.from('ABCD', 'base64')); + }); + }); + + describe('when the object is a key', () => { + const pem = + '-----BEGIN PUBLIC KEY-----\nDEFG\nHIJK\n-----END PUBLIC KEY-----\n'; + + it('returns a DER-encoded key', () => { + const der = toDER(pem); + expect(der).toEqual(Buffer.from('DEFGHIJK', 'base64')); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/util/promise.test.ts b/packages/sign/src/__tests__/util/promise.test.ts new file mode 100644 index 00000000..68b0a1f4 --- /dev/null +++ b/packages/sign/src/__tests__/util/promise.test.ts @@ -0,0 +1,51 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { promiseAny } from '../../util/promise'; + +describe('promiseAny', () => { + describe('when all promises resolve', () => { + it('should return the first resolved promise', async () => { + const promise1 = Promise.resolve('foo'); + const promise2 = Promise.resolve('bar'); + const promise3 = Promise.resolve('baz'); + + const result = await promiseAny([promise1, promise2, promise3]); + expect(result).toBe('foo'); + }); + }); + + describe('when only one promise resolves', () => { + it('should return the first resolved promise', async () => { + const promise1 = Promise.reject('err'); + const promise2 = Promise.resolve('bar'); + const promise3 = Promise.reject('err'); + + const result = await promiseAny([promise1, promise2, promise3]); + expect(result).toBe('bar'); + }); + }); + + describe('when all promises reject', () => { + it('should return all rejections', async () => { + const promise1 = Promise.reject('err1'); + const promise2 = Promise.reject('err2'); + const promise3 = Promise.reject('err3'); + + const result = promiseAny([promise1, promise2, promise3]); + await expect(result).rejects.toEqual(['err1', 'err2', 'err3']); + }); + }); +}); diff --git a/packages/sign/src/__tests__/util/ua.test.ts b/packages/sign/src/__tests__/util/ua.test.ts new file mode 100644 index 00000000..14bae527 --- /dev/null +++ b/packages/sign/src/__tests__/util/ua.test.ts @@ -0,0 +1,22 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { getUserAgent } from '../../util/ua'; + +describe('getUserAgent', () => { + it('returns a user agent string', () => { + expect(getUserAgent()).toMatch(new RegExp('sigstore-js\\/\\d+.\\d+.\\d+')); + }); +}); diff --git a/packages/sign/src/__tests__/witness/tlog/client.test.ts b/packages/sign/src/__tests__/witness/tlog/client.test.ts new file mode 100644 index 00000000..4d43c69c --- /dev/null +++ b/packages/sign/src/__tests__/witness/tlog/client.test.ts @@ -0,0 +1,166 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import nock from 'nock'; +import { InternalError } from '../../../error'; +import { ProposedEntry, TLogClient } from '../../../witness/tlog/client'; + +describe('TLogClient', () => { + const rekorBaseURL = 'http://localhost:8080'; + + describe('constructor', () => { + it('should create a new instance', () => { + const client = new TLogClient({ rekorBaseURL }); + expect(client).toBeDefined(); + }); + }); + + describe('createEntry', () => { + const subject = new TLogClient({ rekorBaseURL }); + + const digest = Buffer.from('digest').toString('hex'); + const signature = Buffer.from('signature').toString('base64'); + const publicKey = Buffer.from('publicKey').toString('base64'); + + const proposedEntry = { + apiVersion: '0.0.1', + kind: 'hashedrekord', + spec: { + data: { + hash: { + algorithm: 'sha256', + value: digest, + }, + }, + signature: { + content: signature, + publicKey: { + content: publicKey, + }, + }, + }, + } satisfies ProposedEntry; + + const uuid = + '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6'; + + const rekorEntry = { + [uuid]: { + body: Buffer.from(JSON.stringify(proposedEntry)).toString('base64'), + integratedTime: 1654015743, + logID: + 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', + logIndex: 2513258, + verification: { + signedEntryTimestamp: + 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=', + }, + }, + }; + + describe('when Rekor returns an error', () => { + beforeEach(() => { + nock(rekorBaseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/log/entries', proposedEntry) + .reply(500, {}); + }); + + it('returns an error', async () => { + await expect( + subject.createEntry(proposedEntry) + ).rejects.toThrowWithCode(InternalError, 'TLOG_CREATE_ENTRY_ERROR'); + }); + }); + + describe('when Rekor returns a valid response', () => { + beforeEach(() => { + nock(rekorBaseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/log/entries', proposedEntry) + .reply(201, rekorEntry); + }); + + it('returns a tlog entry', async () => { + const entry = await subject.createEntry(proposedEntry); + + expect(entry.uuid).toEqual(uuid); + expect(entry.logID).toEqual(rekorEntry[uuid].logID); + expect(entry.logIndex).toEqual(rekorEntry[uuid].logIndex); + expect(entry.integratedTime).toEqual(rekorEntry[uuid].integratedTime); + expect(entry?.verification?.signedEntryTimestamp).toEqual( + rekorEntry[uuid].verification.signedEntryTimestamp + ); + expect(entry.body).toEqual(rekorEntry[uuid].body); + }); + }); + + describe('when Rekor returns a 409 conflict error', () => { + beforeEach(() => { + nock(rekorBaseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/log/entries', proposedEntry) + .reply(409, {}, { Location: `/api/v1/log/entries/${uuid}` }); + }); + + describe('when fetchOnConflict is false', () => { + const subject = new TLogClient({ + rekorBaseURL, + fetchOnConflict: false, + }); + + it('returns an error', async () => { + await expect( + subject.createEntry(proposedEntry) + ).rejects.toThrowWithCode(InternalError, 'TLOG_CREATE_ENTRY_ERROR'); + }); + }); + + describe('when fetchOnConflict is true', () => { + const subject = new TLogClient({ rekorBaseURL, fetchOnConflict: true }); + + describe('when the fetch is successful', () => { + beforeEach(() => { + nock(rekorBaseURL) + .get(`/api/v1/log/entries/${uuid}`) + .reply(200, rekorEntry); + }); + + it('returns a tlog entry', async () => { + const entry = await subject.createEntry(proposedEntry); + expect(entry).toBeTruthy(); + }); + }); + + describe('when the fetch returns an error', () => { + beforeEach(() => { + nock(rekorBaseURL) + .get(`/api/v1/log/entries/${uuid}`) + .reply(404, {}); + }); + + it('returns an error', async () => { + await expect( + subject.createEntry(proposedEntry) + ).rejects.toThrowWithCode(InternalError, 'TLOG_FETCH_ENTRY_ERROR'); + }); + }); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/witness/tlog/entry.test.ts b/packages/sign/src/__tests__/witness/tlog/entry.test.ts new file mode 100644 index 00000000..b2f38a84 --- /dev/null +++ b/packages/sign/src/__tests__/witness/tlog/entry.test.ts @@ -0,0 +1,236 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { envelopeToJSON } from '@sigstore/bundle'; +import { HashAlgorithm } from '@sigstore/protobuf-specs'; +import assert from 'assert'; +import { crypto, encoding as enc } from '../../../util'; +import { toProposedEntry } from '../../../witness/tlog/entry'; + +import type { SignatureBundle } from '../../../witness'; + +describe('toProposedEntry', () => { + const publicKey = '-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----'; + const signature = Buffer.from('signature'); + + describe('when a message signature is provided', () => { + const sigBundle: SignatureBundle = { + $case: 'messageSignature', + messageSignature: { + signature: signature, + messageDigest: { + algorithm: HashAlgorithm.SHA2_256, + digest: Buffer.from('digest'), + }, + }, + }; + + it('returns a valid ProposedEntry entry', () => { + const entry = toProposedEntry(sigBundle, publicKey); + + assert(entry.apiVersion === '0.0.1'); + assert(entry.kind === 'hashedrekord'); + expect(entry.spec).toBeTruthy(); + + expect(entry.spec.data).toBeTruthy(); + expect(entry.spec.data.hash).toBeTruthy(); + expect(entry.spec.data.hash?.algorithm).toBe('sha256'); + expect(entry.spec.data.hash?.value).toBe( + sigBundle.messageSignature.messageDigest.digest.toString('hex') + ); + + expect(entry.spec.signature).toBeTruthy(); + expect(entry.spec.signature?.content).toBe(signature.toString('base64')); + expect(entry.spec.signature?.publicKey).toBeTruthy(); + expect(entry.spec.signature?.publicKey?.content).toBe( + enc.base64Encode(publicKey) + ); + }); + }); + + describe('when a DSSE envelope is provided', () => { + describe('when the keyid is a non-empty string', () => { + const sigBundle: SignatureBundle = { + $case: 'dsseEnvelope', + dsseEnvelope: { + signatures: [{ keyid: '123', sig: signature }], + payloadType: 'application/vnd.in-toto+json', + payload: Buffer.from('payload'), + }, + }; + + it('return a valid ProposedEntry entry', () => { + const entry = toProposedEntry(sigBundle, publicKey); + + assert(entry.apiVersion === '0.0.2'); + assert(entry.kind === 'intoto'); + expect(entry.spec).toBeTruthy(); + expect(entry.spec.content).toBeTruthy(); + expect(entry.spec.content.envelope).toBeTruthy(); + + const e = entry.spec.content.envelope; + expect(e?.payloadType).toEqual(sigBundle.dsseEnvelope.payloadType); + expect(e?.payload).toEqual( + enc.base64Encode(sigBundle.dsseEnvelope.payload.toString('base64')) + ); + expect(e?.signatures).toHaveLength(1); + expect(e?.signatures[0].keyid).toEqual( + sigBundle.dsseEnvelope.signatures[0].keyid + ); + expect(e?.signatures[0].sig).toEqual( + enc.base64Encode( + sigBundle.dsseEnvelope.signatures[0].sig.toString('base64') + ) + ); + expect(e?.signatures[0].publicKey).toEqual(enc.base64Encode(publicKey)); + + expect(entry.spec.content.payloadHash).toBeTruthy(); + expect(entry.spec.content.payloadHash?.algorithm).toBe('sha256'); + expect(entry.spec.content.payloadHash?.value).toBe( + crypto.hash(sigBundle.dsseEnvelope.payload).toString('hex') + ); + expect(entry.spec.content.hash).toBeTruthy(); + expect(entry.spec.content.hash?.algorithm).toBe('sha256'); + + // This hard-coded hash value helps us detect if we've unintentionally + // changed the hashing algorithm. + expect(entry.spec.content.hash?.value).toBe( + '37d47ab456ca63a84f6457be655dd49799542f2e1db5d05160b214fb0b9a7f55' + ); + }); + }); + + describe('when the keyid is an empty string', () => { + const sigBundle: SignatureBundle = { + $case: 'dsseEnvelope', + dsseEnvelope: { + signatures: [{ keyid: '', sig: signature }], + payloadType: 'application/vnd.in-toto+json', + payload: Buffer.from('payload'), + }, + }; + + it('return a valid ProposedEntry entry', () => { + const entry = toProposedEntry(sigBundle, publicKey); + + assert(entry.apiVersion === '0.0.2'); + assert(entry.kind === 'intoto'); + expect(entry.spec).toBeTruthy(); + expect(entry.spec.content).toBeTruthy(); + expect(entry.spec.content.envelope).toBeTruthy(); + + const e = entry.spec.content.envelope; + expect(e?.payloadType).toEqual(sigBundle.dsseEnvelope.payloadType); + expect(e?.payload).toEqual( + enc.base64Encode(sigBundle.dsseEnvelope.payload.toString('base64')) + ); + expect(e?.signatures).toHaveLength(1); + expect(e?.signatures[0].keyid).toBeUndefined(); + expect(e?.signatures[0].sig).toEqual( + enc.base64Encode( + sigBundle.dsseEnvelope.signatures[0].sig.toString('base64') + ) + ); + expect(e?.signatures[0].publicKey).toEqual(enc.base64Encode(publicKey)); + + expect(entry.spec.content.payloadHash).toBeTruthy(); + expect(entry.spec.content.payloadHash?.algorithm).toBe('sha256'); + expect(entry.spec.content.payloadHash?.value).toBe( + crypto.hash(sigBundle.dsseEnvelope.payload).toString('hex') + ); + expect(entry.spec.content.hash).toBeTruthy(); + expect(entry.spec.content.hash?.algorithm).toBe('sha256'); + + // This hard-coded hash value helps us detect if we've unintentionally + // changed the hashing algorithm. + expect(entry.spec.content.hash?.value).toBe( + 'f39ab279af9d9be421342ce4c8e5c422b5bc3dd20602703b1893283a934fbe72' + ); + }); + }); + + describe('when there are multiple signatures in the envelope', () => { + const sigBundle: SignatureBundle = { + $case: 'dsseEnvelope', + dsseEnvelope: { + signatures: [ + { keyid: '123', sig: signature }, + { keyid: '', sig: signature }, + ], + payloadType: 'application/vnd.in-toto+json', + payload: Buffer.from('payload'), + }, + }; + + it('return a valid ProposedEntry entry', () => { + const entry = toProposedEntry(sigBundle, publicKey); + + assert(entry.apiVersion === '0.0.2'); + assert(entry.kind === 'intoto'); + + // Check to ensure only the first signature is included in the envelope + const e = entry.spec.content.envelope; + expect(e?.signatures).toHaveLength(1); + expect(e?.signatures[0].keyid).toEqual( + sigBundle.dsseEnvelope.signatures[0].keyid + ); + expect(e?.signatures[0].sig).toEqual( + enc.base64Encode( + sigBundle.dsseEnvelope.signatures[0].sig.toString('base64') + ) + ); + expect(e?.signatures[0].publicKey).toEqual(enc.base64Encode(publicKey)); + + // This hard-coded hash value helps us detect if we've unintentionally + // changed the hashing algorithm. + expect(entry.spec.content.hash?.value).toBe( + '37d47ab456ca63a84f6457be655dd49799542f2e1db5d05160b214fb0b9a7f55' + ); + }); + }); + }); + + describe('when a DSSE envelope is provided', () => { + describe('when the keyid is a non-empty string', () => { + const sigBundle: SignatureBundle = { + $case: 'dsseEnvelope', + dsseEnvelope: { + signatures: [{ keyid: '123', sig: signature }], + payloadType: 'application/vnd.in-toto+json', + payload: Buffer.from('payload'), + }, + }; + + it('return a valid ProposedEntry entry', () => { + const entry = toProposedEntry(sigBundle, publicKey, 'dsse'); + + assert(entry.apiVersion === '0.0.1'); + assert(entry.kind === 'dsse'); + expect(entry.spec).toBeTruthy(); + expect(entry.spec.proposedContent).toBeTruthy(); + + const e = entry.spec.proposedContent?.envelope; + expect(e).toEqual( + JSON.stringify(envelopeToJSON(sigBundle.dsseEnvelope)) + ); + + expect(entry.spec.proposedContent?.verifiers).toHaveLength(1); + expect(entry.spec.proposedContent?.verifiers[0]).toEqual( + enc.base64Encode(publicKey) + ); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/witness/tlog/index.test.ts b/packages/sign/src/__tests__/witness/tlog/index.test.ts new file mode 100644 index 00000000..f4460982 --- /dev/null +++ b/packages/sign/src/__tests__/witness/tlog/index.test.ts @@ -0,0 +1,305 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { HashAlgorithm } from '@sigstore/protobuf-specs'; +import assert from 'assert'; +import nock from 'nock'; +import { InternalError } from '../../../error'; +import { RekorWitness } from '../../../witness/tlog'; + +import type { SignatureBundle } from '../../../witness'; + +describe('RekorWitness', () => { + const rekorBaseURL = 'http://localhost:8080'; + + describe('constructor', () => { + it('should create a new instance', () => { + const client = new RekorWitness({ rekorBaseURL }); + expect(client).toBeDefined(); + }); + }); + + describe('testify', () => { + const subject = new RekorWitness({ rekorBaseURL }); + const signature = Buffer.from('signature'); + const publicKey = 'publickey'; + + describe('when Rekor returns a valid response', () => { + const uuid = + '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6'; + + const proposedEntry = { + apiVersion: '0.0.1', + kind: 'foo', + }; + + const rekorEntry = { + [uuid]: { + body: Buffer.from(JSON.stringify(proposedEntry)).toString('base64'), + integratedTime: 1654015743, + logID: + 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', + logIndex: 2513258, + verification: { + signedEntryTimestamp: + 'MEUCIQD6CD7ZNLUipFoxzmSL/L8Ewic4SRkXN77UjfJZ7d/wAAIgatokSuX9Rg0iWxAgSfHMtcsagtDCQalU5IvXdQ+yLEA=', + inclusionProof: { + checkpoint: 'checkpoint', + hashes: ['deadbeaf', 'feedface'], + logIndex: 2513257, + rootHash: 'fee1dead', + treeSize: 2513285, + }, + }, + }, + }; + + beforeEach(() => { + nock(rekorBaseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/log/entries') + .reply(201, rekorEntry); + }); + + describe('when the signature bundle is a message signature', () => { + const sigBundle: SignatureBundle = { + $case: 'messageSignature', + messageSignature: { + signature: signature, + messageDigest: { + algorithm: HashAlgorithm.SHA2_256, + digest: Buffer.from('digest'), + }, + }, + }; + + it('returns the tlog entry', async () => { + const vm = await subject.testify(sigBundle, publicKey); + + expect(vm).toBeDefined(); + expect(vm.rfc3161Timestamps).toBeUndefined(); + + assert(vm.tlogEntries); + expect(vm.tlogEntries).toHaveLength(1); + + const tlogEntry = vm.tlogEntries[0]; + expect(tlogEntry).toBeDefined(); + expect(tlogEntry.logIndex).toEqual( + rekorEntry[uuid].logIndex.toString() + ); + expect(tlogEntry.logId?.keyId).toEqual( + Buffer.from(rekorEntry[uuid].logID, 'hex') + ); + expect(tlogEntry.kindVersion?.kind).toEqual(proposedEntry.kind); + expect(tlogEntry.kindVersion?.version).toEqual( + proposedEntry.apiVersion + ); + expect(tlogEntry.integratedTime).toEqual( + rekorEntry[uuid].integratedTime.toString() + ); + expect(tlogEntry.inclusionPromise?.signedEntryTimestamp).toEqual( + Buffer.from( + rekorEntry[uuid].verification.signedEntryTimestamp, + 'base64' + ) + ); + expect(tlogEntry.inclusionProof?.checkpoint?.envelope).toEqual( + rekorEntry[uuid].verification.inclusionProof.checkpoint + ); + expect(tlogEntry.inclusionProof?.hashes).toHaveLength(2); + expect(tlogEntry.inclusionProof?.hashes[0]).toEqual( + Buffer.from( + rekorEntry[uuid].verification.inclusionProof.hashes[0], + 'hex' + ) + ); + expect(tlogEntry.inclusionProof?.hashes[1]).toEqual( + Buffer.from( + rekorEntry[uuid].verification.inclusionProof.hashes[1], + 'hex' + ) + ); + expect(tlogEntry.inclusionProof?.logIndex).toEqual( + rekorEntry[uuid].verification.inclusionProof.logIndex.toString() + ); + expect(tlogEntry.inclusionProof?.rootHash).toEqual( + Buffer.from( + rekorEntry[uuid].verification.inclusionProof.rootHash, + 'hex' + ) + ); + expect(tlogEntry.inclusionProof?.treeSize).toEqual( + rekorEntry[uuid].verification.inclusionProof.treeSize.toString() + ); + expect(tlogEntry.canonicalizedBody).toEqual( + Buffer.from(rekorEntry[uuid].body, 'base64') + ); + }); + }); + + describe('when the signature bundle is a DSSE envelope', () => { + const sigBundle: SignatureBundle = { + $case: 'dsseEnvelope', + dsseEnvelope: { + signatures: [{ keyid: '', sig: signature }], + payload: Buffer.from('payload'), + payloadType: 'payloadType', + }, + }; + + it('returns the tlog entry', async () => { + const vm = await subject.testify(sigBundle, publicKey); + + expect(vm).toBeDefined(); + expect(vm.rfc3161Timestamps).toBeUndefined(); + + assert(vm.tlogEntries); + expect(vm.tlogEntries).toHaveLength(1); + + const tlogEntry = vm.tlogEntries[0]; + expect(tlogEntry).toBeDefined(); + expect(tlogEntry.logIndex).toEqual( + rekorEntry[uuid].logIndex.toString() + ); + expect(tlogEntry.logId?.keyId).toEqual( + Buffer.from(rekorEntry[uuid].logID, 'hex') + ); + expect(tlogEntry.kindVersion?.kind).toEqual(proposedEntry.kind); + expect(tlogEntry.kindVersion?.version).toEqual( + proposedEntry.apiVersion + ); + expect(tlogEntry.integratedTime).toEqual( + rekorEntry[uuid].integratedTime.toString() + ); + expect(tlogEntry.inclusionPromise?.signedEntryTimestamp).toEqual( + Buffer.from( + rekorEntry[uuid].verification.signedEntryTimestamp, + 'base64' + ) + ); + expect(tlogEntry.inclusionProof?.checkpoint?.envelope).toEqual( + rekorEntry[uuid].verification.inclusionProof.checkpoint + ); + expect(tlogEntry.inclusionProof?.hashes).toHaveLength(2); + expect(tlogEntry.inclusionProof?.hashes[0]).toEqual( + Buffer.from( + rekorEntry[uuid].verification.inclusionProof.hashes[0], + 'hex' + ) + ); + expect(tlogEntry.inclusionProof?.hashes[1]).toEqual( + Buffer.from( + rekorEntry[uuid].verification.inclusionProof.hashes[1], + 'hex' + ) + ); + expect(tlogEntry.inclusionProof?.logIndex).toEqual( + rekorEntry[uuid].verification.inclusionProof.logIndex.toString() + ); + expect(tlogEntry.inclusionProof?.rootHash).toEqual( + Buffer.from( + rekorEntry[uuid].verification.inclusionProof.rootHash, + 'hex' + ) + ); + expect(tlogEntry.inclusionProof?.treeSize).toEqual( + rekorEntry[uuid].verification.inclusionProof.treeSize.toString() + ); + expect(tlogEntry.canonicalizedBody).toEqual( + Buffer.from(rekorEntry[uuid].body, 'base64') + ); + }); + }); + }); + + describe('when Rekor returns an entry w/o verification data', () => { + const sigBundle: SignatureBundle = { + $case: 'messageSignature', + messageSignature: { + signature: signature, + messageDigest: { + algorithm: HashAlgorithm.SHA2_256, + digest: Buffer.from('digest'), + }, + }, + }; + + const uuid = + '69e5a0c1663ee4452674a5c9d5050d866c2ee31e2faaf79913aea7cc27293cf6'; + + const proposedEntry = { + apiVersion: '0.0.1', + kind: 'foo', + }; + + const rekorEntry = { + [uuid]: { + body: Buffer.from(JSON.stringify(proposedEntry)).toString('base64'), + integratedTime: 1654015743, + logID: + 'c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d', + logIndex: 2513258, + }, + }; + + beforeEach(() => { + nock(rekorBaseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/log/entries') + .reply(201, rekorEntry); + }); + + it('returns the tlog entry with an empty SET', async () => { + const vm = await subject.testify(sigBundle, publicKey); + + expect(vm).toBeDefined(); + assert(vm.tlogEntries); + + expect(vm.tlogEntries).toHaveLength(1); + const tlogEntry = vm.tlogEntries[0]; + expect( + tlogEntry.inclusionPromise?.signedEntryTimestamp + ).toBeUndefined(); + }); + }); + + describe('when Rekor returns an error', () => { + const sigBundle: SignatureBundle = { + $case: 'dsseEnvelope', + dsseEnvelope: { + signatures: [{ keyid: '', sig: signature }], + payload: Buffer.from('payload'), + payloadType: 'payloadType', + }, + }; + + beforeEach(() => { + nock(rekorBaseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/log/entries') + .reply(500, {}); + }); + + it('returns an error', async () => { + await expect( + subject.testify(sigBundle, publicKey) + ).rejects.toThrowWithCode(InternalError, 'TLOG_CREATE_ENTRY_ERROR'); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/witness/tsa/client.test.ts b/packages/sign/src/__tests__/witness/tsa/client.test.ts new file mode 100644 index 00000000..a7bc3935 --- /dev/null +++ b/packages/sign/src/__tests__/witness/tsa/client.test.ts @@ -0,0 +1,70 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { mockTSA } from '@sigstore/mock'; +import nock from 'nock'; +import { InternalError } from '../../../error'; +import { crypto } from '../../../util'; +import { TSAClient } from '../../../witness/tsa/client'; + +describe('TSAClient', () => { + const baseURL = 'http://localhost:8080'; + + describe('constructor', () => { + it('should create a new instance', () => { + const client = new TSAClient({ tsaBaseURL: baseURL }); + expect(client).toBeDefined(); + }); + }); + + describe('createTimestamp', () => { + const subject = new TSAClient({ tsaBaseURL: baseURL }); + + const signature = Buffer.from('signature'); + + const request = { + artifactHash: crypto.hash(signature).toString('base64'), + hashAlgorithm: 'sha256', + }; + + describe('when TSA returns a timestamp', () => { + beforeEach(async () => { + await mockTSA({ baseURL }); + }); + + it('returns the timestamp', async () => { + const timestamp = await subject.createTimestamp(signature); + + expect(timestamp).toBeDefined(); + expect(timestamp.byteLength).toBeGreaterThan(0); + }); + }); + + describe('when TSA returns an error', () => { + beforeEach(() => { + nock(baseURL) + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/timestamp', request) + .reply(500, {}); + }); + + it('returns an error', async () => { + await expect( + subject.createTimestamp(signature) + ).rejects.toThrowWithCode(InternalError, 'TSA_CREATE_TIMESTAMP_ERROR'); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/witness/tsa/index.test.ts b/packages/sign/src/__tests__/witness/tsa/index.test.ts new file mode 100644 index 00000000..2116a9b5 --- /dev/null +++ b/packages/sign/src/__tests__/witness/tsa/index.test.ts @@ -0,0 +1,110 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { HashAlgorithm } from '@sigstore/protobuf-specs'; +import nock from 'nock'; +import { InternalError } from '../../../error'; +import { TSAWitness } from '../../../witness/tsa'; + +import type { SignatureBundle } from '../../../witness'; + +describe('TSAWitness', () => { + const tsaBaseURL = 'http://localhost:8080'; + + describe('constructor', () => { + it('should create a new instance', () => { + const client = new TSAWitness({ tsaBaseURL }); + expect(client).toBeDefined(); + }); + }); + + describe('testify', () => { + const subject = new TSAWitness({ tsaBaseURL }); + + const signature = Buffer.from('signature'); + + describe('when TSA returns a timestamp', () => { + const timestamp = Buffer.from('timestamp'); + + beforeEach(() => { + nock(tsaBaseURL) + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/timestamp') + .reply(201, timestamp); + }); + + describe('when the signature bundle is a message signature', () => { + const sigBundle: SignatureBundle = { + $case: 'messageSignature', + messageSignature: { + signature: signature, + messageDigest: { + algorithm: HashAlgorithm.SHA2_256, + digest: Buffer.from('digest'), + }, + }, + }; + + it('returns the timestamp', async () => { + await expect(subject.testify(sigBundle)).resolves.toEqual({ + rfc3161Timestamps: [{ signedTimestamp: timestamp }], + }); + }); + }); + + describe('when the signature bundle is a DSSE envelope', () => { + const sigBundle: SignatureBundle = { + $case: 'dsseEnvelope', + dsseEnvelope: { + signatures: [{ keyid: '', sig: signature }], + payload: Buffer.from('payload'), + payloadType: 'payloadType', + }, + }; + + it('returns the timestamp', async () => { + await expect(subject.testify(sigBundle)).resolves.toEqual({ + rfc3161Timestamps: [{ signedTimestamp: timestamp }], + }); + }); + }); + }); + + describe('when TSA returns an error', () => { + const sigBundle: SignatureBundle = { + $case: 'dsseEnvelope', + dsseEnvelope: { + signatures: [{ keyid: '', sig: signature }], + payload: Buffer.from('payload'), + payloadType: 'payloadType', + }, + }; + + beforeEach(() => { + nock(tsaBaseURL) + .matchHeader('Content-Type', 'application/json') + .post('/api/v1/timestamp') + .reply(500, {}); + }); + + it('returns an error', async () => { + await expect(subject.testify(sigBundle)).rejects.toThrowWithCode( + InternalError, + 'TSA_CREATE_TIMESTAMP_ERROR' + ); + }); + }); + }); +}); diff --git a/packages/sign/src/bundler/base.ts b/packages/sign/src/bundler/base.ts new file mode 100644 index 00000000..a3cdd637 --- /dev/null +++ b/packages/sign/src/bundler/base.ts @@ -0,0 +1,112 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import type { + Bundle, + RFC3161SignedTimestamp, + TransparencyLogEntry, +} from '@sigstore/bundle'; +import type { KeyMaterial, Signature, Signer } from '../signer'; +import type { Witness } from '../witness'; + +export interface BundleBuilderOptions { + signer: Signer; + witnesses: Witness[]; +} + +// Representation of the artifact to be signed. Includes the raw bytes of the +// artifact and an optional MIME type. +export interface Artifact { + data: Buffer; + type?: string; +} + +// Interface for bundler implementations. A bundler is responsible for signing +// and witnessing an artifact. +export interface BundleBuilder { + create: (artifact: Artifact) => Promise; +} + +// BaseBundleBuilder is a base class for BundleBuilder implementations. It +// provides a the basic wokflow for signing and witnessing an artifact. +// Subclasses must implement the `package` method to assemble a valid bundle +// with the generated signature and verification material. +export abstract class BaseBundleBuilder implements BundleBuilder { + protected signer: Signer; + private witnesses: Witness[]; + + constructor(options: BundleBuilderOptions) { + this.signer = options.signer; + this.witnesses = options.witnesses; + } + + // Executes the signing/witnessing process for the given artifact. + public async create(artifact: Artifact): Promise { + const signature = await this.prepare(artifact).then((blob) => + this.signer.sign(blob) + ); + const bundle = await this.package(artifact, signature); + + // Invoke all of the witnesses in parallel + const verificationMaterials = await Promise.all( + this.witnesses.map((witness) => + witness.testify(bundle.content, publicKey(signature.key)) + ) + ); + + // Collect the verification material from all of the witnesses + const tlogEntryList: TransparencyLogEntry[] = []; + const timestampList: RFC3161SignedTimestamp[] = []; + + verificationMaterials.forEach(({ tlogEntries, rfc3161Timestamps }) => { + tlogEntryList.push(...(tlogEntries ?? [])); + timestampList.push(...(rfc3161Timestamps ?? [])); + }); + + // Merge the collected verification material into the bundle + bundle.verificationMaterial.tlogEntries = tlogEntryList; + bundle.verificationMaterial.timestampVerificationData = { + rfc3161Timestamps: timestampList, + }; + + return bundle; + } + + // Override this function to apply any pre-signing transformations to the + // artifact. The returned buffer will be signed by the signer. The default + // implementation simply returns the artifact data. + protected async prepare(artifact: Artifact): Promise { + return artifact.data; + } + + // Override this function to package the artifact and signature into a + // bundle. Any verification material from the configured witnesses will be + // merged into the bundle. + protected abstract package( + artifact: Artifact, + signature: Signature + ): Promise; +} + +// Extracts the public key from a KeyMaterial. Returns either the public key +// or the certificate, depending on the type of key material. +function publicKey(key: KeyMaterial): string { + switch (key.$case) { + case 'publicKey': + return key.publicKey; + case 'x509Certificate': + return key.certificate; + } +} diff --git a/packages/sign/src/bundler/bundle.ts b/packages/sign/src/bundler/bundle.ts new file mode 100644 index 00000000..b933301d --- /dev/null +++ b/packages/sign/src/bundler/bundle.ts @@ -0,0 +1,122 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { BUNDLE_V01_MEDIA_TYPE } from '@sigstore/bundle'; +import { HashAlgorithm } from '@sigstore/protobuf-specs'; +import { crypto, pem } from '../util'; + +import type * as sigstore from '@sigstore/bundle'; +import type { KeyMaterial, Signature } from '../signer'; +import type { Artifact } from './base'; + +// Helper functions for assembling the parts of a Sigstore bundle + +// Message signature bundle - $case: 'messageSignature' +export function toMessageSignatureBundle( + artifact: Artifact, + signature: Signature +): sigstore.Bundle { + const digest = crypto.hash(artifact.data); + + return { + mediaType: BUNDLE_V01_MEDIA_TYPE, + content: { + $case: 'messageSignature', + messageSignature: { + messageDigest: { + algorithm: HashAlgorithm.SHA2_256, + digest: digest, + }, + signature: signature.signature, + }, + }, + verificationMaterial: toVerificationMaterial(signature.key), + }; +} + +// DSSE envelope bundle - $case: 'dsseEnvelope' +export function toDSSEBundle( + artifact: Required, + signature: Signature +): sigstore.Bundle { + return { + mediaType: BUNDLE_V01_MEDIA_TYPE, + content: { + $case: 'dsseEnvelope', + dsseEnvelope: toEnvelope(artifact, signature), + }, + verificationMaterial: toVerificationMaterial(signature.key), + }; +} + +function toEnvelope( + artifact: Required, + signature: Signature +): sigstore.Envelope { + return { + payloadType: artifact.type, + payload: artifact.data, + signatures: [toSignature(signature)], + }; +} + +function toSignature(signature: Signature): sigstore.Signature { + return { + keyid: toKeyID(signature), + sig: signature.signature, + }; +} + +function toKeyID(signature: Signature): string { + switch (signature.key.$case) { + case 'publicKey': + return signature.key.hint || ''; + case 'x509Certificate': + return ''; + } +} + +// Verification material + +function toVerificationMaterial( + key: KeyMaterial +): sigstore.Bundle['verificationMaterial'] { + return { + content: toKeyContent(key), + tlogEntries: [], + timestampVerificationData: { rfc3161Timestamps: [] }, + }; +} + +function toKeyContent( + key: KeyMaterial +): sigstore.Bundle['verificationMaterial']['content'] { + switch (key.$case) { + case 'publicKey': + return { + $case: 'publicKey', + publicKey: { + hint: key.hint || '', + }, + }; + case 'x509Certificate': + return { + $case: 'x509CertificateChain', + x509CertificateChain: { + certificates: [{ rawBytes: pem.toDER(key.certificate) }], + }, + }; + } +} diff --git a/packages/sign/src/bundler/dsse.ts b/packages/sign/src/bundler/dsse.ts new file mode 100644 index 00000000..13036189 --- /dev/null +++ b/packages/sign/src/bundler/dsse.ts @@ -0,0 +1,51 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { dsse } from '../util'; +import { Artifact, BaseBundleBuilder, BundleBuilderOptions } from './base'; +import { toDSSEBundle } from './bundle'; + +import type * as sigstore from '@sigstore/bundle'; +import type { Signature } from '../signer'; + +// BundleBuilder implementation for DSSE wrapped attestations +export class DSSEBundleBuilder extends BaseBundleBuilder { + constructor(options: BundleBuilderOptions) { + super(options); + } + + // DSSE requires the artifact to be pre-encoded with the payload type + // before the signature is generated. + protected override async prepare(artifact: Artifact): Promise { + const a = artifactDefaults(artifact); + return dsse.preAuthEncoding(a.type, a.data); + } + + // Packages the artifact and signature into a DSSE bundle + protected override async package( + artifact: Artifact, + signature: Signature + ): Promise { + return toDSSEBundle(artifactDefaults(artifact), signature); + } +} + +// Defaults the artifact type to an empty string if not provided +function artifactDefaults(artifact: Artifact): Required { + return { + ...artifact, + type: artifact.type ?? '', + }; +} diff --git a/packages/sign/src/bundler/index.ts b/packages/sign/src/bundler/index.ts new file mode 100644 index 00000000..ea2d1989 --- /dev/null +++ b/packages/sign/src/bundler/index.ts @@ -0,0 +1,18 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +export type { Artifact, BundleBuilder, BundleBuilderOptions } from './base'; +export { DSSEBundleBuilder } from './dsse'; +export { MessageBundleBuilder } from './message'; diff --git a/packages/sign/src/bundler/message.ts b/packages/sign/src/bundler/message.ts new file mode 100644 index 00000000..03a1ed7e --- /dev/null +++ b/packages/sign/src/bundler/message.ts @@ -0,0 +1,34 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { Artifact, BaseBundleBuilder, BundleBuilderOptions } from './base'; +import { toMessageSignatureBundle } from './bundle'; + +import type { Bundle } from '@sigstore/bundle'; +import type { Signature } from '../signer'; + +// BundleBuilder implementation for raw message signatures +export class MessageBundleBuilder extends BaseBundleBuilder { + constructor(options: BundleBuilderOptions) { + super(options); + } + + override async package( + artifact: Artifact, + signature: Signature + ): Promise { + return toMessageSignatureBundle(artifact, signature); + } +} diff --git a/packages/sign/src/error.ts b/packages/sign/src/error.ts new file mode 100644 index 00000000..6da52ee6 --- /dev/null +++ b/packages/sign/src/error.ts @@ -0,0 +1,42 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/* eslint-disable @typescript-eslint/no-explicit-any */ +type InternalErrorCode = + | 'TLOG_FETCH_ENTRY_ERROR' + | 'TLOG_CREATE_ENTRY_ERROR' + | 'CA_CREATE_SIGNING_CERTIFICATE_ERROR' + | 'TSA_CREATE_TIMESTAMP_ERROR' + | 'IDENTITY_TOKEN_READ_ERROR'; + +export class InternalError extends Error { + code: InternalErrorCode; + cause: any | undefined; + + constructor({ + code, + message, + cause, + }: { + code: InternalErrorCode; + message: string; + cause?: any; + }) { + super(message); + this.name = this.constructor.name; + this.cause = cause; + this.code = code; + } +} diff --git a/packages/sign/src/external/error.ts b/packages/sign/src/external/error.ts new file mode 100644 index 00000000..92610601 --- /dev/null +++ b/packages/sign/src/external/error.ts @@ -0,0 +1,40 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import fetch from 'make-fetch-happen'; + +// Convoluted way of getting at the Response type used by make-fetch-happen +type Response = Awaited>; + +export class HTTPError extends Error { + public response: Response; + public statusCode: number; + public location?: string; + + constructor(response: Response) { + super(`HTTP Error: ${response.status} ${response.statusText}`); + this.response = response; + this.statusCode = response.status; + this.location = response.headers?.get('Location') || undefined; + } +} + +export const checkStatus = (response: Response): Response => { + if (response.ok) { + return response; + } else { + throw new HTTPError(response); + } +}; diff --git a/packages/sign/src/external/fulcio.ts b/packages/sign/src/external/fulcio.ts new file mode 100644 index 00000000..72fc6326 --- /dev/null +++ b/packages/sign/src/external/fulcio.ts @@ -0,0 +1,84 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import fetch, { FetchInterface } from 'make-fetch-happen'; +import { ua } from '../util'; +import { checkStatus } from './error'; + +import type { FetchOptions } from '../types/fetch'; + +export type FulcioOptions = { + baseURL: string; +} & FetchOptions; + +export interface SigningCertificateRequest { + credentials: { + oidcIdentityToken: string; + }; + publicKeyRequest: { + publicKey: { + algorithm: string; + content: string; + }; + proofOfPossession: string; + }; +} + +export interface SigningCertificateResponse { + signedCertificateEmbeddedSct?: { + chain: { certificates: string[] }; + }; + signedCertificateDetachedSct?: { + chain: { + certificates: string[]; + }; + signedCertificateTimestamp: string; + }; +} + +/** + * Fulcio API client. + */ +export class Fulcio { + private fetch: FetchInterface; + private baseUrl: string; + + constructor(options: FulcioOptions) { + this.fetch = fetch.defaults({ + retry: options.retry, + timeout: options.timeout, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': ua.getUserAgent(), + }, + }); + this.baseUrl = options.baseURL; + } + + public async createSigningCertificate( + request: SigningCertificateRequest + ): Promise { + const url = `${this.baseUrl}/api/v2/signingCert`; + + const response = await this.fetch(url, { + method: 'POST', + body: JSON.stringify(request), + }); + checkStatus(response); + + const data = await response.json(); + return data; + } +} diff --git a/packages/sign/src/external/rekor.ts b/packages/sign/src/external/rekor.ts new file mode 100644 index 00000000..30270845 --- /dev/null +++ b/packages/sign/src/external/rekor.ts @@ -0,0 +1,163 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import fetch, { FetchInterface } from 'make-fetch-happen'; +import { ua } from '../util'; +import { checkStatus } from './error'; + +import type { + LogEntry, + ProposedDSSEEntry, + ProposedEntry, + ProposedHashedRekordEntry, + ProposedIntotoEntry, + SearchIndex, + SearchLogQuery, +} from '@sigstore/rekor-types'; +import type { FetchOptions } from '../types/fetch'; + +export type { + ProposedDSSEEntry, + ProposedEntry, + ProposedHashedRekordEntry, + ProposedIntotoEntry, + SearchIndex, + SearchLogQuery, +}; + +// The LogEntry type from @sigstore/rekor-types is a Record type +// mapping the entry's UUID to the entry's data. This is really +// inconvenient to work with, so we define a new type here that +// flattens the data -- the entry's UUID is now a property of the +// entry's data. +export type Entry = { + uuid: string; +} & LogEntry[string]; + +// Client options +export type RekorOptions = { + baseURL: string; +} & FetchOptions; + +/** + * Rekor API client. + */ +export class Rekor { + private fetch: FetchInterface; + + private baseUrl: string; + constructor(options: RekorOptions) { + this.fetch = fetch.defaults({ + retry: options.retry, + timeout: options.timeout, + headers: { + Accept: 'application/json', + 'User-Agent': ua.getUserAgent(), + }, + }); + + this.baseUrl = options.baseURL; + } + + /** + * Create a new entry in the Rekor log. + * @param propsedEntry {ProposedEntry} Data to create a new entry + * @returns {Promise} The created entry + */ + public async createEntry(propsedEntry: ProposedEntry): Promise { + const url = `${this.baseUrl}/api/v1/log/entries`; + + const response = await this.fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(propsedEntry), + }); + checkStatus(response); + + const data = await response.json(); + return entryFromResponse(data); + } + + /** + * Get an entry from the Rekor log. + * @param uuid {string} The UUID of the entry to retrieve + * @returns {Promise} The retrieved entry + */ + public async getEntry(uuid: string): Promise { + const url = `${this.baseUrl}/api/v1/log/entries/${uuid}`; + + const response = await this.fetch(url); + checkStatus(response); + + const data: LogEntry = await response.json(); + return entryFromResponse(data); + } + + /** + * Search the Rekor log index for entries matching the given query. + * @param opts {SearchIndex} Options to search the Rekor log + * @returns {Promise} UUIDs of matching entries + */ + public async searchIndex(opts: SearchIndex): Promise { + const url = `${this.baseUrl}/api/v1/index/retrieve`; + + const response = await this.fetch(url, { + method: 'POST', + body: JSON.stringify(opts), + headers: { 'Content-Type': 'application/json' }, + }); + checkStatus(response); + + const data = await response.json(); + return data; + } + + /** + * Search the Rekor logs for matching the given query. + * @param opts {SearchLogQuery} Query to search the Rekor log + * @returns {Promise} List of matching entries + */ + public async searchLog(opts: SearchLogQuery): Promise { + const url = `${this.baseUrl}/api/v1/log/entries/retrieve`; + + const response = await this.fetch(url, { + method: 'POST', + body: JSON.stringify(opts), + headers: { 'Content-Type': 'application/json' }, + }); + checkStatus(response); + + const rawData: LogEntry[] = await response.json(); + const data = rawData.map((d) => entryFromResponse(d)); + return data; + } +} + +// Unpack the response from the Rekor API into a more convenient format. +function entryFromResponse(data: LogEntry): Entry { + const entries = Object.entries(data); + + if (entries.length != 1) { + throw new Error('Received multiple entries in Rekor response'); + } + + // Grab UUID and entry data from the response + const [uuid, entry] = entries[0]; + + return { + ...entry, + uuid, + }; +} diff --git a/packages/sign/src/external/tsa.ts b/packages/sign/src/external/tsa.ts new file mode 100644 index 00000000..6b6e9b67 --- /dev/null +++ b/packages/sign/src/external/tsa.ts @@ -0,0 +1,61 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import fetch, { FetchInterface } from 'make-fetch-happen'; +import { ua } from '../util'; +import { checkStatus } from './error'; + +import type { FetchOptions } from '../types/fetch'; + +export interface TimestampRequest { + artifactHash: string; + hashAlgorithm: string; + certificates?: boolean; + nonce?: number; + tsaPolicyOID?: string; +} + +export type TimestampAuthorityOptions = { + baseURL: string; +} & FetchOptions; + +export class TimestampAuthority { + private fetch: FetchInterface; + private baseUrl: string; + + constructor(options: TimestampAuthorityOptions) { + this.fetch = fetch.defaults({ + retry: options.retry, + timeout: options.timeout, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': ua.getUserAgent(), + }, + }); + this.baseUrl = options.baseURL; + } + + public async createTimestamp(request: TimestampRequest): Promise { + const url = `${this.baseUrl}/api/v1/timestamp`; + + const response = await this.fetch(url, { + method: 'POST', + body: JSON.stringify(request), + }); + checkStatus(response); + + return response.buffer(); + } +} diff --git a/packages/sign/src/identity/ci.ts b/packages/sign/src/identity/ci.ts new file mode 100644 index 00000000..196074e0 --- /dev/null +++ b/packages/sign/src/identity/ci.ts @@ -0,0 +1,85 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import fetch from 'make-fetch-happen'; +import { promise } from '../util'; +import type { IdentityProvider } from './provider'; + +type ProviderFunc = (audience: string) => Promise; + +// Collection of all the CI-specific providers we have implemented +const providers: ProviderFunc[] = [getGHAToken, getEnv]; + +/** + * CIContextProvider is a composite identity provider which will iterate + * over all of the CI-specific providers and return the token from the first + * one that resolves. + */ +export class CIContextProvider implements IdentityProvider { + private audience: string; + + /* istanbul ignore next */ + constructor(audience = 'sigstore') { + this.audience = audience; + } + + // Invoke all registered ProviderFuncs and return the value of whichever one + // resolves first. + public async getToken() { + return promise + .promiseAny(providers.map((getToken) => getToken(this.audience))) + .catch(() => Promise.reject('CI: no tokens available')); + } +} + +/** + * getGHAToken can retrieve an OIDC token when running in a GitHub Actions + * workflow + */ +async function getGHAToken(audience: string): Promise { + // Check to see if we're running in GitHub Actions + if ( + !process.env.ACTIONS_ID_TOKEN_REQUEST_URL || + !process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN + ) { + return Promise.reject('no token available'); + } + + // Construct URL to request token w/ appropriate audience + const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL); + url.searchParams.append('audience', audience); + + const response = await fetch(url.href, { + retry: 2, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`, + }, + }); + + return response.json().then((data) => data.value); +} + +/** + * getEnv can retrieve an OIDC token from an environment variable. + * This matches the behavior of https://github.com/sigstore/cosign/tree/main/pkg/providers/envvar + */ +async function getEnv(): Promise { + if (!process.env.SIGSTORE_ID_TOKEN) { + return Promise.reject('no token available'); + } + + return process.env.SIGSTORE_ID_TOKEN; +} diff --git a/packages/sign/src/identity/index.ts b/packages/sign/src/identity/index.ts new file mode 100644 index 00000000..65eefc8a --- /dev/null +++ b/packages/sign/src/identity/index.ts @@ -0,0 +1,17 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +export { CIContextProvider } from './ci'; +export type { IdentityProvider } from './provider'; diff --git a/packages/sign/src/identity/provider.ts b/packages/sign/src/identity/provider.ts new file mode 100644 index 00000000..ffec07c7 --- /dev/null +++ b/packages/sign/src/identity/provider.ts @@ -0,0 +1,20 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// Interface representing any identity provider which is capable of returning +// an OIDC token. +export interface IdentityProvider { + getToken: () => Promise; +} diff --git a/packages/sign/src/index.ts b/packages/sign/src/index.ts new file mode 100644 index 00000000..46ff84ea --- /dev/null +++ b/packages/sign/src/index.ts @@ -0,0 +1,16 @@ +export type { Bundle } from '@sigstore/bundle'; +export { DSSEBundleBuilder, MessageBundleBuilder } from './bundler'; +export type { Artifact, BundleBuilder, BundleBuilderOptions } from './bundler'; +export { InternalError } from './error'; +export { CIContextProvider } from './identity'; +export type { IdentityProvider } from './identity'; +export { FulcioSigner } from './signer'; +export type { FulcioSignerOptions, Signature, Signer } from './signer'; +export { RekorWitness, TSAWitness } from './witness'; +export type { + RekorWitnessOptions, + SignatureBundle, + TSAWitnessOptions, + VerificationMaterial, + Witness, +} from './witness'; diff --git a/packages/sign/src/signer/fulcio/ca.ts b/packages/sign/src/signer/fulcio/ca.ts new file mode 100644 index 00000000..8cb26c6c --- /dev/null +++ b/packages/sign/src/signer/fulcio/ca.ts @@ -0,0 +1,89 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { InternalError } from '../../error'; +import { Fulcio, SigningCertificateRequest } from '../../external/fulcio'; + +import type { FetchOptions } from '../../types/fetch'; + +export interface CA { + createSigningCertificate: ( + identityToken: string, + publicKey: string, + challenge: Buffer + ) => Promise; +} + +export type CAClientOptions = { + fulcioBaseURL: string; +} & FetchOptions; + +export class CAClient implements CA { + private fulcio: Fulcio; + + constructor(options: CAClientOptions) { + this.fulcio = new Fulcio({ + baseURL: options.fulcioBaseURL, + retry: options.retry, + timeout: options.timeout, + }); + } + + public async createSigningCertificate( + identityToken: string, + publicKey: string, + challenge: Buffer + ): Promise { + const request = toCertificateRequest(identityToken, publicKey, challenge); + + try { + const resp = await this.fulcio.createSigningCertificate(request); + + // Account for the fact that the response may contain either a + // signedCertificateEmbeddedSct or a signedCertificateDetachedSct. + const cert = resp.signedCertificateEmbeddedSct + ? resp.signedCertificateEmbeddedSct + : resp.signedCertificateDetachedSct; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return cert!.chain.certificates; + } catch (err) { + throw new InternalError({ + code: 'CA_CREATE_SIGNING_CERTIFICATE_ERROR', + message: 'error creating signing certificate', + cause: err, + }); + } + } +} + +function toCertificateRequest( + identityToken: string, + publicKey: string, + challenge: Buffer +): SigningCertificateRequest { + return { + credentials: { + oidcIdentityToken: identityToken, + }, + publicKeyRequest: { + publicKey: { + algorithm: 'ECDSA', + content: publicKey, + }, + proofOfPossession: challenge.toString('base64'), + }, + }; +} diff --git a/packages/sign/src/signer/fulcio/ephemeral.ts b/packages/sign/src/signer/fulcio/ephemeral.ts new file mode 100644 index 00000000..46cdf3d0 --- /dev/null +++ b/packages/sign/src/signer/fulcio/ephemeral.ts @@ -0,0 +1,46 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import crypto, { KeyPairKeyObjectResult } from 'crypto'; + +import type { Signature, Signer } from '../signer'; + +const EC_KEYPAIR_TYPE = 'ec'; +const P256_CURVE = 'P-256'; + +// Signer implementation which uses an ephemeral keypair to sign artifacts. +// The private key lives only in memory and is tied to the lifetime of the +// EphemeralSigner instance. +export class EphemeralSigner implements Signer { + private keypair: KeyPairKeyObjectResult; + + constructor() { + this.keypair = crypto.generateKeyPairSync(EC_KEYPAIR_TYPE, { + namedCurve: P256_CURVE, + }); + } + + public async sign(data: Buffer): Promise { + const signature = crypto.sign(null, data, this.keypair.privateKey); + const publicKey = this.keypair.publicKey + .export({ format: 'pem', type: 'spki' }) + .toString('ascii'); + + return { + signature: signature, + key: { $case: 'publicKey', publicKey }, + }; + } +} diff --git a/packages/sign/src/signer/fulcio/index.ts b/packages/sign/src/signer/fulcio/index.ts new file mode 100644 index 00000000..83f4b5d0 --- /dev/null +++ b/packages/sign/src/signer/fulcio/index.ts @@ -0,0 +1,94 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { InternalError } from '../../error'; +import { oidc } from '../../util'; +import { CA, CAClient } from './ca'; +import { EphemeralSigner } from './ephemeral'; + +import type { IdentityProvider } from '../../identity'; +import type { Signature, Signer } from '../signer'; + +export interface FulcioSignerOptions { + fulcioBaseURL: string; + identityProvider: IdentityProvider; + keyHolder?: Signer; +} + +// Signer implementation which can be used to decorate another signer +// with a Fulcio-issued signing certificate for the signer's public key. +// Must be instantiated with an identity provider which can provide a JWT +// which represents the identity to be bound to the signing certificate. +export class FulcioSigner implements Signer { + private ca: CA; + private identityProvider: IdentityProvider; + private keyHolder: Signer; + + constructor(options: FulcioSignerOptions) { + this.ca = new CAClient(options); + this.identityProvider = options.identityProvider; + this.keyHolder = options.keyHolder || new EphemeralSigner(); + } + + public async sign(data: Buffer): Promise { + // Retrieve identity token from the supplied identity provider + const identityToken = await this.getIdentityToken(); + + // Extract challenge claim from OIDC token + const subject = oidc.extractJWTSubject(identityToken); + + // Construct challenge value by signing the subject claim + const challenge = await this.keyHolder.sign(Buffer.from(subject)); + + if (challenge.key.$case !== 'publicKey') { + throw new InternalError({ + code: 'CA_CREATE_SIGNING_CERTIFICATE_ERROR', + message: 'unexpected format for signing key', + }); + } + + // Create signing certificate + const certificates = await this.ca.createSigningCertificate( + identityToken, + challenge.key.publicKey, + challenge.signature + ); + + // Generate artifact signature + const signature = await this.keyHolder.sign(data); + + // Specifically returning only the first certificate in the chain + // as the key. + return { + signature: signature.signature, + key: { + $case: 'x509Certificate', + certificate: certificates[0], + }, + }; + } + + private async getIdentityToken(): Promise { + try { + return await this.identityProvider.getToken(); + } catch (err) { + throw new InternalError({ + code: 'IDENTITY_TOKEN_READ_ERROR', + message: 'error retrieving identity token', + cause: err, + }); + } + } +} diff --git a/packages/sign/src/signer/index.ts b/packages/sign/src/signer/index.ts new file mode 100644 index 00000000..f1118f5a --- /dev/null +++ b/packages/sign/src/signer/index.ts @@ -0,0 +1,17 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +export { FulcioSigner, FulcioSignerOptions } from './fulcio'; +export type { KeyMaterial, Signature, Signer } from './signer'; diff --git a/packages/sign/src/signer/signer.ts b/packages/sign/src/signer/signer.ts new file mode 100644 index 00000000..ba7c5347 --- /dev/null +++ b/packages/sign/src/signer/signer.ts @@ -0,0 +1,43 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// KeyMaterial is a union type representing either a public key or an X.509 +// certificate. +export type KeyMaterial = + | { + $case: 'x509Certificate'; + certificate: string; + } + | { + $case: 'publicKey'; + publicKey: string; + hint?: string; + }; + +// The Signature returned by a Signer. Includes the signature and the key +// material (either a public key or an X.509 certificate) which can be used to +// verify the signature. +export type Signature = { + signature: Buffer; + key: KeyMaterial; +}; + +// A Signer is responsible for generating a signature for the given blob +// of data. The signature is returned as an Signature, which also includes +// the key material used for verification. +export interface Signer { + sign: (data: Buffer) => Promise; +} diff --git a/packages/sign/src/types/fetch.ts b/packages/sign/src/types/fetch.ts new file mode 100644 index 00000000..06ad3e3e --- /dev/null +++ b/packages/sign/src/types/fetch.ts @@ -0,0 +1,23 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import type { MakeFetchHappenOptions } from 'make-fetch-happen'; + +export type Retry = MakeFetchHappenOptions['retry']; + +export type FetchOptions = { + retry?: Retry; + timeout?: number | undefined; +}; diff --git a/packages/sign/src/util/crypto.ts b/packages/sign/src/util/crypto.ts new file mode 100644 index 00000000..1c084d88 --- /dev/null +++ b/packages/sign/src/util/crypto.ts @@ -0,0 +1,22 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import crypto, { BinaryLike } from 'crypto'; + +const SHA256_ALGORITHM = 'sha256'; + +export function hash(data: BinaryLike, algorithm = SHA256_ALGORITHM): Buffer { + return crypto.createHash(algorithm).update(data).digest(); +} diff --git a/packages/sign/src/util/dsse.ts b/packages/sign/src/util/dsse.ts new file mode 100644 index 00000000..700806e4 --- /dev/null +++ b/packages/sign/src/util/dsse.ts @@ -0,0 +1,25 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const PAE_PREFIX = 'DSSEv1'; + +// DSSE Pre-Authentication Encoding +export function preAuthEncoding(payloadType: string, payload: Buffer): Buffer { + const prefix = Buffer.from( + `${PAE_PREFIX} ${payloadType.length} ${payloadType} ${payload.length} `, + 'ascii' + ); + return Buffer.concat([prefix, payload]); +} diff --git a/packages/sign/src/util/encoding.ts b/packages/sign/src/util/encoding.ts new file mode 100644 index 00000000..d94874a4 --- /dev/null +++ b/packages/sign/src/util/encoding.ts @@ -0,0 +1,25 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const BASE64_ENCODING = 'base64'; +const UTF8_ENCODING = 'utf-8'; + +export function base64Encode(str: string): string { + return Buffer.from(str, UTF8_ENCODING).toString(BASE64_ENCODING); +} + +export function base64Decode(str: string): string { + return Buffer.from(str, BASE64_ENCODING).toString(UTF8_ENCODING); +} diff --git a/packages/sign/src/util/index.ts b/packages/sign/src/util/index.ts new file mode 100644 index 00000000..491f8be3 --- /dev/null +++ b/packages/sign/src/util/index.ts @@ -0,0 +1,23 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +export * as crypto from './crypto'; +export * as dsse from './dsse'; +export * as encoding from './encoding'; +export * as json from './json'; +export * as oidc from './oidc'; +export * as pem from './pem'; +export * as promise from './promise'; +export * as ua from './ua'; diff --git a/packages/sign/src/util/json.ts b/packages/sign/src/util/json.ts new file mode 100644 index 00000000..1ff889c4 --- /dev/null +++ b/packages/sign/src/util/json.ts @@ -0,0 +1,57 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +// JSON canonicalization per https://github.com/cyberphone/json-canonicalization +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function canonicalize(object: any): string { + let buffer = ''; + + if (object === null || typeof object !== 'object' || object.toJSON != null) { + // Primitives or toJSONable objects + buffer += JSON.stringify(object); + } else if (Array.isArray(object)) { + // Array - maintain element order + buffer += '['; + let first = true; + object.forEach((element) => { + if (!first) { + buffer += ','; + } + first = false; + // recursive call + buffer += canonicalize(element); + }); + buffer += ']'; + } else { + // Object - Sort properties before serializing + buffer += '{'; + let first = true; + Object.keys(object) + .sort() + .forEach((property) => { + if (!first) { + buffer += ','; + } + first = false; + buffer += JSON.stringify(property); + buffer += ':'; + // recursive call + buffer += canonicalize(object[property]); + }); + buffer += '}'; + } + + return buffer; +} diff --git a/packages/sign/src/util/oidc.ts b/packages/sign/src/util/oidc.ts new file mode 100644 index 00000000..660e50fc --- /dev/null +++ b/packages/sign/src/util/oidc.ts @@ -0,0 +1,35 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import * as enc from './encoding'; + +type JWTSubject = { + iss: string; + sub: string; + email: string; +}; + +export function extractJWTSubject(jwt: string): string { + const parts = jwt.split('.', 3); + const payload: JWTSubject = JSON.parse(enc.base64Decode(parts[1])); + + switch (payload.iss) { + case 'https://accounts.google.com': + case 'https://oauth2.sigstore.dev/auth': + return payload.email; + default: + return payload.sub; + } +} diff --git a/packages/sign/src/util/pem.ts b/packages/sign/src/util/pem.ts new file mode 100644 index 00000000..7358b9b3 --- /dev/null +++ b/packages/sign/src/util/pem.ts @@ -0,0 +1,27 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const PEM_HEADER = /-----BEGIN (.*)-----/; +const PEM_FOOTER = /-----END (.*)-----/; + +export function toDER(certificate: string): Buffer { + const lines = certificate + .split('\n') + .map((line) => + line.match(PEM_HEADER) || line.match(PEM_FOOTER) ? '' : line + ); + + return Buffer.from(lines.join(''), 'base64'); +} diff --git a/packages/sign/src/util/promise.ts b/packages/sign/src/util/promise.ts new file mode 100644 index 00000000..83bb6c18 --- /dev/null +++ b/packages/sign/src/util/promise.ts @@ -0,0 +1,34 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Implementation of Promise.any (not available until Node v15). +// We're basically inverting the logic of Promise.all and taking advantage +// of the fact that Promise.all will return early on the first rejection. +// By reversing the resolve/reject logic we can use this to return early +// on the first resolved promise. +export const promiseAny = async ( + values: Iterable> +): Promise => { + return Promise.all( + [...values].map( + (promise) => + new Promise((resolve, reject) => promise.then(reject, resolve)) + ) + ).then( + (errors) => Promise.reject(errors), + (value) => Promise.resolve(value) + ); +}; diff --git a/packages/sign/src/util/ua.ts b/packages/sign/src/util/ua.ts new file mode 100644 index 00000000..3d343845 --- /dev/null +++ b/packages/sign/src/util/ua.ts @@ -0,0 +1,27 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import os from 'os'; + +// Format User-Agent: / () +// source: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent +export const getUserAgent = (): string => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const packageVersion = require('../../package.json').version; + const nodeVersion = process.version; + const platformName = os.platform(); + const archName = os.arch(); + return `sigstore-js/${packageVersion} (Node ${nodeVersion}) (${platformName}/${archName})`; +}; diff --git a/packages/sign/src/witness/index.ts b/packages/sign/src/witness/index.ts new file mode 100644 index 00000000..fb5f4a68 --- /dev/null +++ b/packages/sign/src/witness/index.ts @@ -0,0 +1,18 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +export { RekorWitness, RekorWitnessOptions } from './tlog'; +export { TSAWitness, TSAWitnessOptions } from './tsa'; +export type { SignatureBundle, VerificationMaterial, Witness } from './witness'; diff --git a/packages/sign/src/witness/tlog/client.ts b/packages/sign/src/witness/tlog/client.ts new file mode 100644 index 00000000..eb9aaff5 --- /dev/null +++ b/packages/sign/src/witness/tlog/client.ts @@ -0,0 +1,88 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { InternalError } from '../../error'; +import { HTTPError } from '../../external/error'; +import { Rekor } from '../../external/rekor'; + +import type { Entry, ProposedEntry } from '../../external/rekor'; +import type { FetchOptions } from '../../types/fetch'; + +export type { Entry, ProposedEntry }; + +export interface TLog { + createEntry: (proposedEntry: ProposedEntry) => Promise; +} + +export type TLogClientOptions = { + rekorBaseURL: string; + fetchOnConflict?: boolean; +} & FetchOptions; + +export class TLogClient implements TLog { + private rekor: Rekor; + private fetchOnConflict: boolean; + + constructor(options: TLogClientOptions) { + this.fetchOnConflict = options.fetchOnConflict ?? false; + this.rekor = new Rekor({ + baseURL: options.rekorBaseURL, + retry: options.retry, + timeout: options.timeout, + }); + } + + public async createEntry(proposedEntry: ProposedEntry): Promise { + let entry: Entry; + + try { + entry = await this.rekor.createEntry(proposedEntry); + } catch (err) { + // If the entry already exists, fetch it (if enabled) + if (entryExistsError(err) && this.fetchOnConflict) { + // Grab the UUID of the existing entry from the location header + /* istanbul ignore next */ + const uuid = err.location.split('/').pop() || ''; + try { + entry = await this.rekor.getEntry(uuid); + } catch (err) { + throw new InternalError({ + code: 'TLOG_FETCH_ENTRY_ERROR', + message: 'error fetching tlog entry', + cause: err, + }); + } + } else { + throw new InternalError({ + code: 'TLOG_CREATE_ENTRY_ERROR', + message: 'error creating tlog entry', + cause: err, + }); + } + } + + return entry; + } +} + +function entryExistsError( + value: unknown +): value is HTTPError & { location: string } { + return ( + value instanceof HTTPError && + value.statusCode === 409 && + value.location !== undefined + ); +} diff --git a/packages/sign/src/witness/tlog/entry.ts b/packages/sign/src/witness/tlog/entry.ts new file mode 100644 index 00000000..61e17075 --- /dev/null +++ b/packages/sign/src/witness/tlog/entry.ts @@ -0,0 +1,166 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { Envelope, MessageSignature, envelopeToJSON } from '@sigstore/bundle'; +import { crypto, encoding as enc, json } from '../../util'; + +import type { + ProposedDSSEEntry, + ProposedEntry, + ProposedHashedRekordEntry, + ProposedIntotoEntry, +} from '../../external/rekor'; +import type { SignatureBundle } from '../witness'; + +export function toProposedEntry( + content: SignatureBundle, + publicKey: string, + // TODO: Remove this parameter once have completely switched to 'dsse' entries + entryType: 'dsse' | 'intoto' = 'intoto' +): ProposedEntry { + switch (content.$case) { + case 'dsseEnvelope': + // TODO: Remove this conditional once have completely switched to 'dsse' entries + if (entryType === 'dsse') { + return toProposedDSSEEntry(content.dsseEnvelope, publicKey); + } + return toProposedIntotoEntry(content.dsseEnvelope, publicKey); + case 'messageSignature': + return toProposedHashedRekordEntry(content.messageSignature, publicKey); + } +} + +// Returns a properly formatted Rekor "hashedrekord" entry for the given digest +// and signature +function toProposedHashedRekordEntry( + messageSignature: MessageSignature, + publicKey: string +): ProposedHashedRekordEntry { + const hexDigest = messageSignature.messageDigest.digest.toString('hex'); + const b64Signature = messageSignature.signature.toString('base64'); + const b64Key = enc.base64Encode(publicKey); + + return { + apiVersion: '0.0.1', + kind: 'hashedrekord', + spec: { + data: { + hash: { + algorithm: 'sha256', + value: hexDigest, + }, + }, + signature: { + content: b64Signature, + publicKey: { + content: b64Key, + }, + }, + }, + }; +} + +// Returns a properly formatted Rekor "dsse" entry for the given DSSE envelope +// and signature +function toProposedDSSEEntry( + envelope: Envelope, + publicKey: string +): ProposedDSSEEntry { + const envelopeJSON = JSON.stringify(envelopeToJSON(envelope)); + const encodedKey = enc.base64Encode(publicKey); + + return { + apiVersion: '0.0.1', + kind: 'dsse', + spec: { + proposedContent: { + envelope: envelopeJSON, + verifiers: [encodedKey], + }, + }, + }; +} + +// Returns a properly formatted Rekor "intoto" entry for the given DSSE +// envelope and signature +function toProposedIntotoEntry( + envelope: Envelope, + publicKey: string +): ProposedIntotoEntry { + // Calculate the value for the payloadHash field in the Rekor entry + const payloadHash = crypto.hash(envelope.payload).toString('hex'); + + // Calculate the value for the hash field in the Rekor entry + const envelopeHash = calculateDSSEHash(envelope, publicKey); + + // Collect values for re-creating the DSSE envelope. + // Double-encode payload and signature cause that's what Rekor expects + const payload = enc.base64Encode(envelope.payload.toString('base64')); + const sig = enc.base64Encode(envelope.signatures[0].sig.toString('base64')); + const keyid = envelope.signatures[0].keyid; + const encodedKey = enc.base64Encode(publicKey); + + // Create the envelope portion of the entry. Note the inclusion of the + // publicKey in the signature struct is not a standard part of a DSSE + // envelope, but is required by Rekor. + const dsse: ProposedIntotoEntry['spec']['content']['envelope'] = { + payloadType: envelope.payloadType, + payload: payload, + signatures: [{ sig, publicKey: encodedKey }], + }; + + // If the keyid is an empty string, Rekor seems to remove it altogether. We + // need to do the same here so that we can properly recreate the entry for + // verification. + if (keyid.length > 0) { + dsse.signatures[0].keyid = keyid; + } + + return { + apiVersion: '0.0.2', + kind: 'intoto', + spec: { + content: { + envelope: dsse, + hash: { algorithm: 'sha256', value: envelopeHash }, + payloadHash: { algorithm: 'sha256', value: payloadHash }, + }, + }, + }; +} + +// Calculates the hash of a DSSE envelope for inclusion in a Rekor entry. +// There is no standard way to do this, so the scheme we're using as as +// follows: +// * payload is base64 encoded +// * signature is base64 encoded (only the first signature is used) +// * keyid is included ONLY if it is NOT an empty string +// * The resulting JSON is canonicalized and hashed to a hex string +function calculateDSSEHash(envelope: Envelope, publicKey: string): string { + const dsse: ProposedIntotoEntry['spec']['content']['envelope'] = { + payloadType: envelope.payloadType, + payload: envelope.payload.toString('base64'), + signatures: [ + { sig: envelope.signatures[0].sig.toString('base64'), publicKey }, + ], + }; + + // If the keyid is an empty string, Rekor seems to remove it altogether. + if (envelope.signatures[0].keyid.length > 0) { + dsse.signatures[0].keyid = envelope.signatures[0].keyid; + } + + return crypto.hash(json.canonicalize(dsse)).toString('hex'); +} diff --git a/packages/sign/src/witness/tlog/index.ts b/packages/sign/src/witness/tlog/index.ts new file mode 100644 index 00000000..1947e47a --- /dev/null +++ b/packages/sign/src/witness/tlog/index.ts @@ -0,0 +1,109 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { encoding as enc } from '../../util'; +import { + Entry, + ProposedEntry, + TLog, + TLogClient, + TLogClientOptions, +} from './client'; +import { toProposedEntry } from './entry'; + +import type { TransparencyLogEntry } from '@sigstore/bundle'; +import type { + SignatureBundle, + VerificationMaterial, + Witness, +} from '../witness'; + +export type RekorWitnessOptions = TLogClientOptions; + +export class RekorWitness implements Witness { + private tlog: TLog; + + constructor(options: RekorWitnessOptions) { + this.tlog = new TLogClient(options); + } + + public async testify( + content: SignatureBundle, + publicKey: string + ): Promise { + const proposedEntry = toProposedEntry(content, publicKey); + const entry = await this.tlog.createEntry(proposedEntry); + return toTransparencyLogEntry(entry); + } +} + +function toTransparencyLogEntry(entry: Entry): VerificationMaterial { + const logID = Buffer.from(entry.logID, 'hex'); + + // Parse entry body so we can extract the kind and version. + const bodyJSON = enc.base64Decode(entry.body); + const entryBody: ProposedEntry = JSON.parse(bodyJSON); + + const promise = entry?.verification?.signedEntryTimestamp + ? inclusionPromise(entry.verification.signedEntryTimestamp) + : undefined; + + const proof = entry?.verification?.inclusionProof + ? inclusionProof(entry.verification.inclusionProof) + : undefined; + + const tlogEntry: TransparencyLogEntry = { + logIndex: entry.logIndex.toString(), + logId: { + keyId: logID, + }, + integratedTime: entry.integratedTime.toString(), + kindVersion: { + kind: entryBody.kind, + version: entryBody.apiVersion, + }, + inclusionPromise: promise, + inclusionProof: proof, + canonicalizedBody: Buffer.from(entry.body, 'base64'), + }; + + return { + tlogEntries: [tlogEntry], + }; +} + +function inclusionPromise( + promise: NonNullable< + NonNullable['signedEntryTimestamp'] + > +): TransparencyLogEntry['inclusionPromise'] { + return { + signedEntryTimestamp: Buffer.from(promise, 'base64'), + }; +} + +function inclusionProof( + proof: NonNullable['inclusionProof']> +): TransparencyLogEntry['inclusionProof'] { + return { + logIndex: proof.logIndex.toString(), + treeSize: proof.treeSize.toString(), + rootHash: Buffer.from(proof.rootHash, 'hex'), + hashes: proof.hashes.map((h) => Buffer.from(h, 'hex')), + checkpoint: { + envelope: proof.checkpoint, + }, + }; +} diff --git a/packages/sign/src/witness/tsa/client.ts b/packages/sign/src/witness/tsa/client.ts new file mode 100644 index 00000000..04b1aaa5 --- /dev/null +++ b/packages/sign/src/witness/tsa/client.ts @@ -0,0 +1,57 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { InternalError } from '../../error'; +import { TimestampAuthority } from '../../external/tsa'; +import { crypto } from '../../util'; + +import type { FetchOptions } from '../../types/fetch'; + +export interface TSA { + createTimestamp: (signature: Buffer) => Promise; +} + +export type TSAClientOptions = { + tsaBaseURL: string; +} & FetchOptions; + +export class TSAClient implements TSA { + private tsa: TimestampAuthority; + + constructor(options: TSAClientOptions) { + this.tsa = new TimestampAuthority({ + baseURL: options.tsaBaseURL, + retry: options.retry, + timeout: options.timeout, + }); + } + + public async createTimestamp(signature: Buffer): Promise { + const request = { + artifactHash: crypto.hash(signature).toString('base64'), + hashAlgorithm: 'sha256', + }; + + try { + return await this.tsa.createTimestamp(request); + } catch (err) { + throw new InternalError({ + code: 'TSA_CREATE_TIMESTAMP_ERROR', + message: 'error creating timestamp', + cause: err, + }); + } + } +} diff --git a/packages/sign/src/witness/tsa/index.ts b/packages/sign/src/witness/tsa/index.ts new file mode 100644 index 00000000..5aaef4a4 --- /dev/null +++ b/packages/sign/src/witness/tsa/index.ts @@ -0,0 +1,56 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { TSA, TSAClient, TSAClientOptions } from './client'; + +import type { + SignatureBundle, + VerificationMaterial, + Witness, +} from '../witness'; + +export type TSAWitnessOptions = TSAClientOptions; + +export class TSAWitness implements Witness { + private tsa: TSA; + + constructor(options: TSAWitnessOptions) { + this.tsa = new TSAClient({ + tsaBaseURL: options.tsaBaseURL, + retry: options.retry, + timeout: options.timeout, + }); + } + + public async testify( + content: SignatureBundle + ): Promise { + const signature = extractSignature(content); + const timestamp = await this.tsa.createTimestamp(signature); + + return { + rfc3161Timestamps: [{ signedTimestamp: timestamp }], + }; + } +} + +function extractSignature(content: SignatureBundle) { + switch (content.$case) { + case 'dsseEnvelope': + return content.dsseEnvelope.signatures[0].sig; + case 'messageSignature': + return content.messageSignature.signature; + } +} diff --git a/packages/sign/src/witness/witness.ts b/packages/sign/src/witness/witness.ts new file mode 100644 index 00000000..b81b8444 --- /dev/null +++ b/packages/sign/src/witness/witness.ts @@ -0,0 +1,36 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import type { + Bundle, + RFC3161SignedTimestamp, + TransparencyLogEntry, +} from '@sigstore/bundle'; + +// Sigstore bundle with required content fields populated +export type SignatureBundle = Bundle['content']; + +// Collection of transparency log entries and/or RFC3161 timestamps +export type VerificationMaterial = { + tlogEntries?: TransparencyLogEntry[]; + rfc3161Timestamps?: RFC3161SignedTimestamp[]; +}; + +export interface Witness { + testify: ( + signature: SignatureBundle, + publicKey: string + ) => Promise; +} diff --git a/packages/sign/tsconfig.json b/packages/sign/tsconfig.json new file mode 100644 index 00000000..f8502817 --- /dev/null +++ b/packages/sign/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "exclude": [ + "./dist", + "**/__tests__", + "jest.setup.ts" + ], + "references": [ + { "path": "../bundle" }, + { "path": "../mock" }, + { "path": "../rekor-types" }, + { "path": "../jest" } + ] +}