Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/angry-hands-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/sign/src/__tests__/external/rekor-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

Expand Down
210 changes: 208 additions & 2 deletions packages/sign/src/__tests__/witness/tlog/client.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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';
Expand Down Expand Up @@ -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);
});
});
});
});
145 changes: 141 additions & 4 deletions packages/sign/src/__tests__/witness/tlog/entry.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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';

Expand Down Expand Up @@ -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
);
}
});
});
});
});
Loading