From 0eeb747cdc7903834fee00ce9f9254c23b162be9 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Thu, 6 Aug 2026 18:40:47 +0800 Subject: [PATCH] feat(apiKeyStrength): implement API key strength meter and evaluation logic --- .../components/blocks/ApiKeyStrengthMeter.tsx | 77 +++++++++ .../components/blocks/ApiKeysCardEditor.tsx | 2 + .../components/blocks/Blocks.module.scss | 61 +++++++ .../config/components/blocks/shared.ts | 19 ++- src/i18n/locales/en.json | 10 +- src/i18n/locales/ru.json | 10 +- src/i18n/locales/zh-CN.json | 10 +- src/i18n/locales/zh-TW.json | 10 +- src/utils/apiKeyStrength.ts | 151 ++++++++++++++++++ tests/apiKeyStrength.test.ts | 82 ++++++++++ tests/apiKeyStrengthMeter.test.ts | 66 ++++++++ 11 files changed, 493 insertions(+), 5 deletions(-) create mode 100644 src/features/config/components/blocks/ApiKeyStrengthMeter.tsx create mode 100644 src/utils/apiKeyStrength.ts create mode 100644 tests/apiKeyStrength.test.ts create mode 100644 tests/apiKeyStrengthMeter.test.ts diff --git a/src/features/config/components/blocks/ApiKeyStrengthMeter.tsx b/src/features/config/components/blocks/ApiKeyStrengthMeter.tsx new file mode 100644 index 00000000..1b97d212 --- /dev/null +++ b/src/features/config/components/blocks/ApiKeyStrengthMeter.tsx @@ -0,0 +1,77 @@ +import { memo, useEffect, useMemo, useRef, type CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + API_KEY_STRENGTH_SEGMENTS, + evaluateApiKeyStrength, + type ApiKeyStrengthTier, +} from '@/utils/apiKeyStrength'; +import { segmentFillDelayMs } from './shared'; +import styles from './Blocks.module.scss'; + +// 三档语义色 + 段数承担第四档的区分:翡翠绿留给「活的流量」,此处用语义 success。 +const TIER_COLORS: Record = { + weak: 'var(--error-color)', + fair: 'var(--amber-color)', + good: 'var(--success-color)', + strong: 'var(--success-color)', +}; + +const SEGMENT_INDEXES = Array.from({ length: API_KEY_STRENGTH_SEGMENTS }, (_, index) => index); + +/** + * 自拟 API Key 的强度参考条:四段依次点亮,只给档位标签,不参与保存校验。 + */ +export const ApiKeyStrengthMeter = memo(function ApiKeyStrengthMeter({ value }: { value: string }) { + const { t } = useTranslation(); + const { tier, segments } = useMemo(() => evaluateApiKeyStrength(value), [value]); + + // 上一次的段数决定这次谁需要排队;渲染只读,提交后再推进 + const previousSegments = useRef(segments); + const cascadeFrom = previousSegments.current; + useEffect(() => { + previousSegments.current = segments; + }, [segments]); + + const empty = segments === 0; + const tierLabel = empty + ? t('config_management.visual.api_keys.strength.empty') + : t(`config_management.visual.api_keys.strength.${tier}`); + + return ( +
+
+ {SEGMENT_INDEXES.map((index) => ( + + + + ))} +
+ +
+ ); +}); diff --git a/src/features/config/components/blocks/ApiKeysCardEditor.tsx b/src/features/config/components/blocks/ApiKeysCardEditor.tsx index dd1a3939..9eb5e7ce 100644 --- a/src/features/config/components/blocks/ApiKeysCardEditor.tsx +++ b/src/features/config/components/blocks/ApiKeysCardEditor.tsx @@ -8,6 +8,7 @@ import { makeClientId } from '@/types/visualConfig'; import { generateSecureApiKey } from '@/utils/apiKey'; import { maskApiKey } from '@/utils/format'; import { isValidApiKeyCharset } from '@/utils/validation'; +import { ApiKeyStrengthMeter } from './ApiKeyStrengthMeter'; import styles from './Blocks.module.scss'; export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ @@ -219,6 +220,7 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ {t('config_management.visual.api_keys.generate')} +
{t('config_management.visual.api_keys.input_hint')}
diff --git a/src/features/config/components/blocks/Blocks.module.scss b/src/features/config/components/blocks/Blocks.module.scss index 7d9497d0..24a1da00 100644 --- a/src/features/config/components/blocks/Blocks.module.scss +++ b/src/features/config/components/blocks/Blocks.module.scss @@ -151,6 +151,67 @@ } } +/* 强度参考条:四段承载档位,颜色承载严重度。 + 段的填充走 scaleX(只碰 transform),键入时逐字重算也不掉帧。 */ + +.strengthMeter { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 10px; +} + +.strengthTrack { + display: flex; + gap: 6px; +} + +.strengthSegment { + flex: 1; + height: 4px; + border-radius: $radius-full; + overflow: hidden; + // 轨道 = 填充色的淡化步阶,未点亮的段也带着当前状态 + background: color-mix(in srgb, var(--strength-color) 16%, transparent); + transition: background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out); +} + +.strengthSegmentFill { + display: block; + height: 100%; + border-radius: inherit; + background: var(--strength-color); + transform: scaleX(0); + transform-origin: left center; + // 段内比常规交互再快一档,把预算让给段间的 stagger + transition: + transform var(--dur-press, 160ms) var(--ease-out-strong, ease-out), + background-color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out); + + // 延迟写在目标态上:点亮时依次排队,熄灭是系统响应,立刻发生 + &[data-filled='true'] { + transform: scaleX(1); + transition-delay: var(--segment-delay, 0ms); + } + + // 降级只去掉位移和排队,颜色变化保留(它才是状态本身) + @media (prefers-reduced-motion: reduce) { + transition: background-color var(--dur-hover, 200ms) linear; + + &[data-filled='true'] { + transition-delay: 0ms; + } + } +} + +.strengthLabel { + min-height: 16px; + font-size: 12px; + line-height: 16px; + color: var(--strength-color); + transition: color var(--dur-hover, 200ms) var(--ease-out-strong, ease-out); +} + /* ---------- 插件源认证 ---------- */ .storeAuthEditor { diff --git a/src/features/config/components/blocks/shared.ts b/src/features/config/components/blocks/shared.ts index 0d18cf0a..4c4c175e 100644 --- a/src/features/config/components/blocks/shared.ts +++ b/src/features/config/components/blocks/shared.ts @@ -1,4 +1,4 @@ -// 载荷/规则编辑器共享的纯工具(从旧 VisualConfigEditorBlocks 原样迁出, +// 区块编辑器共享的纯工具(载荷/规则部分从旧 VisualConfigEditorBlocks 原样迁出, // 独立成文件以规避组件文件导出非组件的 react-refresh 限制)。 import type { useTranslation } from 'react-i18next'; @@ -40,3 +40,20 @@ export function buildProtocolOptions( return options; } + +/** API Key 强度条相邻两段点亮的间隔;4 段全亮 = 135ms + 段内 160ms,整组仍在 300ms 内 */ +export const SEGMENT_STAGGER_MS = 45; + +/** + * 强度条段填充的起跑延迟:只有本次新增的段排队,已亮的段和熄灭都不延迟。 + * 因此「生成」一次点亮四段是依次的波,而键入让强度 +1 段是即时的。 + */ +export function segmentFillDelayMs( + index: number, + segments: number, + previousSegments: number +): number { + const filled = index < segments; + if (!filled || index < previousSegments) return 0; + return (index - Math.max(previousSegments, 0)) * SEGMENT_STAGGER_MS; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6bc645ae..f1e43048 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1077,7 +1077,15 @@ "input_placeholder": "Paste your API key", "input_hint": "This only modifies the local config file content, it will not sync to the API Key Management interface", "error_empty": "Please enter an API key", - "error_invalid": "API key contains invalid characters" + "error_invalid": "API key contains invalid characters", + "strength": { + "label": "API key strength", + "empty": "Not entered", + "weak": "Weak", + "fair": "Fair", + "good": "Good", + "strong": "Strong" + } }, "payload_rules": { "rule": "Rule", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2c465541..e14c569a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1064,7 +1064,15 @@ "input_placeholder": "Вставьте API-ключ", "input_hint": "Меняет только содержимое локального файла конфигурации, не синхронизируется с интерфейсом управления API-ключами", "error_empty": "Введите API-ключ", - "error_invalid": "API-ключ содержит недопустимые символы" + "error_invalid": "API-ключ содержит недопустимые символы", + "strength": { + "label": "Надёжность API-ключа", + "empty": "Не введено", + "weak": "Слабый", + "fair": "Средний", + "good": "Хороший", + "strong": "Надёжный" + } }, "payload_rules": { "rule": "Правило", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 2d3cfc09..6c21310f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1077,7 +1077,15 @@ "input_placeholder": "粘贴你的 API 密钥", "input_hint": "此处仅修改本地配置文件内容,不会自动同步到 API 密钥管理接口", "error_empty": "请输入 API 密钥", - "error_invalid": "API 密钥包含无效字符" + "error_invalid": "API 密钥包含无效字符", + "strength": { + "label": "API 密钥强度", + "empty": "待输入", + "weak": "弱", + "fair": "一般", + "good": "良好", + "strong": "很强" + } }, "payload_rules": { "rule": "规则", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index ac174003..c55bf453 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1103,7 +1103,15 @@ "input_placeholder": "貼上你的 API 金鑰", "input_hint": "此處僅修改本地設定檔內容,不會自動同步到 API 金鑰管理介面", "error_empty": "請輸入 API 金鑰", - "error_invalid": "API 金鑰包含無效字元" + "error_invalid": "API 金鑰包含無效字元", + "strength": { + "label": "API 金鑰強度", + "empty": "待輸入", + "weak": "弱", + "fair": "一般", + "good": "良好", + "strong": "很強" + } }, "payload_rules": { "rule": "規則", diff --git a/src/utils/apiKeyStrength.ts b/src/utils/apiKeyStrength.ts new file mode 100644 index 00000000..1e62efba --- /dev/null +++ b/src/utils/apiKeyStrength.ts @@ -0,0 +1,151 @@ +/** + * API Key 强度评估。 + * + * 模型:字符集熵 × 可预测性折扣 → 四档。纯函数,UI 只消费 tier/segments, + * 因此评分口径的调整不会牵动组件。 + */ + +export type ApiKeyStrengthTier = 'weak' | 'fair' | 'good' | 'strong'; + +export interface ApiKeyStrength { + tier: ApiKeyStrengthTier; + /** 点亮的段数(0–4);0 表示空输入 */ + segments: number; + /** 估算熵,向下取整到 bit */ + bits: number; +} + +/** 档位由弱到强,索引即点亮段数 - 1 */ +const TIER_ORDER: readonly ApiKeyStrengthTier[] = ['weak', 'fair', 'good', 'strong']; + +export const API_KEY_STRENGTH_SEGMENTS = TIER_ORDER.length; + +// 字符集大小:与 isValidApiKeyCharset 允许的 ASCII 可见字符对齐(0x21–0x7E 共 94 个) +const CHARSET_CLASSES: readonly { pattern: RegExp; size: number }[] = [ + { pattern: /[a-z]/, size: 26 }, + { pattern: /[A-Z]/, size: 26 }, + { pattern: /[0-9]/, size: 10 }, + { pattern: /[^a-zA-Z0-9]/, size: 32 }, +]; + +/** 一眼可猜的口令片段,命中即大幅折价 */ +const GUESSABLE_TOKENS: readonly string[] = [ + 'password', + 'passwd', + '123456', + 'qwerty', + 'admin', + 'secret', + 'apikey', + 'api-key', + 'letmein', + 'changeme', + 'iloveyou', + 'default', + 'test', + 'demo', +]; + +// 重复字符与顺序字符几乎不贡献猜测成本,按残值计入有效长度 +const REPEAT_WEIGHT = 0.25; +const SEQUENCE_WEIGHT = 0.35; +const GUESSABLE_FACTOR = 0.4; + +// 熵阈值(bit)。48 位随机 base62 ≈ 285 bit,32 位十六进制 = 128 bit。 +const BITS_FOR_FAIR = 40; +const BITS_FOR_GOOD = 64; +const BITS_FOR_STRONG = 96; + +// 长度封顶:熵再高也挡不住短串被离线爆破,短于阈值就锁在对应档 +const LENGTH_CAPS: readonly { below: number; tier: ApiKeyStrengthTier }[] = [ + { below: 8, tier: 'weak' }, + { below: 16, tier: 'fair' }, + { below: 24, tier: 'good' }, +]; + +/** 字符种类过少时,长度带来的熵是假的(如 32 个 a) */ +const MIN_UNIQUE_FOR_FAIR = 5; + +/** + * 有效长度:与前一字符相同、或延续升/降序列的字符只按残值计入。 + * 随机串几乎不触发折扣,规律串会被显著压缩。 + */ +function effectiveLength(key: string): number { + let total = 0; + let sequenceRun = 1; + + for (let index = 0; index < key.length; index += 1) { + const code = key.charCodeAt(index); + const previous = index > 0 ? key.charCodeAt(index - 1) : Number.NaN; + + if (code === previous) { + total += REPEAT_WEIGHT; + sequenceRun = 1; + continue; + } + + const delta = code - previous; + if (delta === 1 || delta === -1) { + sequenceRun += 1; + // 前两个字符仍算新信息,第三个起才是可预测的顺序 + total += sequenceRun >= 3 ? SEQUENCE_WEIGHT : 1; + continue; + } + + sequenceRun = 1; + total += 1; + } + + return total; +} + +/** + * 最小周期长度:`deadbeefdeadbeef` → 8,`abcabca` → 3,无周期则返回原长。 + * 用 (s + s).indexOf(s, 1) 求解,尾部不完整的周期同样能识别。 + */ +function smallestPeriod(key: string): number { + const period = `${key}${key}`.indexOf(key, 1); + return period > 0 && period < key.length ? period : key.length; +} + +function charsetSize(key: string): number { + return CHARSET_CLASSES.reduce( + (size, charClass) => (charClass.pattern.test(key) ? size + charClass.size : size), + 0 + ); +} + +function tierForBits(bits: number): ApiKeyStrengthTier { + if (bits >= BITS_FOR_STRONG) return 'strong'; + if (bits >= BITS_FOR_GOOD) return 'good'; + if (bits >= BITS_FOR_FAIR) return 'fair'; + return 'weak'; +} + +function capTier(tier: ApiKeyStrengthTier, cap: ApiKeyStrengthTier): ApiKeyStrengthTier { + return TIER_ORDER.indexOf(tier) <= TIER_ORDER.indexOf(cap) ? tier : cap; +} + +/** + * 评估用户自拟 API Key 的强度。仅供参考,不参与保存校验。 + */ +export function evaluateApiKeyStrength(rawKey: string): ApiKeyStrength { + const key = rawKey.trim(); + if (!key) return { tier: 'weak', segments: 0, bits: 0 }; + + const pool = charsetSize(key); + const lowerCased = key.toLowerCase(); + const guessable = GUESSABLE_TOKENS.some((token) => lowerCased.includes(token)); + // 周期串的猜测成本只等于一个周期,重复部分按残值计入 + const period = smallestPeriod(key); + const length = effectiveLength(key.slice(0, period)) + (key.length - period) * REPEAT_WEIGHT; + const bits = Math.floor(length * Math.log2(pool) * (guessable ? GUESSABLE_FACTOR : 1)); + + let tier = tierForBits(bits); + for (const { below, tier: cap } of LENGTH_CAPS) { + if (key.length < below) tier = capTier(tier, cap); + } + if (new Set(key).size < MIN_UNIQUE_FOR_FAIR) tier = capTier(tier, 'weak'); + + return { tier, segments: TIER_ORDER.indexOf(tier) + 1, bits }; +} diff --git a/tests/apiKeyStrength.test.ts b/tests/apiKeyStrength.test.ts new file mode 100644 index 00000000..7cf07def --- /dev/null +++ b/tests/apiKeyStrength.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test'; +import { generateSecureApiKey } from '../src/utils/apiKey'; +import { + API_KEY_STRENGTH_SEGMENTS, + evaluateApiKeyStrength, + type ApiKeyStrengthTier, +} from '../src/utils/apiKeyStrength'; + +const TIER_ORDER: ApiKeyStrengthTier[] = ['weak', 'fair', 'good', 'strong']; + +describe('API key strength', () => { + test('empty input lights no segment', () => { + for (const value of ['', ' ']) { + expect(evaluateApiKeyStrength(value)).toEqual({ tier: 'weak', segments: 0, bits: 0 }); + } + }); + + test('segments track the tier index', () => { + const samples = ['a', 'Tr0ub4dor', 'Xk7#mQ2vLp9$Rn4wZt6c', generateSecureApiKey()]; + + for (const sample of samples) { + const { tier, segments } = evaluateApiKeyStrength(sample); + expect(segments).toBe(TIER_ORDER.indexOf(tier) + 1); + expect(segments).toBeLessThanOrEqual(API_KEY_STRENGTH_SEGMENTS); + } + }); + + test('generated keys always reach the top tier', () => { + for (let i = 0; i < 50; i += 1) { + const { tier, segments } = evaluateApiKeyStrength(generateSecureApiKey()); + expect(tier).toBe('strong'); + expect(segments).toBe(API_KEY_STRENGTH_SEGMENTS); + } + }); + + test('short keys are capped regardless of charset richness', () => { + // 7 位就算四类字符齐全也只能是最弱档 + expect(evaluateApiKeyStrength('aA1!bB2').tier).toBe('weak'); + // 15 位封顶在第二档 + expect(evaluateApiKeyStrength('aA1!bB2@cC3#dD4').tier).toBe('fair'); + // 23 位封顶在第三档 + expect(evaluateApiKeyStrength('aA1!bB2@cC3#dD4$eE5%fG').tier).toBe('good'); + }); + + test('repeated and sequential runs are discounted', () => { + const repeated = evaluateApiKeyStrength('a'.repeat(48)); + expect(repeated.tier).toBe('weak'); + + const sequential = evaluateApiKeyStrength('abcdefghijklmnopqrstuvwxyz0123456789'); + const shuffled = evaluateApiKeyStrength('qzmXe4Rk9BtLw7Ncy2VsJp5Ghd8FaU3Zmr6Q'); + expect(sequential.bits).toBeLessThan(shuffled.bits); + }); + + test('periodic keys score like a single period', () => { + // 32 位却只有 8 位的猜测成本 + expect(evaluateApiKeyStrength('deadbeefdeadbeefdeadbeefdeadbeef').tier).toBe('fair'); + expect(evaluateApiKeyStrength('abababababababababababababab').tier).toBe('weak'); + // 尾部不完整的周期同样识别 + const periodic = evaluateApiKeyStrength('myproxy2024myproxy2024myproxy'); + const aperiodic = evaluateApiKeyStrength('myproxy2024ZtRv4Ns8Lc3Bd7hQwK'); + expect(periodic.bits).toBeLessThan(aperiodic.bits); + }); + + test('guessable tokens drag the score down', () => { + const withToken = evaluateApiKeyStrength('sk-password-9fKw2mQx7ZtRv4Ns8Lc3Bd'); + const withoutToken = evaluateApiKeyStrength('sk-hRvnqtwj-9fKw2mQx7ZtRv4Ns8Lc3Bd'); + + expect(withToken.bits).toBeLessThan(withoutToken.bits); + expect(TIER_ORDER.indexOf(withToken.tier)).toBeLessThan(TIER_ORDER.indexOf(withoutToken.tier)); + }); + + test('longer keys never score below their prefix', () => { + const base = 'Xk7#mQ2vLp9$Rn4wZt6cHj8&Bd5xVy3'; + let previous = 0; + + for (let length = 1; length <= base.length; length += 1) { + const { bits } = evaluateApiKeyStrength(base.slice(0, length)); + expect(bits).toBeGreaterThanOrEqual(previous); + previous = bits; + } + }); +}); diff --git a/tests/apiKeyStrengthMeter.test.ts b/tests/apiKeyStrengthMeter.test.ts new file mode 100644 index 00000000..9f8d3c30 --- /dev/null +++ b/tests/apiKeyStrengthMeter.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import i18n from '@/i18n'; +import { ApiKeyStrengthMeter } from '@/features/config/components/blocks/ApiKeyStrengthMeter'; +import { SEGMENT_STAGGER_MS, segmentFillDelayMs } from '@/features/config/components/blocks/shared'; +import { generateSecureApiKey } from '@/utils/apiKey'; + +const LOCALES = ['en', 'zh-CN', 'zh-TW', 'ru']; + +describe('ApiKeyStrengthMeter', () => { + test('exposes the tier through the progressbar', () => { + const markup = renderToStaticMarkup( + createElement(ApiKeyStrengthMeter, { value: generateSecureApiKey() }) + ); + + expect(markup).toContain('aria-valuenow="4"'); + expect(markup).toContain('aria-valuemax="4"'); + expect(markup).toContain( + `aria-valuetext="${i18n.t('config_management.visual.api_keys.strength.strong')}"` + ); + expect(markup.match(/data-filled="true"/g)).toHaveLength(4); + }); + + test('empty input lights nothing and shows a placeholder label', () => { + const markup = renderToStaticMarkup(createElement(ApiKeyStrengthMeter, { value: '' })); + + expect(markup).toContain('aria-valuenow="0"'); + expect(markup).not.toContain('data-filled="true"'); + expect(markup).toContain('—'); + }); + + test('segments cascade only over the newly lit ones', () => { + const delays = (segments: number, previous: number) => + [0, 1, 2, 3].map((index) => segmentFillDelayMs(index, segments, previous)); + + // 0 → 4(点「生成」):四段依次起跑 + expect(delays(4, 0)).toEqual([ + 0, + SEGMENT_STAGGER_MS, + SEGMENT_STAGGER_MS * 2, + SEGMENT_STAGGER_MS * 3, + ]); + // 2 → 3(键入一个字符):新增的那段立刻亮,不为它的下标排队 + expect(delays(3, 2)).toEqual([0, 0, 0, 0]); + // 1 → 3:只有新增的两段排队 + expect(delays(3, 1)).toEqual([0, 0, SEGMENT_STAGGER_MS, 0]); + // 4 → 2(删字符):熄灭立即发生 + expect(delays(2, 4)).toEqual([0, 0, 0, 0]); + }); + + test('every tier label is translated in all locales', async () => { + const original = i18n.language; + + for (const locale of LOCALES) { + await i18n.changeLanguage(locale); + for (const key of ['label', 'empty', 'weak', 'fair', 'good', 'strong']) { + const path = `config_management.visual.api_keys.strength.${key}`; + expect(i18n.exists(path)).toBe(true); + expect(i18n.t(path)).not.toBe(path); + } + } + + await i18n.changeLanguage(original); + }); +});