feat: add SSH key signing and verification UI
This commit is contained in:
@@ -24,3 +24,30 @@ export async function createSSHKey(data: SSHKeyCreate): Promise<SSHKey> {
|
|||||||
export async function deleteSSHKey(keyId: string): Promise<void> {
|
export async function deleteSSHKey(keyId: string): Promise<void> {
|
||||||
await apiClient.delete(`/ssh-keys/${keyId}`);
|
await apiClient.delete(`/ssh-keys/${keyId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SignPayloadRequest {
|
||||||
|
payload: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureResponse {
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifySignatureRequest {
|
||||||
|
payload: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifySignatureResponse {
|
||||||
|
valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signPayload(keyId: string, data: SignPayloadRequest): Promise<SignatureResponse> {
|
||||||
|
const response = await apiClient.post<SignatureResponse>(`/ssh-keys/${keyId}/sign`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySignature(keyId: string, data: VerifySignatureRequest): Promise<VerifySignatureResponse> {
|
||||||
|
const response = await apiClient.post<VerifySignatureResponse>(`/ssh-keys/${keyId}/verify`, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh_keys";
|
import { createSSHKey, deleteSSHKey, listSSHKeys, signPayload, verifySignature, type SSHKey } from "../api/ssh_keys";
|
||||||
import { Icon } from "../components/icon";
|
import { Icon } from "../components/icon";
|
||||||
|
|
||||||
export const SSHKeysPage = () => {
|
export const SSHKeysPage = () => {
|
||||||
@@ -10,6 +10,13 @@ export const SSHKeysPage = () => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [newKeyName, setNewKeyName] = useState("");
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [signPayloads, setSignPayloads] = useState<Record<string, string>>({});
|
||||||
|
const [signatures, setSignatures] = useState<Record<string, string>>({});
|
||||||
|
const [signing, setSigning] = useState<Record<string, boolean>>({});
|
||||||
|
const [verifyPayloads, setVerifyPayloads] = useState<Record<string, string>>({});
|
||||||
|
const [verifySignatures, setVerifySignatures] = useState<Record<string, string>>({});
|
||||||
|
const [verifyResults, setVerifyResults] = useState<Record<string, boolean | null>>({});
|
||||||
|
const [verifying, setVerifying] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadKeys();
|
loadKeys();
|
||||||
@@ -59,6 +66,42 @@ export const SSHKeysPage = () => {
|
|||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSign(keyId: string) {
|
||||||
|
const payload = signPayloads[keyId];
|
||||||
|
if (!payload?.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSigning((prev) => ({ ...prev, [keyId]: true }));
|
||||||
|
const result = await signPayload(keyId, { payload: payload.trim() });
|
||||||
|
setSignatures((prev) => ({ ...prev, [keyId]: result.signature }));
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to sign payload");
|
||||||
|
} finally {
|
||||||
|
setSigning((prev) => ({ ...prev, [keyId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleVerify(keyId: string) {
|
||||||
|
const payload = verifyPayloads[keyId];
|
||||||
|
const signature = verifySignatures[keyId];
|
||||||
|
if (!payload?.trim() || !signature?.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setVerifying((prev) => ({ ...prev, [keyId]: true }));
|
||||||
|
const result = await verifySignature(keyId, {
|
||||||
|
payload: payload.trim(),
|
||||||
|
signature: signature.trim(),
|
||||||
|
});
|
||||||
|
setVerifyResults((prev) => ({ ...prev, [keyId]: result.valid }));
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to verify signature");
|
||||||
|
} finally {
|
||||||
|
setVerifying((prev) => ({ ...prev, [keyId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
if (loading) return <div>Loading...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -133,6 +176,110 @@ export const SSHKeysPage = () => {
|
|||||||
Copy Full Key
|
Copy Full Key
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="key-signing">
|
||||||
|
<h4>Sign Payload</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={signPayloads[key.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSignPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter payload to sign..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSign(key.id)}
|
||||||
|
disabled={signing[key.id] || !signPayloads[key.id]?.trim()}
|
||||||
|
className="primary-button"
|
||||||
|
>
|
||||||
|
{signing[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Signing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="edit" size="sm" />
|
||||||
|
Sign
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{signatures[key.id] && (
|
||||||
|
<div className="signature-result">
|
||||||
|
<label>Signature (base64):</label>
|
||||||
|
<code>{signatures[key.id]}</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(signatures[key.id])}
|
||||||
|
className="secondary-button"
|
||||||
|
>
|
||||||
|
<Icon name="copy" size="sm" />
|
||||||
|
Copy Signature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="key-verification">
|
||||||
|
<h4>Verify Signature</h4>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={verifyPayloads[key.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVerifyPayloads((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter payload..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<textarea
|
||||||
|
value={verifySignatures[key.id] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVerifySignatures((prev) => ({ ...prev, [key.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="Enter base64 signature..."
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleVerify(key.id)}
|
||||||
|
disabled={
|
||||||
|
verifying[key.id] ||
|
||||||
|
!verifyPayloads[key.id]?.trim() ||
|
||||||
|
!verifySignatures[key.id]?.trim()
|
||||||
|
}
|
||||||
|
className="primary-button"
|
||||||
|
>
|
||||||
|
{verifying[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="loading" size="sm" />
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="success" size="sm" />
|
||||||
|
Verify
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{verifyResults[key.id] !== undefined && verifyResults[key.id] !== null && (
|
||||||
|
<div className={`verify-result ${verifyResults[key.id] ? "valid" : "invalid"}`}>
|
||||||
|
{verifyResults[key.id] ? (
|
||||||
|
<>
|
||||||
|
<Icon name="success" size="sm" />
|
||||||
|
Signature is valid
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="error" size="sm" />
|
||||||
|
Signature is invalid
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user