Skip to content

Commit d10059e

Browse files
abhinandan13jancarlosthe19916
authored andcommitted
feat(rekor): add basework for rekor search UI (#46)
1 parent 048fe9a commit d10059e

File tree

21 files changed

+2165
-8
lines changed

21 files changed

+2165
-8
lines changed

client/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,22 @@
2626
"@patternfly/react-tokens": "^6.2.0",
2727
"@tanstack/react-query": "^5.61.0",
2828
"@tanstack/react-query-devtools": "^5.85.0",
29+
"@peculiar/x509": "1.8.4",
2930
"axios": "^1.7.2",
3031
"dayjs": "^1.11.7",
32+
"js-yaml": "^4.1.0",
3133
"file-saver": "^2.0.5",
3234
"oidc-client-ts": "^2.4.0",
35+
"next": "^14.2.29",
36+
"pvtsutils": "^1.3.6",
3337
"react": "^18.2.0",
3438
"react-dom": "^18.2.0",
3539
"react-error-boundary": "^4.0.13",
40+
"react-hook-form": "^7.56.0",
3641
"react-oidc-context": "^2.3.1",
3742
"react-router-dom": "^6.21.1",
43+
"react-syntax-highlighter": "^15.6.1",
44+
"rekor": "^0.2.0",
3845
"victory": "^37.3.4"
3946
},
4047
"devDependencies": {

client/src/app/Routes.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { Bullseye, Spinner } from "@patternfly/react-core";
66
import { ErrorFallback } from "./components/ErrorFallback";
77

88
const TrustRoot = lazy(() => import("./pages/TrustRoot"));
9+
const RekorSearch = lazy(() => import("./pages/RekorSearch"));
910

1011
export const AppRoutes = () => {
1112
const allRoutes = useRoutes([
1213
{ path: "/", element: <Navigate to="/trust-root" /> },
1314
{ path: "/trust-root", element: <TrustRoot /> },
15+
{ path: "/rekor-search", element: <RekorSearch /> },
1416
]);
1517

1618
return (

client/src/app/api/context.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {
2+
createContext,
3+
type FunctionComponent,
4+
type PropsWithChildren,
5+
useContext,
6+
useEffect,
7+
useMemo,
8+
useState,
9+
} from "react";
10+
import { RekorClient } from "rekor";
11+
12+
export interface RekorClientContext {
13+
client: RekorClient;
14+
baseUrl?: string;
15+
setBaseUrl: (_base: string | undefined) => void;
16+
}
17+
18+
export const RekorClientContext = createContext<RekorClientContext | undefined>(undefined);
19+
20+
interface RekorClientProviderProps {
21+
initialDomain?: string;
22+
}
23+
24+
export const RekorClientProvider: FunctionComponent<PropsWithChildren<RekorClientProviderProps>> = ({
25+
children,
26+
initialDomain,
27+
}) => {
28+
const [baseUrl, setBaseUrl] = useState<string | undefined>(initialDomain);
29+
30+
useEffect(() => {
31+
if (baseUrl === undefined) {
32+
if (process.env.NEXT_PUBLIC_REKOR_DEFAULT_DOMAIN) {
33+
setBaseUrl(process.env.NEXT_PUBLIC_REKOR_DEFAULT_DOMAIN);
34+
} else {
35+
setBaseUrl("https://rekor.sigstore.dev");
36+
}
37+
}
38+
}, [baseUrl]);
39+
40+
const context: RekorClientContext = useMemo(() => {
41+
return {
42+
client: new RekorClient({ BASE: baseUrl }),
43+
baseUrl,
44+
setBaseUrl,
45+
};
46+
}, [baseUrl]);
47+
48+
return <RekorClientContext.Provider value={context}>{children}</RekorClientContext.Provider>;
49+
};
50+
51+
export function useRekorClient(): RekorClient {
52+
const ctx = useContext(RekorClientContext);
53+
54+
if (!ctx) {
55+
throw new Error("Hook useRekorClient requires RekorClientContext.");
56+
}
57+
58+
return ctx.client;
59+
}
60+
61+
export function useRekorBaseUrl(): [RekorClientContext["baseUrl"], RekorClientContext["setBaseUrl"]] {
62+
const ctx = useContext(RekorClientContext);
63+
64+
if (!ctx) {
65+
throw new Error("Hook useRekorBaseUrl requires RekorClientContext.");
66+
}
67+
68+
return [ctx.baseUrl, ctx.setBaseUrl];
69+
}

client/src/app/api/rekor-api.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { useCallback } from "react";
2+
import { type LogEntry, RekorClient, type SearchIndex } from "rekor";
3+
import { useRekorClient } from "./context.tsx";
4+
5+
const PAGE_SIZE = 20;
6+
7+
export const ATTRIBUTES = ["email", "hash", "commitSha", "uuid", "logIndex"] as const;
8+
const ATTRIBUTES_SET = new Set<string>(ATTRIBUTES);
9+
10+
export type Attribute = (typeof ATTRIBUTES)[number];
11+
12+
export function isAttribute(input: string): input is Attribute {
13+
return ATTRIBUTES_SET.has(input);
14+
}
15+
16+
export type SearchQuery =
17+
| {
18+
attribute: "email" | "hash" | "commitSha" | "uuid";
19+
query: string;
20+
}
21+
| {
22+
attribute: "logIndex";
23+
query: number;
24+
};
25+
26+
export interface RekorEntries {
27+
totalCount: number;
28+
entries: LogEntry[];
29+
}
30+
31+
export function useRekorSearch() {
32+
const client = useRekorClient();
33+
34+
return useCallback(
35+
// eslint-disable-next-line @typescript-eslint/no-inferrable-types
36+
async (search: SearchQuery, page: number = 1): Promise<RekorEntries> => {
37+
switch (search.attribute) {
38+
case "logIndex":
39+
return {
40+
totalCount: 1,
41+
entries: [
42+
await client.entries.getLogEntryByIndex({
43+
logIndex: search.query,
44+
}),
45+
],
46+
};
47+
case "uuid":
48+
return {
49+
totalCount: 1,
50+
entries: [
51+
await client.entries.getLogEntryByUuid({
52+
entryUuid: search.query,
53+
}),
54+
],
55+
};
56+
case "email":
57+
return queryEntries(
58+
client,
59+
{
60+
email: search.query,
61+
},
62+
page
63+
);
64+
case "hash":
65+
return queryEntries(
66+
client,
67+
{
68+
hash: search.query.startsWith("sha256:") ? search.query : `sha256:${search.query}`,
69+
},
70+
page
71+
);
72+
case "commitSha":
73+
// eslint-disable-next-line no-case-declarations
74+
const hash = await digestMessage(search.query);
75+
return queryEntries(client, { hash }, page);
76+
}
77+
},
78+
[client]
79+
);
80+
}
81+
82+
async function queryEntries(client: RekorClient, query: SearchIndex, page: number): Promise<RekorEntries> {
83+
const logIndexes = await client.index.searchIndex({ query });
84+
85+
// Preventing entries from jumping between pages on refresh
86+
logIndexes.sort();
87+
88+
const startIndex = (page - 1) * PAGE_SIZE;
89+
const endIndex = startIndex + PAGE_SIZE;
90+
const uuidToRetrieve = logIndexes.slice(startIndex, endIndex);
91+
92+
const entries = await Promise.all(uuidToRetrieve.map((entryUuid) => client.entries.getLogEntryByUuid({ entryUuid })));
93+
return {
94+
totalCount: logIndexes.length,
95+
entries,
96+
};
97+
}
98+
99+
async function digestMessage(message: string): Promise<string> {
100+
const msgUint8 = new TextEncoder().encode(message);
101+
const hashBuffer = await crypto.subtle.digest("SHA-256", msgUint8);
102+
const hashArray = Array.from(new Uint8Array(hashBuffer));
103+
const hash = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
104+
return `sha256:${hash}`;
105+
}

client/src/app/layout/default-layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const DefaultLayout: React.FC<DefaultLayoutProps> = ({ children }) => {
2020
masthead={<HeaderApp />}
2121
sidebar={<SidebarApp />}
2222
isManagedSidebar
23-
defaultManagedSidebarIsOpen={false}
23+
defaultManagedSidebarIsOpen
2424
skipToContent={PageSkipToContent}
2525
mainContainerId={pageId}
2626
>

client/src/app/layout/sidebar.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ export const SidebarApp: React.FC = () => {
2323
Trust root
2424
</NavLink>
2525
</li>
26+
<li className={nav.navItem}>
27+
<NavLink
28+
to="/rekor-search"
29+
className={({ isActive }) => {
30+
return css(LINK_CLASS, isActive ? ACTIVE_LINK_CLASS : "");
31+
}}
32+
>
33+
Rekor Search
34+
</NavLink>
35+
</li>
2636
</NavList>
2737
</Nav>
2838
);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { PageSection } from "@patternfly/react-core";
2+
import { Explorer } from "./components/Explorer";
3+
import { RekorClientProvider } from "@app/api/context";
4+
5+
export const RekorSearch: React.FC = () => {
6+
return (
7+
<PageSection>
8+
<RekorClientProvider>
9+
<Explorer />
10+
</RekorClientProvider>
11+
</PageSection>
12+
);
13+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { dump } from "js-yaml";
2+
import NextLink from "next/link";
3+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
4+
import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
5+
import { type IntotoV001Schema } from "rekor";
6+
import { decodex509 } from "./x509/decode";
7+
import { Panel } from "@patternfly/react-core";
8+
9+
export function IntotoViewer001({ intoto }: { intoto: IntotoV001Schema }) {
10+
const certContent = window.atob(intoto.publicKey || "");
11+
12+
const publicKey = {
13+
title: "Public Key",
14+
content: certContent,
15+
};
16+
if (certContent.includes("BEGIN CERTIFICATE")) {
17+
publicKey.title = "Public Key Certificate";
18+
publicKey.content = dump(decodex509(certContent), {
19+
noArrayIndent: true,
20+
lineWidth: -1,
21+
});
22+
}
23+
24+
return (
25+
<Panel>
26+
<h5 style={{ paddingTop: "1.5em", paddingBottom: "1.5em" }}>
27+
<NextLink
28+
href={`/?hash=${intoto.content.payloadHash?.algorithm}:${intoto.content.payloadHash?.value}`}
29+
passHref
30+
>
31+
Hash
32+
</NextLink>
33+
</h5>
34+
35+
<SyntaxHighlighter language="text" style={atomDark}>
36+
{`${intoto.content.payloadHash?.algorithm}:${intoto.content.payloadHash?.value}`}
37+
</SyntaxHighlighter>
38+
39+
<h5 style={{ paddingTop: "1.5em", paddingBottom: "1.5em" }}>Signature</h5>
40+
<SyntaxHighlighter language="text" style={atomDark}>
41+
{"Missing for intoto v0.0.1 entries"}
42+
</SyntaxHighlighter>
43+
<h5 style={{ paddingTop: "1.5em", paddingBottom: "1.5em" }}>{publicKey.title}</h5>
44+
<SyntaxHighlighter language="yaml" style={atomDark}>
45+
{publicKey.content}
46+
</SyntaxHighlighter>
47+
</Panel>
48+
);
49+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { dump } from "js-yaml";
2+
import Link from "next/link";
3+
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
4+
import { atomDark } from "react-syntax-highlighter/dist/cjs/styles/prism";
5+
import { type IntotoV002Schema } from "rekor";
6+
import { decodex509 } from "./x509/decode";
7+
import { Panel } from "@patternfly/react-core";
8+
9+
export function IntotoViewer002({ intoto }: { intoto: IntotoV002Schema }) {
10+
const signature = intoto.content.envelope?.signatures[0];
11+
const certContent = window.atob(signature?.publicKey || "");
12+
13+
const publicKey = {
14+
title: "Public Key",
15+
content: certContent,
16+
};
17+
if (certContent.includes("BEGIN CERTIFICATE")) {
18+
publicKey.title = "Public Key Certificate";
19+
publicKey.content = dump(decodex509(certContent), {
20+
noArrayIndent: true,
21+
lineWidth: -1,
22+
});
23+
}
24+
25+
return (
26+
<Panel>
27+
<h5 style={{ paddingTop: "1.5em", paddingBottom: "1.5em" }}>
28+
<Link href={`/?hash=${intoto.content.payloadHash?.algorithm}:${intoto.content.payloadHash?.value}`}>Hash</Link>
29+
</h5>
30+
31+
<SyntaxHighlighter language="text" style={atomDark}>
32+
{`${intoto.content.payloadHash?.algorithm}:${intoto.content.payloadHash?.value}`}
33+
</SyntaxHighlighter>
34+
35+
<h5 style={{ paddingTop: "1.5em", paddingBottom: "1.5em" }}>Signature</h5>
36+
<SyntaxHighlighter language="text" style={atomDark}>
37+
{window.atob(signature?.sig || "")}
38+
</SyntaxHighlighter>
39+
<h5 style={{ paddingTop: "1.5em", paddingBottom: "1.5em" }}>{publicKey.title}</h5>
40+
<SyntaxHighlighter language="yaml" style={atomDark}>
41+
{publicKey.content}
42+
</SyntaxHighlighter>
43+
</Panel>
44+
);
45+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { KeyUsageFlags } from "@peculiar/x509";
2+
3+
export const KEY_USAGE_NAMES: Record<KeyUsageFlags, string> = {
4+
[KeyUsageFlags.digitalSignature]: "Digital Signature",
5+
[KeyUsageFlags.nonRepudiation]: "Non Repudiation",
6+
[KeyUsageFlags.keyEncipherment]: "Key Encipherment",
7+
[KeyUsageFlags.dataEncipherment]: "Data Encipherment",
8+
[KeyUsageFlags.keyAgreement]: "Key Agreement",
9+
[KeyUsageFlags.keyCertSign]: "Key Certificate Sign",
10+
[KeyUsageFlags.cRLSign]: "Certificate Revocation List Sign",
11+
[KeyUsageFlags.encipherOnly]: "Encipher Only",
12+
[KeyUsageFlags.decipherOnly]: "Decipher Only",
13+
};

0 commit comments

Comments
 (0)