feat(apiKeyStrength): implement API key strength meter and evaluation logic

This commit is contained in:
Supra4E8C
2026-08-06 18:40:47 +08:00
parent cfa8f616a3
commit 0eeb747cdc
11 changed files with 493 additions and 5 deletions
@@ -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<ApiKeyStrengthTier, string> = {
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 (
<div
className={styles.strengthMeter}
style={
{
'--strength-color': empty ? 'var(--text-quaternary)' : TIER_COLORS[tier],
} as CSSProperties
}
>
<div
className={styles.strengthTrack}
role="progressbar"
aria-valuemin={0}
aria-valuemax={API_KEY_STRENGTH_SEGMENTS}
aria-valuenow={segments}
aria-valuetext={tierLabel}
aria-label={t('config_management.visual.api_keys.strength.label')}
>
{SEGMENT_INDEXES.map((index) => (
<span key={index} className={styles.strengthSegment}>
<span
className={styles.strengthSegmentFill}
data-filled={index < segments}
style={
{
'--segment-delay': `${segmentFillDelayMs(index, segments, cascadeFrom)}ms`,
} as CSSProperties
}
/>
</span>
))}
</div>
<span className={styles.strengthLabel} aria-hidden="true">
{empty ? '—' : tierLabel}
</span>
</div>
);
});
@@ -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')}
</Button>
</div>
<ApiKeyStrengthMeter value={inputValue} />
<div id={apiKeyHintId} className="hint">
{t('config_management.visual.api_keys.input_hint')}
</div>
@@ -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 {
@@ -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;
}
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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": "Правило",
+9 -1
View File
@@ -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": "规则",
+9 -1
View File
@@ -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": "規則",
+151
View File
@@ -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 可见字符对齐(0x210x7E 共 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 bit32 位十六进制 = 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 };
}
+82
View File
@@ -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;
}
});
});
+66
View File
@@ -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);
});
});