Skip to content

Commit 0da8f59

Browse files
committed
fix(ui): address PR #991 UI review comments
## Critical fixes - CreateSplitKey 422: add ObjectType: SymmetricKey as first TTLV field (required non-Option field in server struct; was always 422 before) - Share UID extraction: read UniqueIdentifier not PrivateKeyUniqueIdentifier (WASM deserialises CreateSplitKeyResponse.unique_identifier as { UniqueIdentifier: string[] }, not PrivateKeyUniqueIdentifier) - Extract shared buildCreateSplitKeyRequest to splitKeyUtils.ts so both SplitKey.tsx and CryptoOfficerRole.tsx stay in sync - Compensating Destroy: if CreateSplitKey (step 2) fails after AES key creation (step 1), destroy the orphaned key before re-throwing to prevent the permanent 'object already exists' retry trap ## Peer-revocation fix - Show the revoke section to any CO candidate, not only active COs (server allows dormant candidates as break-glass peer-revocation path) - Gate on ceremony_activated && users.length > 0 instead of is_crypto_officer - Disable the button for dormant COs when no target is selected - Add revokeHintDormant i18n key (en + zh-CN) - Filter the logged-in user from the revoke Select (self-revoke via empty) ## UI / a11y fixes - Remove @font-face CDN blocks (Inter URL was 404; Montserrat calls fonts.gstatic.com — blocked in air-gapped environments); system fonts - Fix WCAG AA contrast in dark theme: colorInfo raised to #4fa8d8 (>=4.5:1 on #161923), colorTextSecondary pinned to #9fa0b8 - Apply --cosmian-sidebar-bg to Sider and Menu to fix sidebar disparity - Document dark colorPrimary change in CHANGELOG (purple -> orange) ## Tests - Update split-key-logic.test.ts: import from shared util, rewrite extractShareUids tests for real WASM response shape - Update CryptoOfficerRevoke.test.ts: add dormant CO peer-revoke tests
1 parent 8348b5c commit 0da8f59

11 files changed

Lines changed: 150 additions & 120 deletions

File tree

CHANGELOG/feat_split_key.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ Replaces the former `privileged_users` flat list with two FIPS 140-3 aligned rol
2626
- **Self-revoke**: active CO calls `POST /access/crypto_officer/disable`.
2727
- **Peer revocation**: any CO candidate calls the same endpoint with `{ "target_user": "<uid>" }` to demote another active CO without server restart (NIST SP 800-152 FR:6.119).
2828

29+
- **`CreateSplitKey` fix**: `ObjectType: SymmetricKey` added as first field in the TTLV request (the field is required/non-`Option` in the server struct); both `SplitKey.tsx` and `CryptoOfficerRole.tsx` previously omitted it, causing a 422 on every submission. Both callers now share a single `buildCreateSplitKeyRequest` helper from `utils/splitKeyUtils.ts`.
30+
- **Compensating delete**: if step 2 (`CreateSplitKey`) fails after step 1 (AES key creation) succeeds, the newly-created key is destroyed before the error is surfaced; this prevents permanent "object already exists" retry failure.
31+
- **Peer-revocation selector**: the logged-in user is now filtered out of the peer-revocation `Select`; self-revoke remains available via the empty selection.
32+
- **External CDN fonts removed**: `@font-face` declarations referencing `fonts.gstatic.com` (Inter URL was 404; Montserrat URL was live but violates air-gapped deployment requirements); falls back to system font stack.
33+
2934
## Security
3035

3136
- Zeroized key material throughout (`Zeroizing<Vec<u8>>`, `Drop` on `CeremonyKeys`).
@@ -46,8 +51,8 @@ Replaces the former `privileged_users` flat list with two FIPS 140-3 aligned rol
4651

4752
- **Crypto Officer page**: status dashboard (ceremony state, active CO list, custodian count); configurable base key ID with live share-UID preview (`<id>#1`, `<id>#2`…); peer-revocation dropdown (visible to active CO only); ceremony activation form.
4853
- **SplitKey / JoinSplitKey dialogs**: Shamir option removed; only XOR n-of-n supported. "Total Parts" renamed to "Number of Shares". Threshold and method selectors removed.
49-
- **Dark theme**: aligned to mdBook Eviden palette (`#161923` bg, `#bcbdd0` text, `#282d3f` sidebar, orange `#f14611`). All contrast ratios WCAG AA.
50-
- **Sidebar fixes**: sub-menus visible when sidebar is collapsed; collapse trigger button color corrected in light theme.
54+
- **Dark theme**: aligned to mdBook Eviden palette (`#161923` bg, `#bcbdd0` text, `#282d3f` sidebar, orange `#f14611`). `colorPrimary` changed from `#9e6eff` (purple) to `#f14611` (Cosmian brand orange) for brand consistency. Light `colorPrimary` changed from `#e34319` to `#c73f1b` (≥4.5:1 on white, WCAG AA). `colorInfo` (links) raised to `#4fa8d8` (≥4.5:1 on `#161923`). `colorTextSecondary` pinned to `#9fa0b8` to prevent the dark algorithm deriving a low-contrast value (~2.84:1) on the elevated card surface.
55+
- **Sidebar background**: both light and dark modes now use the `--cosmian-sidebar-bg` CSS variable, eliminating the disparity between themes.
5156

5257
## Testing
5358

ui/src/App.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,7 @@ function App() {
508508
token: {
509509
colorPrimary: "#c73f1b" /* Cosmian brand orange — eviden.css --cosmian-accent-dark (>= 4.5:1 on white) */,
510510
colorText: "#1a1a1a" /* Eviden brand ink — matches eviden.css --cosmian-dark */,
511-
fontFamily: "'Inter', 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
511+
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
512512
},
513513
components: {
514514
Layout: {
@@ -540,16 +540,17 @@ function App() {
540540
algorithm: theme.darkAlgorithm,
541541
token: {
542542
colorPrimary: "#f14611" /* Cosmian primary orange — eviden.css --cosmian-accent (bright accent on dark) */,
543-
colorInfo: "#2b79a2" /* mdBook dark-theme link blue */,
543+
colorInfo: "#4fa8d8" /* mdBook dark-theme link blue — ≥ 4.5:1 on #161923 (WCAG AA) */,
544544
colorTextBase: "#bcbdd0" /* mdBook navy --fg */,
545+
colorTextSecondary: "#9fa0b8" /* explicit — prevents algorithm deriving ~#666979 (only 2.84:1 on card bg) */,
545546
colorBgBase: "#161923" /* mdBook navy --bg hsl(226,23%,11%) — black background */,
546547
colorBgLayout: "#161923",
547548
colorBgContainer: "#1f2432" /* elevated card surface */,
548549
colorBgElevated: "#282d3f" /* mdBook navy --sidebar-bg */,
549550
colorBorder: "#5a6278",
550551
colorSplit: "#3a4155",
551552
colorError: "#ff6b6b" /* light red (>= 4.5:1 on #161923) */,
552-
fontFamily: "'Inter', 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
553+
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
553554
},
554555
components: {
555556
Layout: {

ui/src/actions/Access/CryptoOfficerRole.tsx

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useAuth } from "../../contexts/useAuth";
55
import { getNoTTLVRequest, postNoTTLVRequest, sendKmipRequest } from "../../utils/utils";
66
import LocateButton from "../../components/common/LocateButton";
77
import * as wasm from "../../wasm/pkg";
8+
import { buildCreateSplitKeyRequest } from "../../utils/splitKeyUtils";
89

910
const { Text } = Typography;
1011

@@ -24,18 +25,7 @@ interface CeremonyActivateFormData {
2425
}
2526

2627
type CreateSymKeyResponse = { UniqueIdentifier: string };
27-
type CreateSplitKeyResponse = { UniqueIdentifier: string; PrivateKeyUniqueIdentifier: string[] };
28-
29-
const buildCreateSplitKeyRequest = (keyId: string, n: number) => ({
30-
tag: "CreateSplitKey",
31-
type: "Structure",
32-
value: [
33-
{ tag: "UniqueIdentifier", type: "TextString", value: keyId },
34-
{ tag: "SplitKeyParts", type: "Integer", value: n },
35-
{ tag: "SplitKeyThreshold", type: "Integer", value: n },
36-
{ tag: "SplitKeyMethod", type: "Enumeration", value: "XOR" },
37-
],
38-
});
28+
type CreateSplitKeyResponse = { UniqueIdentifier: string | string[] };
3929

4030
const CryptoOfficerRole: React.FC = () => {
4131
const { t } = useTranslation("actions");
@@ -50,7 +40,7 @@ const CryptoOfficerRole: React.FC = () => {
5040
const [splitKeyId, setSplitKeyId] = useState<string>("");
5141
/** Target user for peer revocation (empty = self-revoke) */
5242
const [revokeTarget, setRevokeTarget] = useState<string>("");
53-
const { serverUrl } = useAuth();
43+
const { serverUrl, userId } = useAuth();
5444
const responseRef = useRef<HTMLDivElement>(null);
5545
const [activateForm] = Form.useForm<CeremonyActivateFormData>();
5646

@@ -119,14 +109,26 @@ const CryptoOfficerRole: React.FC = () => {
119109

120110
// Split the key into n shares (n = custodians_count)
121111
const splitReq = buildCreateSplitKeyRequest(createdKeyId, n);
122-
const splitRespStr = await sendKmipRequest(splitReq, serverUrl);
112+
let splitRespStr: string | null;
113+
try {
114+
splitRespStr = await sendKmipRequest(splitReq, serverUrl);
115+
} catch (splitErr) {
116+
// Compensating delete: destroy the orphaned AES key before re-throwing
117+
try {
118+
const destroyReq = wasm.destroy_ttlv_request(createdKeyId, false);
119+
await sendKmipRequest(destroyReq, serverUrl);
120+
} catch {
121+
/* best-effort; ignore cleanup errors */
122+
}
123+
throw splitErr;
124+
}
123125
if (!splitRespStr) throw new Error("Split key operation returned an empty response");
124126

125127
const splitResp: CreateSplitKeyResponse = await wasm.parse_create_split_key_ttlv_response(splitRespStr);
126-
const shareUids: string[] = Array.isArray(splitResp.PrivateKeyUniqueIdentifier)
127-
? splitResp.PrivateKeyUniqueIdentifier
128-
: splitResp.PrivateKeyUniqueIdentifier
129-
? [splitResp.PrivateKeyUniqueIdentifier]
128+
const shareUids: string[] = Array.isArray(splitResp.UniqueIdentifier)
129+
? splitResp.UniqueIdentifier
130+
: splitResp.UniqueIdentifier
131+
? [splitResp.UniqueIdentifier]
130132
: [];
131133

132134
if (shareUids.length === 0) {
@@ -282,25 +284,34 @@ const CryptoOfficerRole: React.FC = () => {
282284
</div>
283285
</div>
284286

285-
{/* Only the active CO can revoke (self-revoke or peer-revoke via the target selector). */}
286-
{status.ceremony_activated && status.is_crypto_officer && (
287+
{/* Any CO candidate (active or dormant) can revoke an active CO.
288+
Active COs can also self-revoke by leaving the target empty.
289+
Dormant candidates (in users list but no active activation) can
290+
only peer-revoke — the button is disabled if no target is set. */}
291+
{status.ceremony_activated && status.users.length > 0 && (
287292
<div className="pt-2 border-t space-y-3">
288293
<p className="text-sm font-medium">{t("cryptoOfficer.revokeRole")}</p>
289294
<Space direction="vertical" style={{ display: "flex" }}>
290-
{/* Only list users that are currently active COs */}
295+
{/* List active COs only; current user is filtered out (self-revoke uses empty selection) */}
291296
<Select
292297
placeholder={t("cryptoOfficer.selectRevokePlaceholder")}
293298
value={revokeTarget || undefined}
294299
onChange={(val: string | undefined) => setRevokeTarget(val ?? "")}
295300
allowClear
296301
style={{ width: 380 }}
297-
options={(status.active_co_users ?? status.users).map((u) => ({
298-
value: u,
299-
label: u,
300-
}))}
302+
options={(status.active_co_users ?? status.users)
303+
.filter((u) => u !== userId)
304+
.map((u) => ({
305+
value: u,
306+
label: u,
307+
}))}
301308
data-testid="revoke-target-select"
302309
/>
303-
<p className="text-xs text-gray-500 dark:text-gray-400">{t("cryptoOfficer.revokeHint")}</p>
310+
<p className="text-xs text-gray-500 dark:text-gray-400">
311+
{status.is_crypto_officer
312+
? t("cryptoOfficer.revokeHint")
313+
: t("cryptoOfficer.revokeHintDormant")}
314+
</p>
304315
<Tooltip
305316
title={
306317
!status.is_crypto_officer && !revokeTarget.trim()
@@ -310,7 +321,13 @@ const CryptoOfficerRole: React.FC = () => {
310321
: t("cryptoOfficer.tooltipSelfRevoke")
311322
}
312323
>
313-
<Button danger onClick={disableCeremony} loading={isDisabling} data-testid="disable-btn">
324+
<Button
325+
danger
326+
onClick={disableCeremony}
327+
loading={isDisabling}
328+
disabled={!status.is_crypto_officer && !revokeTarget.trim()}
329+
data-testid="disable-btn"
330+
>
314331
{revokeTarget.trim()
315332
? t("cryptoOfficer.revokeFor", { user: revokeTarget.trim() })
316333
: t("cryptoOfficer.revokeMyCeremony")}

ui/src/actions/Keys/SplitKey.tsx

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React from "react";
33
import { Trans, useTranslation } from "react-i18next";
44
import { sendKmipRequest } from "../../utils/utils";
55
import * as wasm from "../../wasm/pkg";
6+
import { buildCreateSplitKeyRequest } from "../../utils/splitKeyUtils";
67
import { useActionState } from "../../hooks/useActionState";
78
import { ActionResponse } from "../../components/common/ActionResponse";
89

@@ -11,26 +12,14 @@ interface SplitKeyFormData {
1112
shareCount: number;
1213
}
1314

14-
/// Build a CreateSplitKey TTLV request. The caller supplies the resolved `n`.
15-
const buildCreateSplitKeyRequest = (keyId: string, n: number) => ({
16-
tag: "CreateSplitKey",
17-
type: "Structure",
18-
value: [
19-
{ tag: "UniqueIdentifier", type: "TextString", value: keyId },
20-
{ tag: "SplitKeyParts", type: "Integer", value: n },
21-
{ tag: "SplitKeyThreshold", type: "Integer", value: n },
22-
{ tag: "SplitKeyMethod", type: "Enumeration", value: "XOR" },
23-
],
24-
});
25-
2615
type CreateSymKeyResponse = {
2716
ObjectType: string;
2817
UniqueIdentifier: string;
2918
};
3019

3120
type CreateSplitKeyResponse = {
32-
UniqueIdentifier: string;
33-
PrivateKeyUniqueIdentifier: string[];
21+
// Vec<UniqueIdentifier> serialised by serde_wasm_bindgen as an array
22+
UniqueIdentifier: string | string[];
3423
};
3524

3625
const SplitKeyForm: React.FC = () => {
@@ -61,17 +50,28 @@ const SplitKeyForm: React.FC = () => {
6150

6251
// ── Step 2: Split the newly created key ────────────────────────────
6352
const splitReq = buildCreateSplitKeyRequest(createdKeyId, n);
64-
const splitRespStr = await sendKmipRequest(splitReq, serverUrl);
53+
let splitRespStr: string | null;
54+
try {
55+
splitRespStr = await sendKmipRequest(splitReq, serverUrl);
56+
} catch (splitErr) {
57+
// Compensating delete: destroy the orphaned AES key before re-throwing
58+
try {
59+
const destroyReq = wasm.destroy_ttlv_request(createdKeyId, false);
60+
await sendKmipRequest(destroyReq, serverUrl);
61+
} catch {
62+
/* best-effort; ignore cleanup errors */
63+
}
64+
throw splitErr;
65+
}
6566
if (!splitRespStr) {
6667
throw new Error("Split key operation returned an empty response");
6768
}
6869

69-
// Use the WASM parser for type-safe CreateSplitKeyResponse parsing.
7070
const splitResp: CreateSplitKeyResponse = await wasm.parse_create_split_key_ttlv_response(splitRespStr);
71-
const shareUids: string[] = Array.isArray(splitResp.PrivateKeyUniqueIdentifier)
72-
? splitResp.PrivateKeyUniqueIdentifier
73-
: splitResp.PrivateKeyUniqueIdentifier
74-
? [splitResp.PrivateKeyUniqueIdentifier]
71+
const shareUids: string[] = Array.isArray(splitResp.UniqueIdentifier)
72+
? splitResp.UniqueIdentifier
73+
: splitResp.UniqueIdentifier
74+
? [splitResp.UniqueIdentifier]
7575
: [];
7676

7777
if (shareUids.length > 0) {

ui/src/components/layout/Sidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ const Sidebar: React.FC<{ isFips?: boolean; isDarkMode?: boolean }> = ({ isFips
149149
collapsed={collapsed}
150150
onCollapse={setCollapsed}
151151
className="h-full"
152-
style={{ position: "sticky", top: 0, overflow: "auto" }}
152+
style={{ position: "sticky", top: 0, overflow: "auto", background: "var(--cosmian-sidebar-bg)" }}
153153
>
154154
<Menu
155155
mode="inline"
@@ -161,7 +161,7 @@ const Sidebar: React.FC<{ isFips?: boolean; isDarkMode?: boolean }> = ({ isFips
161161
items={modifiedMenuItems}
162162
onClick={({ key }: { key: string }) => navigate(key)}
163163
className="h-full border-r-0"
164-
style={{ fontWeight: "500", overflow: "auto" }}
164+
style={{ fontWeight: "500", overflow: "auto", background: "var(--cosmian-sidebar-bg)" }}
165165
/>
166166
</Sider>
167167
);

ui/src/i18n/locales/en/actions.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1828,6 +1828,7 @@
18281828
"revokeRole": "Revoke Crypto Officer Role",
18291829
"selectRevokePlaceholder": "Select active CO to revoke (or leave empty to self-revoke)",
18301830
"revokeHint": "Any Crypto Officer candidate can revoke another active CO. Leave empty to self-revoke (you must be the active CO).",
1831+
"revokeHintDormant": "Select an active Crypto Officer to revoke their role. You cannot self-revoke because you have no active ceremony.",
18311832
"tooltipNotActive": "You are not the active CO. Select a target user to peer-revoke.",
18321833
"tooltipRevokeFor": "Revoke CO role for: {{user}}",
18331834
"tooltipSelfRevoke": "Revokes your active ceremony. The role becomes dormant until a new ceremony completes.",

ui/src/i18n/locales/zh-CN/actions.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1828,6 +1828,7 @@
18281828
"revokeRole": "撤销加密官角色",
18291829
"selectRevokePlaceholder": "选择要撤销的活动加密官(或留空以自我撤销)",
18301830
"revokeHint": "任何加密官候选人都可以撤销另一位活动加密官。留空以自我撤销(您必须是活动加密官)。",
1831+
"revokeHintDormant": "选择一位活动加密官以撤销其角色。您无法自我撤销,因为您没有活动仪式。",
18311832
"tooltipNotActive": "您不是活动加密官。请选择目标用户进行对等撤销。",
18321833
"tooltipRevokeFor": "撤销用户 {{user}} 的加密官角色",
18331834
"tooltipSelfRevoke": "撤销您的活动仪式。该角色将变为休眠状态,直到新的仪式完成。",

ui/src/styles.css

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,6 @@
55
theme switch (not the OS `prefers-color-scheme`). */
66
@custom-variant dark (&:where(.dark, .dark *));
77

8-
/* ── Cosmian brand fonts (same stack as documentation/theme/fonts/fonts.css) ── */
9-
@font-face {
10-
font-family: "Inter";
11-
font-style: normal;
12-
font-weight: 100 900;
13-
font-display: swap;
14-
src: url("https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2") format("woff2");
15-
}
16-
17-
@font-face {
18-
font-family: "Montserrat";
19-
font-style: normal;
20-
font-weight: 100 900;
21-
font-display: swap;
22-
src: url("https://fonts.gstatic.com/s/montserrat/v31/JTUSjIg1_i6t8kCHKm459Wlhyw.woff2") format("woff2");
23-
}
24-
258
/* ── Cosmian design tokens — mirrors documentation/theme/css/eviden.css ─────── */
269
:root {
2710
--cosmian-accent: #f14611; /* Cosmian primary orange */
@@ -59,14 +42,7 @@ body {
5942
height: 100%;
6043
margin: 0;
6144
padding: 0;
62-
font-family:
63-
"Inter",
64-
"Montserrat",
65-
-apple-system,
66-
BlinkMacSystemFont,
67-
"Segoe UI",
68-
Roboto,
69-
sans-serif;
45+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
7046
}
7147

7248
/* Black (mdBook navy) background behind/around the app, not just inside AntD. */

ui/src/utils/splitKeyUtils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Shared utility for building a KMIP `CreateSplitKey` TTLV request.
3+
*
4+
* Used by both the standalone Split Key page and the Crypto Officer ceremony
5+
* workflow to ensure a single, consistent request shape. `ObjectType` is the
6+
* first field and is required (non-`Option`) by the server.
7+
*/
8+
9+
/** Build a `CreateSplitKey` TTLV request for an AES-256 symmetric key. */
10+
export const buildCreateSplitKeyRequest = (keyId: string, n: number) => ({
11+
tag: "CreateSplitKey",
12+
type: "Structure",
13+
value: [
14+
{ tag: "ObjectType", type: "Enumeration", value: "SymmetricKey" },
15+
{ tag: "UniqueIdentifier", type: "TextString", value: keyId },
16+
{ tag: "SplitKeyParts", type: "Integer", value: n },
17+
{ tag: "SplitKeyThreshold", type: "Integer", value: n },
18+
{ tag: "SplitKeyMethod", type: "Enumeration", value: "XOR" },
19+
],
20+
});

0 commit comments

Comments
 (0)