Skip to content

Commit a35b76b

Browse files
Add RekorV2 client (#1517)
* add RekorV2 client Signed-off-by: Brian DeHamer <[email protected]> * Update packages/sign/src/__tests__/external/rekor-v2.test.ts Co-authored-by: Eugene <[email protected]> Signed-off-by: Brian DeHamer <[email protected]> * Update packages/sign/src/external/rekor-v2.ts Co-authored-by: Eugene <[email protected]> Signed-off-by: Brian DeHamer <[email protected]> --------- Signed-off-by: Brian DeHamer <[email protected]> Co-authored-by: Eugene <[email protected]>
1 parent eba6a52 commit a35b76b

File tree

3 files changed

+196
-0
lines changed

3 files changed

+196
-0
lines changed

.changeset/dirty-ghosts-guess.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
Copyright 2025 The Sigstore Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
import nock from 'nock';
17+
import { RekorV2 } from '../../external/rekor-v2';
18+
19+
import { PublicKeyDetails } from '@sigstore/protobuf-specs';
20+
import { CreateEntryRequest } from '@sigstore/protobuf-specs/rekor/v2';
21+
22+
describe('RekorV2', () => {
23+
const baseURL = 'http://localhost:8080';
24+
const subject = new RekorV2({ baseURL });
25+
26+
it('should create an instance', () => {
27+
expect(subject).toBeTruthy();
28+
});
29+
30+
describe('#createEntry', () => {
31+
// Create a minimal valid HashedRekordRequestV002 entry for v2 API
32+
const proposedEntry: CreateEntryRequest = {
33+
spec: {
34+
$case: 'hashedRekordRequestV002',
35+
hashedRekordRequestV002: {
36+
digest: Buffer.from(
37+
'5dJ2SakUeOXY4ut1hBHPomz+hTn6tc0KTzcGosA+epE=',
38+
'base64'
39+
), //
40+
signature: {
41+
content: Buffer.from(
42+
'MEUCIG4QZL8qNVlYtlzSP8GoUUBWZm+VX4nMpxuSeryLFLq8AiEAzGwoNj4Z0PMvbXjC/4aQT5IaBtMSvANyuV4LoF6uLI0=',
43+
'base64'
44+
),
45+
verifier: {
46+
verifier: {
47+
$case: 'publicKey',
48+
publicKey: {
49+
rawBytes: Buffer.from(
50+
'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE0q9Q+tdG195CXKfmUx1YatvZ/mN3rhjpOfm1lvskWprH9Pxpe9Xc6LjEJbBcVPhutkfUvBB9RDk+42SNE6/5xg==',
51+
'base64'
52+
),
53+
},
54+
},
55+
keyDetails: PublicKeyDetails.PKIX_ECDSA_P256_SHA_256,
56+
},
57+
},
58+
},
59+
},
60+
};
61+
62+
const responseBody = {
63+
logIndex: '412114',
64+
logId: {
65+
keyId: 'zxGZFVvd0FEmjR8WrFwMdcAJ9vtaY/QXf44Y1wUeP6A=',
66+
},
67+
kindVersion: {
68+
kind: 'hashedrekord',
69+
version: '0.0.2',
70+
},
71+
integratedTime: '0',
72+
inclusionPromise: null,
73+
inclusionProof: {
74+
logIndex: '412114',
75+
rootHash: '+8vUkEgBK/ansexBUomzocaWoPEmPIzxJC/y+xNMQN4=',
76+
treeSize: '412115',
77+
hashes: [
78+
'KIoYVJ0TqmaEkFboP7YWTjSh8vFjVECmokcTOAByfIM=',
79+
'Umf0h0cK2hegTzNnSgsXszyiA4bp5OvEvP+GrWq3C8w=',
80+
'vNQeSNBfepYZI2Ez3ViKdCft0JH87ZS8IGKwixxUVjc=',
81+
'JBAugd5awOqmHXIKgz1MOjlR5f37VqmP0bWoRVcHX5M=',
82+
'xYH2mAxGxfOgvSOLnItT2LsJt+Z2a2egjf8QJFwK7jA=',
83+
'PbZWM3NitzChx9A22m/kddDtzh2bAKX5Fy7j76l3z2k=',
84+
'sKX9Sbsahvw5DiC2oP6pbZsDi1NzuNS1nIULXkCC57o=',
85+
'pfTCxXHnCM253jwYxVJcuUhmoTTtDxznn92QhN0M4Ws=',
86+
'cJ8uk2ZvZyfg+HRILKOHcyHu2pvI8Fz3R1MyMvyzWtA=',
87+
],
88+
checkpoint: {
89+
envelope:
90+
'log2025-1.rekor.sigstore.dev\n412115\n+8vUkEgBK/ansexBUomzocaWoPEmPIzxJC/y+xNMQN4=\n\n— log2025-1.rekor.sigstore.dev zxGZFahCZ/+MqTjH4rC5MWcdLDWbpetE5l30RZfQc4BQkRjWSoKipEUPjvHENeZDHIlAsuezJcLzUVvItpNjaSRoMAs=\n',
91+
},
92+
},
93+
canonicalizedBody:
94+
'eyJhcGlWZXJzaW9uIjoiMC4wLjIiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJoYXNoZWRSZWtvcmRWMDAyIjp7ImRhdGEiOnsiYWxnb3JpdGhtIjoiU0hBMl8yNTYiLCJkaWdlc3QiOiI1ZEoyU2FrVWVPWFk0dXQxaEJIUG9teitoVG42dGMwS1R6Y0dvc0ErZXBFPSJ9LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FWUNJUURjSk84Mm56MXJydHUxRHhTcHJ1WDFvZ0p1bThkbDRJaUdCTVB6M2pZY2JnSWhBS3dROHdOdTFPbjhwWWZoQUpDSzhPcVQwT09HVGgveUFRK0FuL2UzZC9kVSIsInZlcmlmaWVyIjp7ImtleURldGFpbHMiOiJQS0lYX0VDRFNBX1AyNTZfU0hBXzI1NiIsInB1YmxpY0tleSI6eyJyYXdCeXRlcyI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRTBxOVErdGRHMTk1Q1hLZm1VeDFZYXR2Wi9tTjNyaGpwT2ZtMWx2c2tXcHJIOVB4cGU5WGM2TGpFSmJCY1ZQaHV0a2ZVdkJCOVJEays0MlNORTYvNXhnPT0ifX19fX19',
95+
};
96+
97+
describe('when the entry is successfully added', () => {
98+
beforeEach(() => {
99+
nock(baseURL)
100+
.matchHeader('Accept', 'application/json')
101+
.matchHeader('Content-Type', 'application/json')
102+
.matchHeader('User-Agent', new RegExp('sigstore-js\\/\\d+.\\d+.\\d+'))
103+
.post('/api/v2/log/entries')
104+
.reply(201, responseBody);
105+
});
106+
107+
it('returns the new entry', async () => {
108+
const result = await subject.createEntry(proposedEntry);
109+
110+
expect(result).toBeDefined();
111+
expect(result.logIndex).toBeDefined();
112+
expect(typeof result.logIndex).toBe('string');
113+
expect(result.logId).toBeDefined();
114+
expect(result.logId?.keyId).toBeDefined();
115+
expect(result.integratedTime).toBeDefined();
116+
expect(typeof result.integratedTime).toBe('string');
117+
expect(result.kindVersion).toBeDefined();
118+
expect(result.kindVersion?.kind).toBe('hashedrekord');
119+
expect(result.inclusionPromise).toBeDefined();
120+
});
121+
});
122+
123+
describe('when a matching entry already exists', () => {
124+
const responseBody = {
125+
code: 409,
126+
message: 'An equivalent entry already exists',
127+
};
128+
129+
beforeEach(() => {
130+
nock(baseURL).post('/api/v2/log/entries').reply(409, responseBody);
131+
});
132+
133+
it('returns an error', async () => {
134+
await expect(subject.createEntry(proposedEntry)).rejects.toThrow(
135+
'(409) An equivalent entry already exists'
136+
);
137+
});
138+
});
139+
});
140+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Copyright 2025 The Sigstore Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
import { fetchWithRetry } from './fetch';
17+
18+
import type { TransparencyLogEntry } from '@sigstore/protobuf-specs';
19+
import { CreateEntryRequest } from '@sigstore/protobuf-specs/rekor/v2';
20+
import type { FetchOptions } from '../types/fetch';
21+
22+
// Client options
23+
export type RekorOptions = {
24+
baseURL: string;
25+
} & FetchOptions;
26+
27+
/**
28+
* Rekor API client.
29+
*/
30+
export class RekorV2 {
31+
private options: RekorOptions;
32+
33+
constructor(options: RekorOptions) {
34+
this.options = options;
35+
}
36+
37+
public async createEntry(
38+
proposedEntry: CreateEntryRequest
39+
): Promise<TransparencyLogEntry> {
40+
const { baseURL, timeout, retry } = this.options;
41+
const url = `${baseURL}/api/v2/log/entries`;
42+
const response = await fetchWithRetry(url, {
43+
headers: {
44+
'Content-Type': 'application/json',
45+
Accept: 'application/json',
46+
},
47+
body: JSON.stringify(CreateEntryRequest.toJSON(proposedEntry)),
48+
timeout,
49+
retry,
50+
});
51+
52+
return response.json();
53+
}
54+
}

0 commit comments

Comments
 (0)