diff --git a/.changeset/angry-hands-notice.md b/.changeset/angry-hands-notice.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/angry-hands-notice.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/sign/src/__tests__/external/rekor-v2.test.ts b/packages/sign/src/__tests__/external/rekor-v2.test.ts index 9b2c94184..e25f9f246 100644 --- a/packages/sign/src/__tests__/external/rekor-v2.test.ts +++ b/packages/sign/src/__tests__/external/rekor-v2.test.ts @@ -116,7 +116,7 @@ describe('RekorV2', () => { expect(typeof result.integratedTime).toBe('string'); expect(result.kindVersion).toBeDefined(); expect(result.kindVersion?.kind).toBe('hashedrekord'); - expect(result.inclusionPromise).toBeDefined(); + expect(result.inclusionPromise).toBeUndefined(); }); }); diff --git a/packages/sign/src/__tests__/witness/tlog/client.test.ts b/packages/sign/src/__tests__/witness/tlog/client.test.ts index 98309d1ee..241feceb3 100644 --- a/packages/sign/src/__tests__/witness/tlog/client.test.ts +++ b/packages/sign/src/__tests__/witness/tlog/client.test.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Sigstore Authors. +Copyright 2025 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. @@ -15,7 +15,14 @@ limitations under the License. */ import nock from 'nock'; import { InternalError } from '../../../error'; -import { ProposedEntry, TLogClient } from '../../../witness/tlog/client'; +import { + ProposedEntry, + TLogClient, + TLogV2Client, +} from '../../../witness/tlog/client'; + +import type { CreateEntryRequest } from '@sigstore/protobuf-specs/rekor/v2'; +import assert from 'assert'; describe('TLogClient', () => { const rekorBaseURL = 'http://localhost:8080'; @@ -194,3 +201,202 @@ describe('TLogClient', () => { }); }); }); + +describe('TLogV2Client', () => { + const rekorBaseURL = 'http://localhost:8080'; + + describe('constructor', () => { + it('should create a new instance', () => { + const client = new TLogV2Client({ rekorBaseURL }); + expect(client).toBeDefined(); + }); + }); + + describe('createEntry', () => { + const subject = new TLogV2Client({ rekorBaseURL, retry: false }); + + const createEntryRequest: CreateEntryRequest = { + spec: { + $case: 'hashedRekordRequestV002', + hashedRekordRequestV002: { + digest: Buffer.from('digest'), + signature: { + content: Buffer.from('signature'), + verifier: { + keyDetails: 5, // PKIX_ECDSA_P256_SHA_256 + verifier: { + $case: 'x509Certificate', + x509Certificate: { + rawBytes: Buffer.from('certificate'), + }, + }, + }, + }, + }, + }, + }; + + const rekorEntry = { + logIndex: '2513258', + logId: { + keyId: 'zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=', + }, + kindVersion: { + kind: 'hashedrekord', + version: '0.0.2', + }, + integratedTime: '0', + inclusionPromise: null, + inclusionProof: { + logIndex: '2513258', + rootHash: '+8vUkEgBK/ansexBUomzocaWoPEmPIzxJC/y+xNMQN4=', + treeSize: '412115', + hashes: [ + 'KIoYVJ0TqmaEkFboP7YWTjSh8vFjVECmokcTOAByfIM=', + 'Umf0h0cK2hegTzNnSgsXszyiA4bp5OvEvP+GrWq3C8w=', + 'vNQeSNBfepYZI2Ez3ViKdCft0JH87ZS8IGKwixxUVjc=', + 'JBAugd5awOqmHXIKgz1MOjlR5f37VqmP0bWoRVcHX5M=', + 'xYH2mAxGxfOgvSOLnItT2LsJt+Z2a2egjf8QJFwK7jA=', + 'PbZWM3NitzChx9A22m/kddDtzh2bAKX5Fy7j76l3z2k=', + 'sKX9Sbsahvw5DiC2oP6pbZsDi1NzuNS1nIULXkCC57o=', + 'pfTCxXHnCM253jwYxVJcuUhmoTTtDxznn92QhN0M4Ws=', + 'cJ8uk2ZvZyfg+HRILKOHcyHu2pvI8Fz3R1MyMvyzWtA=', + ], + checkpoint: { + envelope: + 'log2025-1.rekor.sigstore.dev\n412115\n+8vUkEgBK/ansexBUomzocaWoPEmPIzxJC/y+xNMQN4=\n\n— log2025-1.rekor.sigstore.dev zxGZFahCZ/+MqTjH4rC5MWcdLDWbpetE5l30RZfQc4BQkRjWSoKipEUPjvHENeZDHIlAsuezJcLzUVvItpNjaSRoMAs=\n', + }, + }, + canonicalizedBody: Buffer.from( + JSON.stringify(createEntryRequest) + ).toString('base64'), + }; + + describe('when Rekor returns an error', () => { + beforeEach(() => { + nock(rekorBaseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v2/log/entries') + .reply(500, {}); + }); + + it('returns an error', async () => { + await expect( + subject.createEntry(createEntryRequest) + ).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/v2/log/entries') + .reply(201, rekorEntry); + }); + + it('returns a tlog entry', async () => { + const entry = await subject.createEntry(createEntryRequest); + + expect(entry.logIndex).toEqual(rekorEntry.logIndex); + expect(entry.logId.keyId).toEqual( + Buffer.from(rekorEntry.logId.keyId, 'base64') + ); + expect(entry.integratedTime).toEqual(rekorEntry.integratedTime); + expect(entry.kindVersion).toEqual(rekorEntry.kindVersion); + expect(entry.inclusionPromise).toBeUndefined(); + assert(entry.inclusionProof); + expect(entry.inclusionProof.logIndex).toEqual( + rekorEntry.inclusionProof.logIndex + ); + expect(entry.inclusionProof.rootHash).toEqual( + Buffer.from(rekorEntry.inclusionProof.rootHash, 'base64') + ); + expect(entry.inclusionProof.treeSize).toEqual( + rekorEntry.inclusionProof.treeSize + ); + expect(entry.inclusionProof.hashes).toEqual( + rekorEntry.inclusionProof.hashes.map((h: string) => + Buffer.from(h, 'base64') + ) + ); + assert(entry.inclusionProof.checkpoint); + expect(entry.inclusionProof.checkpoint.envelope).toEqual( + rekorEntry.inclusionProof.checkpoint.envelope + ); + expect(entry.canonicalizedBody).toEqual( + Buffer.from(rekorEntry.canonicalizedBody, 'base64') + ); + }); + }); + + describe('when Rekor returns an incomplete response', () => { + describe('when logId is missing', () => { + const incompleteEntry = { + ...rekorEntry, + logId: undefined, + }; + + beforeEach(() => { + nock(rekorBaseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v2/log/entries') + .reply(201, incompleteEntry); + }); + + it('returns an error', async () => { + await expect( + subject.createEntry(createEntryRequest) + ).rejects.toThrowWithCode(InternalError, 'TLOG_CREATE_ENTRY_ERROR'); + }); + }); + + describe('when kindVersion is missing', () => { + const incompleteEntry = { + ...rekorEntry, + kindVersion: undefined, + }; + + beforeEach(() => { + nock(rekorBaseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v2/log/entries') + .reply(201, incompleteEntry); + }); + + it('returns an error', async () => { + await expect( + subject.createEntry(createEntryRequest) + ).rejects.toThrowWithCode(InternalError, 'TLOG_CREATE_ENTRY_ERROR'); + }); + }); + }); + + describe('when Rekor returns a valid response after retry', () => { + let scope: nock.Scope; + + beforeEach(() => { + scope = nock(rekorBaseURL) + .post('/api/v2/log/entries') + .reply(500, { message: 'oops' }) + .post('/api/v2/log/entries') + .reply(201, rekorEntry); + }); + + it('returns a tlog entry', async () => { + const subject = new TLogV2Client({ + rekorBaseURL, + retry: { retries: 1, factor: 0, minTimeout: 1, maxTimeout: 1 }, + }); + const entry = await subject.createEntry(createEntryRequest); + + expect(entry.logIndex).toEqual(rekorEntry.logIndex); + expect(scope.isDone()).toBe(true); + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/witness/tlog/entry.test.ts b/packages/sign/src/__tests__/witness/tlog/entry.test.ts index 13e7fa6b9..1a9001b8a 100644 --- a/packages/sign/src/__tests__/witness/tlog/entry.test.ts +++ b/packages/sign/src/__tests__/witness/tlog/entry.test.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Sigstore Authors. +Copyright 2025 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. @@ -14,10 +14,13 @@ 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 { HashAlgorithm, PublicKeyDetails } from '@sigstore/protobuf-specs'; import assert from 'assert'; -import { crypto, encoding as enc } from '../../../util'; -import { toProposedEntry } from '../../../witness/tlog/entry'; +import { crypto, encoding as enc, pem } from '../../../util'; +import { + toCreateEntryRequest, + toProposedEntry, +} from '../../../witness/tlog/entry'; import type { SignatureBundle } from '../../../witness'; @@ -234,3 +237,137 @@ describe('toProposedEntry', () => { }); }); }); + +describe('toCreateEntryRequest', () => { + 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 CreateEntryRequest with hashedRekordRequestV002', () => { + const request = toCreateEntryRequest(sigBundle, publicKey); + + expect(request.spec).toBeTruthy(); + expect(request.spec?.$case).toBe('hashedRekordRequestV002'); + + if (request.spec?.$case === 'hashedRekordRequestV002') { + const hashedRekord = request.spec.hashedRekordRequestV002; + + // Check digest + expect(hashedRekord.digest).toEqual( + sigBundle.messageSignature.messageDigest.digest + ); + + // Check signature content + expect(hashedRekord.signature).toBeTruthy(); + assert(hashedRekord.signature); + expect(hashedRekord.signature.content).toEqual( + sigBundle.messageSignature.signature + ); + + // Check verifier + expect(hashedRekord.signature.verifier).toBeTruthy(); + assert(hashedRekord.signature.verifier); + expect(hashedRekord.signature.verifier.keyDetails).toBe( + PublicKeyDetails.PKIX_ECDSA_P256_SHA_256 + ); + expect(hashedRekord.signature.verifier.verifier?.$case).toBe( + 'x509Certificate' + ); + + if ( + hashedRekord.signature.verifier.verifier?.$case === 'x509Certificate' + ) { + expect( + hashedRekord.signature.verifier.verifier.x509Certificate.rawBytes + ).toEqual(Buffer.from(publicKey, 'base64')); + } + } + }); + }); + + 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('returns a valid CreateEntryRequest with dsseRequestV002', () => { + const request = toCreateEntryRequest(sigBundle, publicKey); + + expect(request.spec).toBeTruthy(); + expect(request.spec?.$case).toBe('dsseRequestV002'); + + if (request.spec?.$case === 'dsseRequestV002') { + const dsseRequest = request.spec.dsseRequestV002; + + // Check envelope + expect(dsseRequest.envelope).toEqual(sigBundle.dsseEnvelope); + + // Check verifiers array + expect(dsseRequest.verifiers).toHaveLength(1); + const verifier = dsseRequest.verifiers[0]; + + expect(verifier.keyDetails).toBe( + PublicKeyDetails.PKIX_ECDSA_P256_SHA_256 + ); + expect(verifier.verifier?.$case).toBe('x509Certificate'); + + if (verifier.verifier?.$case === 'x509Certificate') { + expect(verifier.verifier.x509Certificate.rawBytes).toEqual( + pem.toDER(publicKey) + ); + } + } + }); + }); + + 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('returns a valid CreateEntryRequest with dsseRequestV002', () => { + const request = toCreateEntryRequest(sigBundle, publicKey); + + expect(request.spec).toBeTruthy(); + expect(request.spec?.$case).toBe('dsseRequestV002'); + + if (request.spec?.$case === 'dsseRequestV002') { + const dsseRequest = request.spec.dsseRequestV002; + + // Check envelope is preserved exactly + assert(dsseRequest.envelope); + expect(dsseRequest.envelope).toEqual(sigBundle.dsseEnvelope); + expect(dsseRequest.envelope.signatures[0].keyid).toBe(''); + + // Check verifiers + expect(dsseRequest.verifiers).toHaveLength(1); + expect(dsseRequest.verifiers[0].keyDetails).toBe( + PublicKeyDetails.PKIX_ECDSA_P256_SHA_256 + ); + } + }); + }); + }); +}); diff --git a/packages/sign/src/__tests__/witness/tlog/index.test.ts b/packages/sign/src/__tests__/witness/tlog/index.test.ts index 2e6ba2dff..9f6317eba 100644 --- a/packages/sign/src/__tests__/witness/tlog/index.test.ts +++ b/packages/sign/src/__tests__/witness/tlog/index.test.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Sigstore Authors. +Copyright 2025 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. @@ -31,138 +31,61 @@ describe('RekorWitness', () => { }); }); - 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('apiMajorVersion 1', () => { + describe('testify', () => { + const subject = new RekorWitness({ rekorBaseURL }); + const signature = Buffer.from('signature'); + const publicKey = 'publickey'; - 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'), + 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, + }, }, }, }; - it('returns the tlog entry', 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).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') - ); + 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 DSSE envelope', () => { - const sigBundle: SignatureBundle = { - $case: 'dsseEnvelope', - dsseEnvelope: { - signatures: [{ keyid: '', sig: signature }], - payload: Buffer.from('payload'), - payloadType: 'payloadType', - }, - }; - describe('when the Rekor entry type is "intoto"', () => { - const subject = new RekorWitness({ - rekorBaseURL, - entryType: 'intoto', - }); + 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); @@ -226,11 +149,294 @@ describe('RekorWitness', () => { }); }); - describe('when the Rekor entry type is "dsse"', () => { - const subject = new RekorWitness({ - rekorBaseURL, - entryType: 'dsse', + 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', + }, + }; + + describe('when the Rekor entry type is "intoto"', () => { + const subject = new RekorWitness({ + rekorBaseURL, + entryType: 'intoto', + }); + + it('returns the tlog entry', 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).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 Rekor entry type is "dsse"', () => { + const subject = new RekorWitness({ + rekorBaseURL, + entryType: 'dsse', + }); + + it('returns the tlog entry', 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).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'); + }); + }); + }); + }); + + describe('majorApiVersion 2', () => { + describe('testify', () => { + const subject = new RekorWitness({ rekorBaseURL, majorApiVersion: 2 }); + const signature = Buffer.from('signature'); + const publicKey = 'publickey'; + + describe('when Rekor returns a valid response', () => { + const rekorEntry = { + logIndex: '2513258', + logId: { + keyId: 'zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=', + }, + kindVersion: { + kind: 'hashedrekord', + version: '0.0.2', + }, + integratedTime: '0', + inclusionPromise: null, + inclusionProof: { + logIndex: '2513258', + rootHash: '+8vUkEgBK/ansexBUomzocaWoPEmPIzxJC/y+xNMQN4=', + treeSize: '412115', + hashes: [ + 'KIoYVJ0TqmaEkFboP7YWTjSh8vFjVECmokcTOAByfIM=', + 'Umf0h0cK2hegTzNnSgsXszyiA4bp5OvEvP+GrWq3C8w=', + 'vNQeSNBfepYZI2Ez3ViKdCft0JH87ZS8IGKwixxUVjc=', + 'JBAugd5awOqmHXIKgz1MOjlR5f37VqmP0bWoRVcHX5M=', + 'xYH2mAxGxfOgvSOLnItT2LsJt+Z2a2egjf8QJFwK7jA=', + 'PbZWM3NitzChx9A22m/kddDtzh2bAKX5Fy7j76l3z2k=', + 'sKX9Sbsahvw5DiC2oP6pbZsDi1NzuNS1nIULXkCC57o=', + 'pfTCxXHnCM253jwYxVJcuUhmoTTtDxznn92QhN0M4Ws=', + 'cJ8uk2ZvZyfg+HRILKOHcyHu2pvI8Fz3R1MyMvyzWtA=', + ], + checkpoint: { + envelope: + 'log2025-1.rekor.sigstore.dev\n412115\n+8vUkEgBK/ansexBUomzocaWoPEmPIzxJC/y+xNMQN4=\n\n— log2025-1.rekor.sigstore.dev zxGZFahCZ/+MqTjH4rC5MWcdLDWbpetE5l30RZfQc4BQkRjWSoKipEUPjvHENeZDHIlAsuezJcLzUVvItpNjaSRoMAs=\n', + }, + }, + canonicalizedBody: + 'eyJhcGlWZXJzaW9uIjoiMC4wLjIiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJoYXNoZWRSZWtvcmRWMDAyIjp7ImRhdGEiOnsiYWxnb3JpdGhtIjoiU0hBMl8yNTYiLCJkaWdlc3QiOiI1ZEoyU2FrVWVPWFk0dXQxaEJIUG9teitoVG42dGMwS1R6Y0dvc0ErZXBFPSJ9LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FWUNJUURjSk84Mm56MXJydHUxRHhTcHJ1WDFvZ0p1bThkbDRJaUdCTVB6M2pZY2JnSWhBS3dROHdOdTFPbjhwWWZoQUpDSzhPcVQwT09HVGgveUFRK0FuL2UzZC9kVSIsInZlcmlmaWVyIjp7ImtleURldGFpbHMiOiJQS0lYX0VDRFNBX1AyNTZfU0hBXzI1NiIsInB1YmxpY0tleSI6eyJyYXdCeXRlcyI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRTBxOVErdGRHMTk1Q1hLZm1VeDFZYXR2Wi9tTjNyaGpwT2ZtMWx2c2tXcHJIOVB4cGU5WGM2TGpFSmJCY1ZQaHV0a2ZVdkJCOVJEays0MlNORTYvNXhnPT0ifX19fX19', + }; + + beforeEach(() => { + nock(rekorBaseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v2/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); @@ -239,137 +445,63 @@ describe('RekorWitness', () => { 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 + const entry = vm.tlogEntries[0]; + expect(entry.logIndex).toEqual(rekorEntry.logIndex); + expect(entry.logId.keyId).toEqual( + Buffer.from(rekorEntry.logId.keyId, 'base64') ); - expect(tlogEntry.inclusionProof?.hashes).toHaveLength(2); - expect(tlogEntry.inclusionProof?.hashes[0]).toEqual( - Buffer.from( - rekorEntry[uuid].verification.inclusionProof.hashes[0], - 'hex' - ) + expect(entry.integratedTime).toEqual(rekorEntry.integratedTime); + expect(entry.kindVersion).toEqual(rekorEntry.kindVersion); + expect(entry.inclusionPromise).toBeUndefined(); + assert(entry.inclusionProof); + expect(entry.inclusionProof.logIndex).toEqual( + rekorEntry.inclusionProof.logIndex ); - expect(tlogEntry.inclusionProof?.hashes[1]).toEqual( - Buffer.from( - rekorEntry[uuid].verification.inclusionProof.hashes[1], - 'hex' - ) + expect(entry.inclusionProof.rootHash).toEqual( + Buffer.from(rekorEntry.inclusionProof.rootHash, 'base64') ); - expect(tlogEntry.inclusionProof?.logIndex).toEqual( - rekorEntry[uuid].verification.inclusionProof.logIndex.toString() + expect(entry.inclusionProof.treeSize).toEqual( + rekorEntry.inclusionProof.treeSize ); - expect(tlogEntry.inclusionProof?.rootHash).toEqual( - Buffer.from( - rekorEntry[uuid].verification.inclusionProof.rootHash, - 'hex' + expect(entry.inclusionProof.hashes).toEqual( + rekorEntry.inclusionProof.hashes.map((h: string) => + Buffer.from(h, 'base64') ) ); - expect(tlogEntry.inclusionProof?.treeSize).toEqual( - rekorEntry[uuid].verification.inclusionProof.treeSize.toString() + assert(entry.inclusionProof.checkpoint); + expect(entry.inclusionProof.checkpoint.envelope).toEqual( + rekorEntry.inclusionProof.checkpoint.envelope ); - expect(tlogEntry.canonicalizedBody).toEqual( - Buffer.from(rekorEntry[uuid].body, 'base64') + expect(entry.canonicalizedBody).toEqual( + Buffer.from(rekorEntry.canonicalizedBody, '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'), + describe('when Rekor returns an error', () => { + const sigBundle: SignatureBundle = { + $case: 'dsseEnvelope', + dsseEnvelope: { + signatures: [{ keyid: '', sig: signature }], + payload: Buffer.from('payload'), + payloadType: 'payloadType', }, - }, - }; - - 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, {}); - }); + beforeEach(() => { + nock(rekorBaseURL) + .matchHeader('Accept', 'application/json') + .matchHeader('Content-Type', 'application/json') + .post('/api/v2/log/entries') + .reply(500, {}); + }); - it('returns an error', async () => { - await expect( - subject.testify(sigBundle, publicKey) - ).rejects.toThrowWithCode(InternalError, 'TLOG_CREATE_ENTRY_ERROR'); + it('returns an error', async () => { + await expect( + subject.testify(sigBundle, publicKey) + ).rejects.toThrowWithCode(InternalError, 'TLOG_CREATE_ENTRY_ERROR'); + }); }); }); }); diff --git a/packages/sign/src/external/rekor-v2.ts b/packages/sign/src/external/rekor-v2.ts index 5958736ed..6d6848911 100644 --- a/packages/sign/src/external/rekor-v2.ts +++ b/packages/sign/src/external/rekor-v2.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { fetchWithRetry } from './fetch'; -import type { TransparencyLogEntry } from '@sigstore/protobuf-specs'; +import { TransparencyLogEntry } from '@sigstore/protobuf-specs'; import { CreateEntryRequest } from '@sigstore/protobuf-specs/rekor/v2'; import type { FetchOptions } from '../types/fetch'; @@ -49,6 +49,6 @@ export class RekorV2 { retry, }); - return response.json(); + return response.json().then((data) => TransparencyLogEntry.fromJSON(data)); } } diff --git a/packages/sign/src/witness/tlog/client.ts b/packages/sign/src/witness/tlog/client.ts index c547a6319..0e133220c 100644 --- a/packages/sign/src/witness/tlog/client.ts +++ b/packages/sign/src/witness/tlog/client.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Sigstore Authors. +Copyright 2025 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. @@ -16,7 +16,11 @@ limitations under the License. import { internalError } from '../../error'; import { HTTPError } from '../../external/error'; import { Rekor } from '../../external/rekor'; +import { RekorV2 } from '../../external/rekor-v2'; +import type { TransparencyLogEntry } from '@sigstore/bundle'; +import type { TransparencyLogEntry as TLE } from '@sigstore/protobuf-specs'; +import type { CreateEntryRequest } from '@sigstore/protobuf-specs/rekor/v2'; import type { Entry, ProposedEntry } from '../../external/rekor'; import type { FetchOptions } from '../../types/fetch'; @@ -86,3 +90,55 @@ function entryExistsError( value.location !== undefined ); } + +export interface TLogV2 { + createEntry: ( + createEntryRequest: CreateEntryRequest + ) => Promise; +} + +export type TLogV2ClientOptions = { + rekorBaseURL: string; +} & FetchOptions; + +export class TLogV2Client implements TLogV2 { + private rekor: RekorV2; + + constructor(options: TLogV2ClientOptions) { + this.rekor = new RekorV2({ + baseURL: options.rekorBaseURL, + retry: options.retry, + timeout: options.timeout, + }); + } + + public async createEntry( + createEntryRequest: CreateEntryRequest + ): Promise { + let entry: TLE; + + try { + entry = await this.rekor.createEntry(createEntryRequest); + } catch (err) { + internalError( + err, + 'TLOG_CREATE_ENTRY_ERROR', + 'error creating tlog entry' + ); + } + + if (entry.logId === undefined || entry.kindVersion === undefined) { + internalError( + new Error('invalid tlog entry'), + 'TLOG_CREATE_ENTRY_ERROR', + 'error creating tlog entry' + ); + } + + return { + ...entry, + logId: entry.logId, + kindVersion: entry.kindVersion, + }; + } +} diff --git a/packages/sign/src/witness/tlog/entry.ts b/packages/sign/src/witness/tlog/entry.ts index 311a42b23..df8c79323 100644 --- a/packages/sign/src/witness/tlog/entry.ts +++ b/packages/sign/src/witness/tlog/entry.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Sigstore Authors. +Copyright 2025 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. @@ -14,8 +14,10 @@ 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 { PublicKeyDetails } from '@sigstore/protobuf-specs'; +import { crypto, encoding as enc, json, pem } from '../../util'; +import type { CreateEntryRequest } from '@sigstore/protobuf-specs/rekor/v2'; import type { ProposedDSSEEntry, ProposedEntry, @@ -170,3 +172,73 @@ function calculateDSSEHash(envelope: Envelope, publicKey: string): string { .digest(SHA256_ALGORITHM, json.canonicalize(dsse)) .toString('hex'); } + +export function toCreateEntryRequest( + content: SignatureBundle, + publicKey: string +): CreateEntryRequest { + switch (content.$case) { + case 'dsseEnvelope': + return toCreateEntryRequestDSSE(content.dsseEnvelope, publicKey); + case 'messageSignature': + return toCreateEntryRequestMessageSignature( + content.messageSignature, + publicKey + ); + } +} + +function toCreateEntryRequestDSSE( + envelope: Envelope, + publicKey: string +): CreateEntryRequest { + return { + spec: { + $case: 'dsseRequestV002', + dsseRequestV002: { + envelope: envelope, + verifiers: [ + { + // TODO: We need to add support of passing the key details in the + // signature bundle. For now we're hardcoding the key details here. + keyDetails: PublicKeyDetails.PKIX_ECDSA_P256_SHA_256, + verifier: { + $case: 'x509Certificate', + x509Certificate: { + rawBytes: pem.toDER(publicKey), + }, + }, + }, + ], + }, + }, + }; +} + +function toCreateEntryRequestMessageSignature( + messageSignature: MessageSignature, + publicKey: string +): CreateEntryRequest { + return { + spec: { + $case: 'hashedRekordRequestV002', + hashedRekordRequestV002: { + digest: messageSignature.messageDigest.digest, + signature: { + content: messageSignature.signature, + verifier: { + // TODO: We need to add support of passing the key details in the + // signature bundle. For now we're hardcoding the key details here. + keyDetails: PublicKeyDetails.PKIX_ECDSA_P256_SHA_256, + verifier: { + $case: 'x509Certificate', + x509Certificate: { + rawBytes: Buffer.from(publicKey, 'base64'), + }, + }, + }, + }, + }, + }, + }; +} diff --git a/packages/sign/src/witness/tlog/index.ts b/packages/sign/src/witness/tlog/index.ts index 5bf00f423..85450b788 100644 --- a/packages/sign/src/witness/tlog/index.ts +++ b/packages/sign/src/witness/tlog/index.ts @@ -1,5 +1,5 @@ /* -Copyright 2023 The Sigstore Authors. +Copyright 2025 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. @@ -20,8 +20,10 @@ import { TLog, TLogClient, TLogClientOptions, + TLogV2, + TLogV2Client, } from './client'; -import { toProposedEntry } from './entry'; +import { toCreateEntryRequest, toProposedEntry } from './entry'; import type { TransparencyLogEntry } from '@sigstore/bundle'; import type { SignatureBundle, Witness } from '../witness'; @@ -32,15 +34,26 @@ type TransparencyLogEntries = { tlogEntries: TransparencyLogEntry[] }; export type RekorWitnessOptions = Partial & { entryType?: 'dsse' | 'intoto'; + majorApiVersion?: number; }; export class RekorWitness implements Witness { - private tlog: TLog; + private tlogV1: TLog; + private tlogV2: TLogV2; private entryType?: 'dsse' | 'intoto'; + private majorApiVersion: number; constructor(options: RekorWitnessOptions) { this.entryType = options.entryType; - this.tlog = new TLogClient({ + this.majorApiVersion = options.majorApiVersion || 1; + + this.tlogV1 = new TLogClient({ + ...options, + rekorBaseURL: + options.rekorBaseURL || /* istanbul ignore next */ DEFAULT_REKOR_URL, + }); + + this.tlogV2 = new TLogV2Client({ ...options, rekorBaseURL: options.rekorBaseURL || /* istanbul ignore next */ DEFAULT_REKOR_URL, @@ -51,13 +64,22 @@ export class RekorWitness implements Witness { content: SignatureBundle, publicKey: string ): Promise { - const proposedEntry = toProposedEntry(content, publicKey, this.entryType); - const entry = await this.tlog.createEntry(proposedEntry); - return toTransparencyLogEntry(entry); + let tlogEntry: TransparencyLogEntry; + + if (this.majorApiVersion === 2) { + const request = toCreateEntryRequest(content, publicKey); + tlogEntry = await this.tlogV2.createEntry(request); + } else { + const proposedEntry = toProposedEntry(content, publicKey, this.entryType); + const entry = await this.tlogV1.createEntry(proposedEntry); + tlogEntry = toTransparencyLogEntry(entry); + } + + return { tlogEntries: [tlogEntry] }; } } -function toTransparencyLogEntry(entry: Entry): TransparencyLogEntries { +function toTransparencyLogEntry(entry: Entry): TransparencyLogEntry { const logID = Buffer.from(entry.logID, 'hex'); // Parse entry body so we can extract the kind and version. @@ -87,9 +109,7 @@ function toTransparencyLogEntry(entry: Entry): TransparencyLogEntries { canonicalizedBody: Buffer.from(entry.body, 'base64'), }; - return { - tlogEntries: [tlogEntry], - }; + return tlogEntry; } function inclusionPromise(