Skip to content

Commit 39435da

Browse files
committed
rekor v2 support in RekorWitness
Signed-off-by: Brian DeHamer <[email protected]>
1 parent a35b76b commit 39435da

File tree

9 files changed

+893
-269
lines changed

9 files changed

+893
-269
lines changed

.changeset/angry-hands-notice.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/sign/src/__tests__/external/rekor-v2.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ describe('RekorV2', () => {
116116
expect(typeof result.integratedTime).toBe('string');
117117
expect(result.kindVersion).toBeDefined();
118118
expect(result.kindVersion?.kind).toBe('hashedrekord');
119-
expect(result.inclusionPromise).toBeDefined();
119+
expect(result.inclusionPromise).toBeUndefined();
120120
});
121121
});
122122

packages/sign/src/__tests__/witness/tlog/client.test.ts

Lines changed: 207 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2023 The Sigstore Authors.
2+
Copyright 2025 The Sigstore Authors.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
you may not use this file except in compliance with the License.
@@ -15,7 +15,14 @@ limitations under the License.
1515
*/
1616
import nock from 'nock';
1717
import { InternalError } from '../../../error';
18-
import { ProposedEntry, TLogClient } from '../../../witness/tlog/client';
18+
import {
19+
ProposedEntry,
20+
TLogClient,
21+
TLogV2Client,
22+
} from '../../../witness/tlog/client';
23+
24+
import type { CreateEntryRequest } from '@sigstore/protobuf-specs/rekor/v2';
25+
import assert from 'assert';
1926

2027
describe('TLogClient', () => {
2128
const rekorBaseURL = 'http://localhost:8080';
@@ -194,3 +201,201 @@ describe('TLogClient', () => {
194201
});
195202
});
196203
});
204+
205+
describe('TLogV2Client', () => {
206+
const rekorBaseURL = 'http://localhost:8080';
207+
208+
describe('constructor', () => {
209+
it('should create a new instance', () => {
210+
const client = new TLogV2Client({ rekorBaseURL });
211+
expect(client).toBeDefined();
212+
});
213+
});
214+
215+
describe('createEntry', () => {
216+
const subject = new TLogV2Client({ rekorBaseURL, retry: false });
217+
218+
const createEntryRequest: CreateEntryRequest = {
219+
spec: {
220+
$case: 'hashedRekordRequestV002',
221+
hashedRekordRequestV002: {
222+
digest: Buffer.from('digest'),
223+
signature: {
224+
content: Buffer.from('signature'),
225+
verifier: {
226+
keyDetails: 5, // PKIX_ECDSA_P256_SHA_256
227+
verifier: {
228+
$case: 'x509Certificate',
229+
x509Certificate: {
230+
rawBytes: Buffer.from('certificate'),
231+
},
232+
},
233+
},
234+
},
235+
},
236+
},
237+
};
238+
239+
const rekorEntry = {
240+
logIndex: '2513258',
241+
logId: {
242+
keyId: 'zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=',
243+
},
244+
kindVersion: {
245+
kind: 'hashedrekord',
246+
version: '0.0.2',
247+
},
248+
integratedTime: '0',
249+
inclusionPromise: null,
250+
inclusionProof: {
251+
logIndex: '2513258',
252+
rootHash: '+8vUkEgBK/ansexBUomzocaWoPEmPIzxJC/y+xNMQN4=',
253+
treeSize: '412115',
254+
hashes: [
255+
'KIoYVJ0TqmaEkFboP7YWTjSh8vFjVECmokcTOAByfIM=',
256+
'Umf0h0cK2hegTzNnSgsXszyiA4bp5OvEvP+GrWq3C8w=',
257+
'vNQeSNBfepYZI2Ez3ViKdCft0JH87ZS8IGKwixxUVjc=',
258+
'JBAugd5awOqmHXIKgz1MOjlR5f37VqmP0bWoRVcHX5M=',
259+
'xYH2mAxGxfOgvSOLnItT2LsJt+Z2a2egjf8QJFwK7jA=',
260+
'PbZWM3NitzChx9A22m/kddDtzh2bAKX5Fy7j76l3z2k=',
261+
'sKX9Sbsahvw5DiC2oP6pbZsDi1NzuNS1nIULXkCC57o=',
262+
'pfTCxXHnCM253jwYxVJcuUhmoTTtDxznn92QhN0M4Ws=',
263+
'cJ8uk2ZvZyfg+HRILKOHcyHu2pvI8Fz3R1MyMvyzWtA=',
264+
],
265+
checkpoint: {
266+
envelope:
267+
'log2025-1.rekor.sigstore.dev\n412115\n+8vUkEgBK/ansexBUomzocaWoPEmPIzxJC/y+xNMQN4=\n\n— log2025-1.rekor.sigstore.dev zxGZFahCZ/+MqTjH4rC5MWcdLDWbpetE5l30RZfQc4BQkRjWSoKipEUPjvHENeZDHIlAsuezJcLzUVvItpNjaSRoMAs=\n',
268+
},
269+
},
270+
canonicalizedBody: Buffer.from(
271+
JSON.stringify(createEntryRequest)
272+
).toString('base64'),
273+
};
274+
275+
describe('when Rekor returns an error', () => {
276+
beforeEach(() => {
277+
nock(rekorBaseURL)
278+
.matchHeader('Accept', 'application/json')
279+
.matchHeader('Content-Type', 'application/json')
280+
.post('/api/v2/log/entries')
281+
.reply(500, {});
282+
});
283+
284+
it('returns an error', async () => {
285+
await expect(
286+
subject.createEntry(createEntryRequest)
287+
).rejects.toThrowWithCode(InternalError, 'TLOG_CREATE_ENTRY_ERROR');
288+
});
289+
});
290+
291+
describe('when Rekor returns a valid response', () => {
292+
beforeEach(() => {
293+
nock(rekorBaseURL)
294+
.matchHeader('Accept', 'application/json')
295+
.matchHeader('Content-Type', 'application/json')
296+
.post('/api/v2/log/entries')
297+
.reply(201, rekorEntry);
298+
});
299+
300+
it('returns a tlog entry', async () => {
301+
const entry = await subject.createEntry(createEntryRequest);
302+
303+
expect(entry.logIndex).toEqual(rekorEntry.logIndex);
304+
expect(entry.logId.keyId).toEqual(
305+
Buffer.from(rekorEntry.logId.keyId, 'base64')
306+
);
307+
expect(entry.integratedTime).toEqual(rekorEntry.integratedTime);
308+
expect(entry.kindVersion).toEqual(rekorEntry.kindVersion);
309+
expect(entry.inclusionPromise).toBeUndefined();
310+
assert(entry.inclusionProof);
311+
expect(entry.inclusionProof.logIndex).toEqual(
312+
rekorEntry.inclusionProof.logIndex
313+
);
314+
expect(entry.inclusionProof.rootHash).toEqual(
315+
Buffer.from(rekorEntry.inclusionProof.rootHash, 'base64')
316+
);
317+
expect(entry.inclusionProof.treeSize).toEqual(
318+
rekorEntry.inclusionProof.treeSize
319+
);
320+
expect(entry.inclusionProof.hashes).toEqual(
321+
rekorEntry.inclusionProof.hashes.map((h: string) =>
322+
Buffer.from(h, 'base64')
323+
)
324+
);
325+
assert(entry.inclusionProof.checkpoint);
326+
expect(entry.inclusionProof.checkpoint.envelope).toEqual(
327+
rekorEntry.inclusionProof.checkpoint.envelope
328+
);
329+
expect(entry.canonicalizedBody).toEqual(
330+
Buffer.from(rekorEntry.canonicalizedBody, 'base64')
331+
);
332+
});
333+
});
334+
335+
describe('when Rekor returns an incomplete response', () => {
336+
describe('when logId is missing', () => {
337+
const incompleteEntry = {
338+
...rekorEntry,
339+
logId: undefined,
340+
};
341+
342+
beforeEach(() => {
343+
nock(rekorBaseURL)
344+
.matchHeader('Accept', 'application/json')
345+
.matchHeader('Content-Type', 'application/json')
346+
.post('/api/v2/log/entries')
347+
.reply(201, incompleteEntry);
348+
});
349+
350+
it('returns an error', async () => {
351+
await expect(
352+
subject.createEntry(createEntryRequest)
353+
).rejects.toThrowWithCode(InternalError, 'TLOG_CREATE_ENTRY_ERROR');
354+
});
355+
});
356+
357+
describe('when kindVersion is missing', () => {
358+
const incompleteEntry = {
359+
...rekorEntry,
360+
kindVersion: undefined,
361+
};
362+
363+
beforeEach(() => {
364+
nock(rekorBaseURL)
365+
.matchHeader('Accept', 'application/json')
366+
.matchHeader('Content-Type', 'application/json')
367+
.post('/api/v2/log/entries')
368+
.reply(201, incompleteEntry);
369+
});
370+
371+
it('returns an error', async () => {
372+
await expect(
373+
subject.createEntry(createEntryRequest)
374+
).rejects.toThrowWithCode(InternalError, 'TLOG_CREATE_ENTRY_ERROR');
375+
});
376+
});
377+
});
378+
379+
describe('when Rekor returns a valid response after retry', () => {
380+
let scope: nock.Scope;
381+
382+
beforeEach(() => {
383+
scope = nock(rekorBaseURL)
384+
.post('/api/v2/log/entries')
385+
.reply(500, { message: 'oops' })
386+
.post('/api/v2/log/entries')
387+
.reply(201, rekorEntry);
388+
});
389+
390+
it('returns a tlog entry', async () => {
391+
const subject = new TLogV2Client({
392+
rekorBaseURL,
393+
retry: { retries: 1, factor: 0, minTimeout: 1, maxTimeout: 1 },
394+
});
395+
const entry = await subject.createEntry(createEntryRequest);
396+
397+
expect(entry.logIndex).toEqual(rekorEntry.logIndex);
398+
});
399+
});
400+
});
401+
});

packages/sign/src/__tests__/witness/tlog/entry.test.ts

Lines changed: 141 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
Copyright 2023 The Sigstore Authors.
2+
Copyright 2025 The Sigstore Authors.
33
44
Licensed under the Apache License, Version 2.0 (the "License");
55
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
1414
limitations under the License.
1515
*/
1616
import { envelopeToJSON } from '@sigstore/bundle';
17-
import { HashAlgorithm } from '@sigstore/protobuf-specs';
17+
import { HashAlgorithm, PublicKeyDetails } from '@sigstore/protobuf-specs';
1818
import assert from 'assert';
19-
import { crypto, encoding as enc } from '../../../util';
20-
import { toProposedEntry } from '../../../witness/tlog/entry';
19+
import { crypto, encoding as enc, pem } from '../../../util';
20+
import {
21+
toCreateEntryRequest,
22+
toProposedEntry,
23+
} from '../../../witness/tlog/entry';
2124

2225
import type { SignatureBundle } from '../../../witness';
2326

@@ -234,3 +237,137 @@ describe('toProposedEntry', () => {
234237
});
235238
});
236239
});
240+
241+
describe('toCreateEntryRequest', () => {
242+
const publicKey = '-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----';
243+
const signature = Buffer.from('signature');
244+
245+
describe('when a message signature is provided', () => {
246+
const sigBundle: SignatureBundle = {
247+
$case: 'messageSignature',
248+
messageSignature: {
249+
signature: signature,
250+
messageDigest: {
251+
algorithm: HashAlgorithm.SHA2_256,
252+
digest: Buffer.from('digest'),
253+
},
254+
},
255+
};
256+
257+
it('returns a valid CreateEntryRequest with hashedRekordRequestV002', () => {
258+
const request = toCreateEntryRequest(sigBundle, publicKey);
259+
260+
expect(request.spec).toBeTruthy();
261+
expect(request.spec?.$case).toBe('hashedRekordRequestV002');
262+
263+
if (request.spec?.$case === 'hashedRekordRequestV002') {
264+
const hashedRekord = request.spec.hashedRekordRequestV002;
265+
266+
// Check digest
267+
expect(hashedRekord.digest).toEqual(
268+
sigBundle.messageSignature.messageDigest.digest
269+
);
270+
271+
// Check signature content
272+
expect(hashedRekord.signature).toBeTruthy();
273+
assert(hashedRekord.signature);
274+
expect(hashedRekord.signature.content).toEqual(
275+
sigBundle.messageSignature.signature
276+
);
277+
278+
// Check verifier
279+
expect(hashedRekord.signature.verifier).toBeTruthy();
280+
assert(hashedRekord.signature.verifier);
281+
expect(hashedRekord.signature.verifier.keyDetails).toBe(
282+
PublicKeyDetails.PKIX_ECDSA_P256_SHA_256
283+
);
284+
expect(hashedRekord.signature.verifier.verifier?.$case).toBe(
285+
'x509Certificate'
286+
);
287+
288+
if (
289+
hashedRekord.signature.verifier.verifier?.$case === 'x509Certificate'
290+
) {
291+
expect(
292+
hashedRekord.signature.verifier.verifier.x509Certificate.rawBytes
293+
).toEqual(Buffer.from(publicKey, 'base64'));
294+
}
295+
}
296+
});
297+
});
298+
299+
describe('when a DSSE envelope is provided', () => {
300+
describe('when the keyid is a non-empty string', () => {
301+
const sigBundle: SignatureBundle = {
302+
$case: 'dsseEnvelope',
303+
dsseEnvelope: {
304+
signatures: [{ keyid: '123', sig: signature }],
305+
payloadType: 'application/vnd.in-toto+json',
306+
payload: Buffer.from('payload'),
307+
},
308+
};
309+
310+
it('returns a valid CreateEntryRequest with dsseRequestV002', () => {
311+
const request = toCreateEntryRequest(sigBundle, publicKey);
312+
313+
expect(request.spec).toBeTruthy();
314+
expect(request.spec?.$case).toBe('dsseRequestV002');
315+
316+
if (request.spec?.$case === 'dsseRequestV002') {
317+
const dsseRequest = request.spec.dsseRequestV002;
318+
319+
// Check envelope
320+
expect(dsseRequest.envelope).toEqual(sigBundle.dsseEnvelope);
321+
322+
// Check verifiers array
323+
expect(dsseRequest.verifiers).toHaveLength(1);
324+
const verifier = dsseRequest.verifiers[0];
325+
326+
expect(verifier.keyDetails).toBe(
327+
PublicKeyDetails.PKIX_ECDSA_P256_SHA_256
328+
);
329+
expect(verifier.verifier?.$case).toBe('x509Certificate');
330+
331+
if (verifier.verifier?.$case === 'x509Certificate') {
332+
expect(verifier.verifier.x509Certificate.rawBytes).toEqual(
333+
pem.toDER(publicKey)
334+
);
335+
}
336+
}
337+
});
338+
});
339+
340+
describe('when the keyid is an empty string', () => {
341+
const sigBundle: SignatureBundle = {
342+
$case: 'dsseEnvelope',
343+
dsseEnvelope: {
344+
signatures: [{ keyid: '', sig: signature }],
345+
payloadType: 'application/vnd.in-toto+json',
346+
payload: Buffer.from('payload'),
347+
},
348+
};
349+
350+
it('returns a valid CreateEntryRequest with dsseRequestV002', () => {
351+
const request = toCreateEntryRequest(sigBundle, publicKey);
352+
353+
expect(request.spec).toBeTruthy();
354+
expect(request.spec?.$case).toBe('dsseRequestV002');
355+
356+
if (request.spec?.$case === 'dsseRequestV002') {
357+
const dsseRequest = request.spec.dsseRequestV002;
358+
359+
// Check envelope is preserved exactly
360+
assert(dsseRequest.envelope);
361+
expect(dsseRequest.envelope).toEqual(sigBundle.dsseEnvelope);
362+
expect(dsseRequest.envelope.signatures[0].keyid).toBe('');
363+
364+
// Check verifiers
365+
expect(dsseRequest.verifiers).toHaveLength(1);
366+
expect(dsseRequest.verifiers[0].keyDetails).toBe(
367+
PublicKeyDetails.PKIX_ECDSA_P256_SHA_256
368+
);
369+
}
370+
});
371+
});
372+
});
373+
});

0 commit comments

Comments
 (0)