ai_co_discuss 0.3.46 → 0.3.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/discuss-host/SKILL.md +1 -0
- package/.agents/skills/discuss-host/references/discussion.md +113 -113
- package/.agents/skills/discuss-host/references/free.md +36 -34
- package/.agents/skills/discuss-host/references/goal.md +35 -0
- package/.agents/skills/discuss-participant/SKILL.md +1 -0
- package/.agents/skills/workflow-creator/SKILL.md +2 -2
- package/README.md +3 -1
- package/backend/admin.py +104 -35
- package/backend/free_room.py +2170 -2136
- package/backend/goal_room.py +358 -0
- package/backend/mode_room.py +105 -0
- package/backend/schema.sql +1593 -1510
- package/backend/server.py +2636 -2555
- package/backend/session_room.py +1180 -1148
- package/docs/THIRD_PARTY_NOTICES.md +1293 -1264
- package/docs/deployment.md +5 -1
- package/docs/en/workflow.md +15 -1
- package/docs/runner.md +28 -7
- package/docs/workflow.md +15 -1
- package/package.json +3 -2
- package/runner/adapters/api.mjs +333 -313
- package/runner/adapters/claude.mjs +12 -16
- package/runner/admin_account.mjs +12 -19
- package/runner/device_auth.mjs +369 -0
- package/runner/discuss.py +1208 -1125
- package/runner/discussion_launch.mjs +107 -12
- package/runner/discussion_service.mjs +25 -1
- package/runner/expert_prompts.mjs +132 -132
- package/runner/free_runner.mjs +982 -910
- package/runner/pinyin_table.mjs +406 -0
- package/runner/project_files.mjs +52 -3
- package/runner/relay_channel.mjs +51 -7
- package/runner/remote_launch.mjs +221 -235
- package/runner/remote_policy.mjs +156 -0
- package/runner/scheduler.mjs +459 -429
- package/runner/stt.mjs +2 -2
- package/runner/tts.mjs +74 -113
- package/runner/tts_providers.mjs +205 -0
- package/runner/webauthn.mjs +155 -0
- package/runner/workflows.mjs +371 -348
- package/web/app.js +355 -51
- package/web/modern.css +1 -1
- package/web/modern.js +5764 -3816
package/runner/stt.mjs
CHANGED
|
@@ -78,7 +78,7 @@ export async function stt_part(home, input, request = fetch) {
|
|
|
78
78
|
const { upload_id, index, data, final } = input || {};
|
|
79
79
|
if (!/^[a-f0-9-]{36}$/i.test(upload_id || '') || !Number.isInteger(index) || index < 0 || typeof data !== 'string') throw new Error('录音片段无效');
|
|
80
80
|
const settings = tts_settings(home);
|
|
81
|
-
if (!settings.
|
|
81
|
+
if (!settings.stt_key_ref) throw new Error('语音输入使用火山引擎:请在设置的“语音”中填写火山引擎 API Key');
|
|
82
82
|
const part = Buffer.from(data, 'base64');
|
|
83
83
|
if (part.length > PART_MAX_BYTES || part.length % 2) throw new Error('录音片段无效');
|
|
84
84
|
const root = join(home, 'stt');
|
|
@@ -98,7 +98,7 @@ export async function stt_part(home, input, request = fetch) {
|
|
|
98
98
|
const pcm = Buffer.concat(parts);
|
|
99
99
|
if (pcm.length > STT_MAX_BYTES) throw new Error('录音超过 5 分钟,请分段录制');
|
|
100
100
|
if (!pcm.length) return { text: '' };
|
|
101
|
-
const key = read_saved_api_key(home, TTS_BASE_URL, settings.
|
|
101
|
+
const key = read_saved_api_key(home, TTS_BASE_URL, settings.stt_key_ref);
|
|
102
102
|
return { text: await recognize(key, pcm_to_wav(pcm), request) };
|
|
103
103
|
} finally { rmSync(directory, { recursive: true, force: true }); }
|
|
104
104
|
}
|
package/runner/tts.mjs
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Read-aloud: synthesize short text with the speech service chosen in settings (Volcengine, Aliyun or Azure); keys stay
|
|
3
|
+
* in the local private store. Each service keeps its own key and voices, so switching back finds them as they were.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
3
6
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
4
7
|
import { join } from 'node:path';
|
|
5
8
|
|
|
6
9
|
import { read_saved_api_key } from './discussion_api.mjs';
|
|
7
10
|
import { atomic_json } from './files.mjs';
|
|
11
|
+
import { provider_speak, TTS_PROVIDERS } from './tts_providers.mjs';
|
|
8
12
|
|
|
13
|
+
// Volcengine's key root; its key also serves voice input.
|
|
9
14
|
export const TTS_BASE_URL = 'https://openspeech.bytedance.com';
|
|
10
|
-
const TTS_ENDPOINT = `${TTS_BASE_URL}/api/v3/tts/unidirectional`;
|
|
11
|
-
const TTS_RESOURCE_ID = 'seed-tts-2.0';
|
|
12
|
-
const TTS_DONE_CODE = 20000000;
|
|
13
|
-
const TTS_TIMEOUT_MS = 45_000;
|
|
14
15
|
// The page sends a paragraph of up to 200 characters; at about 8 KB of MP3 per second this keeps one relayed answer under 1 MiB.
|
|
15
16
|
export const TTS_MAX_TEXT = 300;
|
|
16
17
|
const MAX_CONTEXT = 200;
|
|
17
18
|
const SETTINGS_FILE = 'tts-settings.json';
|
|
18
|
-
const SPEAKER_PATTERN = /^[\w
|
|
19
|
+
const SPEAKER_PATTERN = /^[\w.:-]{1,128}$/;
|
|
20
|
+
const REGION_PATTERN = /^[a-z0-9]{2,32}$/;
|
|
21
|
+
const KEY_REF_PATTERN = /^[a-f0-9-]{36}$/i;
|
|
19
22
|
// Trailing silence the page may ask for at a paragraph or message end; it is part of the audio, so it also plays in the background.
|
|
20
23
|
const MAX_SILENCE_MS = 3000;
|
|
21
24
|
// Synthesized paragraphs are kept on this computer, so hearing the same text again is instant and not billed again.
|
|
@@ -24,59 +27,44 @@ const CACHE_DIR = 'tts-cache';
|
|
|
24
27
|
const CACHE_LIMIT_BYTES = 50 * 1024 * 1024;
|
|
25
28
|
const CACHE_KEEP_BYTES = 40 * 1024 * 1024;
|
|
26
29
|
const MAX_MEMBER_VOICES = 50;
|
|
27
|
-
// A default tone instruction (context_texts) so reading sounds told rather than recited; users may change or clear it.
|
|
30
|
+
// A default tone instruction (Volcengine's context_texts) so reading sounds told rather than recited; users may change or clear it.
|
|
28
31
|
export const TTS_DEFAULT_CONTEXT = '请用自然放松的语气朗读,像给朋友讲故事一样,有轻重缓急,句子之间自然停顿。';
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
{ id: 'zh_female_qingxinnvsheng_uranus_bigtts', label: '清新女声' },
|
|
39
|
-
{ id: 'zh_female_cancan_uranus_bigtts', label: '知性灿灿 · 女声' },
|
|
40
|
-
{ id: 'zh_female_zhixingnv_uranus_bigtts', label: '知性女声' },
|
|
41
|
-
{ id: 'zh_female_wenroushunv_uranus_bigtts', label: '温柔淑女 · 女声' },
|
|
42
|
-
{ id: 'zh_female_linjianvhai_uranus_bigtts', label: '邻家女孩' },
|
|
43
|
-
{ id: 'zh_female_shuangkuaisisi_uranus_bigtts', label: '爽快思思 · 女声' },
|
|
44
|
-
{ id: 'zh_female_gaolengyujie_uranus_bigtts', label: '高冷御姐 · 女声' },
|
|
45
|
-
{ id: 'zh_female_xiaoxue_uranus_bigtts', label: '儿童绘本 · 女声' },
|
|
46
|
-
{ id: 'zh_male_ruyaqingnian_uranus_bigtts', label: '儒雅青年 · 男声' },
|
|
47
|
-
{ id: 'zh_male_yangguangqingnian_uranus_bigtts', label: '阳光青年 · 男声' },
|
|
48
|
-
{ id: 'zh_male_yuanboxiaoshu_uranus_bigtts', label: '渊博小叔 · 男声' },
|
|
49
|
-
{ id: 'zh_male_cixingjieshuonan_uranus_bigtts', label: '磁性解说 · 男声' },
|
|
50
|
-
{ id: 'zh_male_shenyeboke_uranus_bigtts', label: '深夜播客 · 男声' },
|
|
51
|
-
{ id: 'zh_male_xuanyijieshuo_uranus_bigtts', label: '悬疑解说 · 男声' },
|
|
52
|
-
{ id: 'zh_male_baqiqingshu_uranus_bigtts', label: '霸气青叔 · 有声书男声' },
|
|
53
|
-
{ id: 'zh_male_yizhipiannan_uranus_bigtts', label: '译制片男声' },
|
|
54
|
-
{ id: 'zh_male_jieshuoxiaoming_uranus_bigtts', label: '解说小明 · 男声' },
|
|
55
|
-
{ id: 'zh_male_wennuanahu_uranus_bigtts', label: '温暖阿虎 · 男声' },
|
|
56
|
-
];
|
|
57
|
-
const STATUS_MESSAGES = {
|
|
58
|
-
40402003: '文本超出单次合成长度上限',
|
|
59
|
-
45000000: '音色不可用:请确认已在火山引擎控制台开通该豆包语音合成 2.0 音色,并核对音色 ID',
|
|
60
|
-
55000000: '火山引擎语音服务暂时出错,请稍后再试',
|
|
61
|
-
};
|
|
32
|
+
// A paragraph asked for again while its synthesis is still running (another device, a quick restart) waits for that call.
|
|
33
|
+
const in_flight = new Map();
|
|
34
|
+
const PROVIDER_IDS = TTS_PROVIDERS.map(provider => provider.id);
|
|
35
|
+
const provider_of = (id) => TTS_PROVIDERS.find(provider => provider.id === id);
|
|
36
|
+
|
|
37
|
+
/** One service's saved part with its defaults: key reference, voices and (Azure) region. */
|
|
38
|
+
function provider_settings(provider, saved = {}) {
|
|
39
|
+
return { api_key_ref: '', speaker: provider.default_speaker, member_voices: {}, file_voice: '', ...(provider.region ? { region: '' } : {}), ...saved };
|
|
40
|
+
}
|
|
62
41
|
|
|
63
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Read the saved read-aloud settings; only key references are kept, never keys. Settings saved before services could be
|
|
44
|
+
* chosen held Volcengine's key and voices at the top level and are read as Volcengine's.
|
|
45
|
+
* The chosen service's key reference and voices are also given at the top level for readers that only play.
|
|
46
|
+
*/
|
|
64
47
|
export function tts_settings(home) {
|
|
65
48
|
const path = join(home, SETTINGS_FILE);
|
|
66
49
|
const saved = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {};
|
|
67
|
-
|
|
50
|
+
const stored = saved.providers || { volcengine: { api_key_ref: saved.api_key_ref, speaker: saved.speaker, member_voices: saved.member_voices,
|
|
51
|
+
file_voice: saved.file_voice } };
|
|
52
|
+
const providers = Object.fromEntries(TTS_PROVIDERS.map(provider => [provider.id,
|
|
53
|
+
provider_settings(provider, Object.fromEntries(Object.entries(stored[provider.id] || {}).filter(([, value]) => value !== undefined)))]));
|
|
54
|
+
const provider = PROVIDER_IDS.includes(saved.provider) ? saved.provider : 'volcengine';
|
|
55
|
+
const active = providers[provider];
|
|
56
|
+
const ready = Boolean(active.api_key_ref && (!provider_of(provider).region || active.region));
|
|
57
|
+
return { provider, speech_rate: saved.speech_rate ?? 0, context: saved.context ?? TTS_DEFAULT_CONTEXT, providers, catalog: TTS_PROVIDERS,
|
|
58
|
+
api_key_ref: ready ? active.api_key_ref : '', speaker: active.speaker, member_voices: active.member_voices, file_voice: active.file_voice,
|
|
59
|
+
stt_key_ref: providers.volcengine.api_key_ref };
|
|
68
60
|
}
|
|
69
61
|
|
|
70
|
-
/** Validate
|
|
71
|
-
|
|
72
|
-
const speaker = String(input.speaker
|
|
62
|
+
/** Validate one service's part as the page sent it. */
|
|
63
|
+
function check_provider(provider, input = {}) {
|
|
64
|
+
const speaker = String(input.speaker || provider.default_speaker).trim();
|
|
73
65
|
if (!SPEAKER_PATTERN.test(speaker)) throw new Error('请填写有效的音色 ID');
|
|
74
|
-
const
|
|
75
|
-
if (!
|
|
76
|
-
const context = String(input.context ?? '').trim();
|
|
77
|
-
if (context.length > MAX_CONTEXT) throw new Error(`语气提示不超过 ${MAX_CONTEXT} 字`);
|
|
78
|
-
const api_key_ref = input.api_key_ref || undefined;
|
|
79
|
-
if (api_key_ref && !/^[a-f0-9-]{36}$/i.test(api_key_ref)) throw new Error('API Key 引用无效,请重新填写');
|
|
66
|
+
const api_key_ref = input.api_key_ref || '';
|
|
67
|
+
if (api_key_ref && !KEY_REF_PATTERN.test(api_key_ref)) throw new Error('API Key 引用无效,请重新填写');
|
|
80
68
|
// Voices chosen per AI platform; members of other platforms, the user and program messages use the default voice.
|
|
81
69
|
const member_voices = {};
|
|
82
70
|
for (const [name, voice] of Object.entries(input.member_voices || {}).slice(0, MAX_MEMBER_VOICES)) {
|
|
@@ -87,37 +75,24 @@ export function save_tts_settings(home, input) {
|
|
|
87
75
|
// The voice for reading project files; empty uses the default voice.
|
|
88
76
|
const file_voice = String(input.file_voice ?? '').trim();
|
|
89
77
|
if (file_voice && !SPEAKER_PATTERN.test(file_voice)) throw new Error('文件音色无效,请重新选择');
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return tts_settings(home);
|
|
78
|
+
const region = String(input.region ?? '').trim().toLowerCase();
|
|
79
|
+
if (region && !REGION_PATTERN.test(region)) throw new Error('Azure 区域应为如 eastasia 的小写英文与数字');
|
|
80
|
+
return { api_key_ref, speaker, member_voices, file_voice, ...(provider.region ? { region } : {}) };
|
|
94
81
|
}
|
|
95
82
|
|
|
96
|
-
/**
|
|
97
|
-
export function
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
return objects;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Turn one service code into a message that says what to fix. */
|
|
114
|
-
function tts_error(code, message) {
|
|
115
|
-
return new Error(`语音合成失败(${code}):${STATUS_MESSAGES[code] || message || '请核对 API Key 与音色'}`);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Flatten the per-word timings the service returns with subtitles, as { w: word, t: start in ms }. */
|
|
119
|
-
function word_marks(objects) {
|
|
120
|
-
return objects.flatMap(item => item.sentence?.words || []).map(word => ({ w: String(word.word || ''), t: Math.round(Number(word.startTime || 0) * 1000) }));
|
|
83
|
+
/** Validate and save the read-aloud settings the page edits: the chosen service, each service's part, rate and tone. */
|
|
84
|
+
export function save_tts_settings(home, input) {
|
|
85
|
+
const provider = String(input.provider || 'volcengine');
|
|
86
|
+
if (!PROVIDER_IDS.includes(provider)) throw new Error('请选择语音服务');
|
|
87
|
+
const speech_rate = Number(input.speech_rate ?? 0);
|
|
88
|
+
if (!Number.isInteger(speech_rate) || speech_rate < -50 || speech_rate > 100) throw new Error('语速须为 -50 到 100 的整数');
|
|
89
|
+
const context = String(input.context ?? '').trim();
|
|
90
|
+
if (context.length > MAX_CONTEXT) throw new Error(`语气提示不超过 ${MAX_CONTEXT} 字`);
|
|
91
|
+
const current = tts_settings(home).providers;
|
|
92
|
+
const providers = Object.fromEntries(TTS_PROVIDERS.map(item => [item.id, check_provider(item, input.providers?.[item.id] || current[item.id])]));
|
|
93
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
94
|
+
atomic_json(join(home, SETTINGS_FILE), { provider, speech_rate, context, providers });
|
|
95
|
+
return tts_settings(home);
|
|
121
96
|
}
|
|
122
97
|
|
|
123
98
|
/** Read a cached synthesis, refreshing its time so the most recently heard stay; null when absent or unreadable. */
|
|
@@ -143,47 +118,33 @@ function cache_write(directory, path, value) {
|
|
|
143
118
|
}
|
|
144
119
|
|
|
145
120
|
/**
|
|
146
|
-
* Synthesize one paragraph and return it as base64 MP3 with word timings for highlighting.
|
|
121
|
+
* Synthesize one paragraph with the chosen service and return it as base64 MP3 with word timings for highlighting.
|
|
147
122
|
* `speaker` may name another voice for this paragraph (a member's own voice); `silence_ms` appends trailing silence.
|
|
148
|
-
* Requests of one reading pass share `section_id`, so
|
|
123
|
+
* Requests of one reading pass share `section_id`, so Volcengine keeps the earlier paragraphs as context.
|
|
124
|
+
* @param {{ request?: Function, open?: Function }} [transport] `fetch` and WebSocket factory, replaceable in tests.
|
|
149
125
|
*/
|
|
150
|
-
export async function tts_speak(home, input,
|
|
126
|
+
export async function tts_speak(home, input, transport) {
|
|
151
127
|
const text = typeof input?.text === 'string' ? input.text.trim() : '';
|
|
152
128
|
if (!text) throw new Error('朗读文本为空');
|
|
153
129
|
if (text.length > TTS_MAX_TEXT) throw new Error(`单次朗读不超过 ${TTS_MAX_TEXT} 字`);
|
|
154
130
|
const settings = tts_settings(home);
|
|
155
|
-
|
|
156
|
-
const
|
|
131
|
+
const provider = provider_of(settings.provider);
|
|
132
|
+
const own = settings.providers[provider.id];
|
|
133
|
+
if (!own.api_key_ref) throw new Error(`尚未配置语音:请在设置的“语音”中填写${provider.label} API Key`);
|
|
134
|
+
const speaker = SPEAKER_PATTERN.test(input.speaker || '') ? input.speaker : own.speaker;
|
|
157
135
|
const silence = Math.max(0, Math.min(MAX_SILENCE_MS, Math.round(Number(input.silence_ms) || 0)));
|
|
136
|
+
const context = provider.context ? settings.context : '';
|
|
158
137
|
const directory = join(home, CACHE_DIR);
|
|
159
|
-
const cache_path = join(directory, `${createHash('sha256').update(JSON.stringify([text, speaker, settings.speech_rate,
|
|
138
|
+
const cache_path = join(directory, `${createHash('sha256').update(JSON.stringify([text, speaker, settings.speech_rate, context, silence,
|
|
139
|
+
...(provider.id === 'volcengine' ? [] : [provider.id])])).digest('hex')}.json`);
|
|
160
140
|
const cached = existsSync(cache_path) && cache_read(cache_path);
|
|
161
141
|
if (cached?.audio) return { ...cached, cached: true };
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
...(Object.keys(additions).length ? { additions: JSON.stringify(additions) } : {}) } };
|
|
169
|
-
let response;
|
|
170
|
-
try {
|
|
171
|
-
response = await request(TTS_ENDPOINT, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(TTS_TIMEOUT_MS),
|
|
172
|
-
headers: { 'Content-Type': 'application/json', 'X-Api-Key': key, 'X-Api-Resource-Id': TTS_RESOURCE_ID, 'X-Api-Request-Id': randomUUID() },
|
|
173
|
-
body: JSON.stringify(body) });
|
|
174
|
-
} catch { throw new Error('语音合成请求超时或连接失败,请检查网络后重试'); }
|
|
175
|
-
if (response.status === 401 || response.status === 403) throw new Error('火山引擎拒绝了请求:API Key 无效或未开通豆包语音合成 2.0');
|
|
176
|
-
const objects = split_json_objects(await response.text());
|
|
177
|
-
const chunks = [];
|
|
178
|
-
for (const item of objects) {
|
|
179
|
-
if (item.code === TTS_DONE_CODE) {
|
|
180
|
-
const result = { audio: Buffer.concat(chunks).toString('base64'), format: 'mp3', marks: word_marks(objects) };
|
|
181
|
-
cache_write(directory, cache_path, result);
|
|
182
|
-
return result;
|
|
183
|
-
}
|
|
184
|
-
if (item.code !== 0) throw tts_error(item.code, item.message);
|
|
185
|
-
if (item.data) chunks.push(Buffer.from(item.data, 'base64'));
|
|
142
|
+
if (!in_flight.has(cache_path)) {
|
|
143
|
+
const key = read_saved_api_key(home, provider.key_base_url, own.api_key_ref);
|
|
144
|
+
const section_id = KEY_REF_PATTERN.test(input.section_id || '') ? input.section_id : undefined;
|
|
145
|
+
in_flight.set(cache_path, provider_speak(provider.id, { key, text, speaker, rate: settings.speech_rate, context, silence, section_id, region: own.region }, transport)
|
|
146
|
+
.then((result) => { cache_write(directory, cache_path, result); return result; })
|
|
147
|
+
.finally(() => in_flight.delete(cache_path)));
|
|
186
148
|
}
|
|
187
|
-
|
|
188
|
-
throw new Error('语音合成未完成:火山引擎没有返回结束标记');
|
|
149
|
+
return in_flight.get(cache_path);
|
|
189
150
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-aloud providers: what each speech service offers (voices, key root, console) and how one paragraph is
|
|
3
|
+
* synthesized there. Every provider answers the same shape, `{ audio: base64 MP3, format: "mp3", marks: [{ w, t }] }`;
|
|
4
|
+
* `marks` holds per-character start times in ms when the service gives them, otherwise it is empty and the page spreads
|
|
5
|
+
* the highlight by length.
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
const TTS_TIMEOUT_MS = 45_000;
|
|
10
|
+
const VOLC_ENDPOINT = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional';
|
|
11
|
+
const VOLC_RESOURCE_ID = 'seed-tts-2.0';
|
|
12
|
+
const VOLC_DONE_CODE = 20000000;
|
|
13
|
+
const VOLC_MESSAGES = {
|
|
14
|
+
40402003: '文本超出单次合成长度上限',
|
|
15
|
+
45000000: '音色不可用:请确认已在火山引擎控制台开通该豆包语音合成 2.0 音色,并核对音色 ID',
|
|
16
|
+
55000000: '火山引擎语音服务暂时出错,请稍后再试',
|
|
17
|
+
};
|
|
18
|
+
const ALIYUN_SOCKET = 'wss://dashscope.aliyuncs.com/api-ws/v1/inference';
|
|
19
|
+
const ALIYUN_MODEL = 'cosyvoice-v3-flash';
|
|
20
|
+
// Aliyun and Azure answer 24 kHz mono MP3, so trailing silence is appended as silent frames of that format.
|
|
21
|
+
const SAMPLE_RATE = 24000;
|
|
22
|
+
// One silent MPEG-2 Layer III frame (24 kHz, 32 kbps, mono): a header and zeroed side info, 576 samples = 24 ms.
|
|
23
|
+
const SILENT_FRAME = Buffer.concat([Buffer.from([0xff, 0xf3, 0x44, 0xc0]), Buffer.alloc(92)]);
|
|
24
|
+
const SILENT_FRAME_MS = 24;
|
|
25
|
+
const AZURE_FORMAT = 'audio-24khz-48kbitrate-mono-mp3';
|
|
26
|
+
|
|
27
|
+
const VOLC_VOICES = [
|
|
28
|
+
['zh_female_vv_uranus_bigtts', 'Vivi · 通用女声'], ['zh_female_xiaohe_uranus_bigtts', '小何 · 通用女声'],
|
|
29
|
+
['zh_male_m191_uranus_bigtts', '云舟 · 通用男声'], ['zh_male_taocheng_uranus_bigtts', '小天 · 通用男声'],
|
|
30
|
+
['zh_male_liufei_uranus_bigtts', '刘飞 · 通用男声'], ['zh_female_sophie_uranus_bigtts', '魅力苏菲 · 女声'],
|
|
31
|
+
['zh_female_qingxinnvsheng_uranus_bigtts', '清新女声'], ['zh_female_cancan_uranus_bigtts', '知性灿灿 · 女声'],
|
|
32
|
+
['zh_female_zhixingnv_uranus_bigtts', '知性女声'], ['zh_female_wenroushunv_uranus_bigtts', '温柔淑女 · 女声'],
|
|
33
|
+
['zh_female_linjianvhai_uranus_bigtts', '邻家女孩'], ['zh_female_shuangkuaisisi_uranus_bigtts', '爽快思思 · 女声'],
|
|
34
|
+
['zh_female_gaolengyujie_uranus_bigtts', '高冷御姐 · 女声'], ['zh_female_xiaoxue_uranus_bigtts', '儿童绘本 · 女声'],
|
|
35
|
+
['zh_male_ruyaqingnian_uranus_bigtts', '儒雅青年 · 男声'], ['zh_male_yangguangqingnian_uranus_bigtts', '阳光青年 · 男声'],
|
|
36
|
+
['zh_male_yuanboxiaoshu_uranus_bigtts', '渊博小叔 · 男声'], ['zh_male_cixingjieshuonan_uranus_bigtts', '磁性解说 · 男声'],
|
|
37
|
+
['zh_male_shenyeboke_uranus_bigtts', '深夜播客 · 男声'], ['zh_male_xuanyijieshuo_uranus_bigtts', '悬疑解说 · 男声'],
|
|
38
|
+
['zh_male_baqiqingshu_uranus_bigtts', '霸气青叔 · 有声书男声'], ['zh_male_yizhipiannan_uranus_bigtts', '译制片男声'],
|
|
39
|
+
['zh_male_jieshuoxiaoming_uranus_bigtts', '解说小明 · 男声'], ['zh_male_wennuanahu_uranus_bigtts', '温暖阿虎 · 男声'],
|
|
40
|
+
];
|
|
41
|
+
// System voices of cosyvoice-v3-flash that read Mandarin and support per-character timings (voice list, 2026-11-04).
|
|
42
|
+
const ALIYUN_VOICES = [
|
|
43
|
+
['longxiaochun_v3', '龙小淳 · 知性女声'], ['longanwen_v3', '龙安温 · 优雅女声'], ['longanli_v3', '龙安莉 · 利落女声'],
|
|
44
|
+
['longyingtao_v3', '龙应桃 · 温柔女声'], ['longyuan_v3', '龙媛 · 治愈女声'], ['longyue_v3', '龙悦 · 磁性女声'],
|
|
45
|
+
['longxing_v3', '龙星 · 邻家女声'], ['longwan_v3', '龙婉 · 细腻女声'], ['longmiao_v3', '龙妙 · 抑扬顿挫女声'],
|
|
46
|
+
['longxiaoxia_v3', '龙小夏 · 沉稳女声'], ['longanyang', '龙安洋 · 阳光男声'], ['longanyun_v3', '龙安昀 · 暖男'],
|
|
47
|
+
['longanlang_v3', '龙安朗 · 清爽男声'], ['longanzhi_v3', '龙安智 · 睿智男声'], ['longcheng_v3', '龙橙 · 智慧青年'],
|
|
48
|
+
['longtian_v3', '龙天 · 磁性男声'], ['longsanshu_v3', '龙三叔 · 沉稳男声'], ['longxiu_v3', '龙修 · 说书男声'],
|
|
49
|
+
['longshu_v3', '龙书 · 沉稳青年'], ['longnan_v3', '龙楠 · 睿智青年'],
|
|
50
|
+
];
|
|
51
|
+
const AZURE_VOICES = [
|
|
52
|
+
['zh-CN-XiaoxiaoNeural', '晓晓 · 女声'], ['zh-CN-XiaoxiaoMultilingualNeural', '晓晓(多语言)· 女声'],
|
|
53
|
+
['zh-CN-XiaochenNeural', '晓辰 · 女声'], ['zh-CN-XiaoyiNeural', '晓伊 · 女声'], ['zh-CN-XiaohanNeural', '晓涵 · 女声'],
|
|
54
|
+
['zh-CN-XiaomoNeural', '晓墨 · 女声'], ['zh-CN-YunxiNeural', '云希 · 男声'],
|
|
55
|
+
['zh-CN-YunjianNeural', '云健 · 男声'], ['zh-CN-YunyangNeural', '云扬 · 新闻男声'], ['zh-CN-YunfengNeural', '云枫 · 男声'],
|
|
56
|
+
['zh-CN-YunzeNeural', '云泽 · 男声'],
|
|
57
|
+
];
|
|
58
|
+
const voices = (list) => list.map(([id, label]) => ({ id, label }));
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The speech services the page can choose between. `key_base_url` is the root a saved key is bound to; `context` says
|
|
62
|
+
* whether the tone instruction applies; `region` says whether the service also needs a region; `voice_input` marks the
|
|
63
|
+
* one whose key also serves voice input.
|
|
64
|
+
*/
|
|
65
|
+
export const TTS_PROVIDERS = [
|
|
66
|
+
{ id: 'volcengine', label: '火山引擎', key_base_url: 'https://openspeech.bytedance.com', console_url: 'https://console.volcengine.com/speech/new',
|
|
67
|
+
note: '豆包语音合成 2.0,约 3 元/万字(字数包更便宜);语音输入也用这个 Key。', voices: voices(VOLC_VOICES),
|
|
68
|
+
default_speaker: 'zh_female_vv_uranus_bigtts', context: true, region: false, voice_input: true },
|
|
69
|
+
{ id: 'aliyun', label: '阿里云百炼', key_base_url: 'https://dashscope.aliyuncs.com', console_url: 'https://bailian.console.aliyun.com/?tab=model#/api-key',
|
|
70
|
+
note: 'CosyVoice v3-flash,1 元/万字符(一个汉字计 2 字符,约 2 元/万字)。', voices: voices(ALIYUN_VOICES),
|
|
71
|
+
default_speaker: 'longxiaochun_v3', context: false, region: false, voice_input: false },
|
|
72
|
+
{ id: 'azure', label: 'Azure', key_base_url: 'https://tts.speech.microsoft.com', console_url: 'https://portal.azure.com/#create/Microsoft.CognitiveServicesSpeechServices',
|
|
73
|
+
note: '神经语音,免费档 F0 每月 50 万字符(一个汉字计 2 字符,约 25 万字);付费档约 2 元/万字;高亮按时长估算。', voices: voices(AZURE_VOICES),
|
|
74
|
+
default_speaker: 'zh-CN-XiaoxiaoNeural', context: false, region: true, voice_input: false },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** Trailing silence as silent MP3 frames matching Aliyun's and Azure's output. */
|
|
78
|
+
function silence_frames(silence_ms) {
|
|
79
|
+
return Buffer.concat(Array(Math.round(silence_ms / SILENT_FRAME_MS)).fill(SILENT_FRAME));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Split a chunked answer made of concatenated JSON objects, newline-separated or not. */
|
|
83
|
+
export function split_json_objects(text) {
|
|
84
|
+
const objects = [];
|
|
85
|
+
let depth = 0, start = -1, in_string = false, escaped = false;
|
|
86
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
87
|
+
const character = text[index];
|
|
88
|
+
if (in_string) {
|
|
89
|
+
if (escaped) escaped = false;
|
|
90
|
+
else if (character === '\\') escaped = true;
|
|
91
|
+
else if (character === '"') in_string = false;
|
|
92
|
+
} else if (character === '"') in_string = true;
|
|
93
|
+
else if (character === '{') { if (depth++ === 0) start = index; }
|
|
94
|
+
else if (character === '}' && depth > 0 && --depth === 0) objects.push(JSON.parse(text.slice(start, index + 1)));
|
|
95
|
+
}
|
|
96
|
+
return objects;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Volcengine Doubao TTS 2.0: one-way chunked JSON; silence, tone and the shared section are sent as `additions`. */
|
|
100
|
+
async function volc_speak({ key, text, speaker, rate, context, silence, section_id }, request) {
|
|
101
|
+
const additions = { ...(context ? { context_texts: [context] } : {}), ...(section_id ? { section_id } : {}),
|
|
102
|
+
...(silence ? { silence_duration: silence } : {}) };
|
|
103
|
+
const body = { user: { uid: 'codiscuss' }, req_params: { text, speaker,
|
|
104
|
+
audio_params: { format: 'mp3', sample_rate: SAMPLE_RATE, speech_rate: rate, enable_subtitle: true },
|
|
105
|
+
...(Object.keys(additions).length ? { additions: JSON.stringify(additions) } : {}) } };
|
|
106
|
+
let response;
|
|
107
|
+
try {
|
|
108
|
+
response = await request(VOLC_ENDPOINT, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(TTS_TIMEOUT_MS),
|
|
109
|
+
headers: { 'Content-Type': 'application/json', 'X-Api-Key': key, 'X-Api-Resource-Id': VOLC_RESOURCE_ID, 'X-Api-Request-Id': randomUUID() },
|
|
110
|
+
body: JSON.stringify(body) });
|
|
111
|
+
} catch { throw new Error('语音合成请求超时或连接失败,请检查网络后重试'); }
|
|
112
|
+
if (response.status === 401 || response.status === 403) throw new Error('火山引擎拒绝了请求:API Key 无效或未开通豆包语音合成 2.0');
|
|
113
|
+
const objects = split_json_objects(await response.text());
|
|
114
|
+
const chunks = [];
|
|
115
|
+
for (const item of objects) {
|
|
116
|
+
if (item.code === VOLC_DONE_CODE) {
|
|
117
|
+
const marks = objects.flatMap(part => part.sentence?.words || [])
|
|
118
|
+
.map(word => ({ w: String(word.word || ''), t: Math.round(Number(word.startTime || 0) * 1000) }));
|
|
119
|
+
return { audio: Buffer.concat(chunks).toString('base64'), format: 'mp3', marks };
|
|
120
|
+
}
|
|
121
|
+
if (item.code !== 0) throw new Error(`语音合成失败(${item.code}):${VOLC_MESSAGES[item.code] || item.message || '请核对 API Key 与音色'}`);
|
|
122
|
+
if (item.data) chunks.push(Buffer.from(item.data, 'base64'));
|
|
123
|
+
}
|
|
124
|
+
if (!response.ok) throw new Error(`语音合成失败:火山引擎返回 HTTP ${response.status}`);
|
|
125
|
+
throw new Error('语音合成未完成:火山引擎没有返回结束标记');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Per-character timings over all sentences; each sentence's timings may restart at zero, so they are laid end to end. */
|
|
129
|
+
function aliyun_marks(sentences) {
|
|
130
|
+
const marks = [];
|
|
131
|
+
let base = 0, last = 0;
|
|
132
|
+
for (const [, words] of [...sentences].sort((a, b) => a[0] - b[0])) {
|
|
133
|
+
if (Number(words[0].begin_time) + base < last) base = last;
|
|
134
|
+
for (const word of words) marks.push({ w: String(word.text || ''), t: Number(word.begin_time) + base });
|
|
135
|
+
last = Number(words.at(-1).end_time) + base;
|
|
136
|
+
}
|
|
137
|
+
return marks;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Aliyun Bailian CosyVoice over its duplex WebSocket: run-task, the text, finish-task; audio arrives as binary frames. */
|
|
141
|
+
function aliyun_speak({ key, text, speaker, rate, silence }, open) {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const task_id = randomUUID();
|
|
144
|
+
const chunks = [], sentences = new Map();
|
|
145
|
+
let settled = false;
|
|
146
|
+
const socket = open(ALIYUN_SOCKET, { headers: { Authorization: `Bearer ${key}` } });
|
|
147
|
+
const finish = (error, value) => {
|
|
148
|
+
if (settled) return;
|
|
149
|
+
settled = true; clearTimeout(timer);
|
|
150
|
+
try { socket.close(); } catch { /* Already closed. */ }
|
|
151
|
+
if (error) reject(error); else resolve(value);
|
|
152
|
+
};
|
|
153
|
+
const timer = setTimeout(() => finish(new Error('语音合成请求超时,请检查网络后重试')), TTS_TIMEOUT_MS);
|
|
154
|
+
const send = (action, payload) => socket.send(JSON.stringify({ header: { action, task_id, streaming: 'duplex' }, payload }));
|
|
155
|
+
socket.binaryType = 'arraybuffer';
|
|
156
|
+
socket.onopen = () => send('run-task', { task_group: 'audio', task: 'tts', function: 'SpeechSynthesizer', model: ALIYUN_MODEL, input: {},
|
|
157
|
+
parameters: { text_type: 'PlainText', voice: speaker, format: 'mp3', sample_rate: SAMPLE_RATE, rate: 1 + rate / 100, word_timestamp_enabled: true } });
|
|
158
|
+
socket.onmessage = ({ data }) => {
|
|
159
|
+
if (typeof data !== 'string') { chunks.push(Buffer.from(data)); return; }
|
|
160
|
+
const { header = {}, payload = {} } = JSON.parse(data);
|
|
161
|
+
const sentence = payload.output?.sentence;
|
|
162
|
+
if (sentence?.words?.length) sentences.set(sentence.index, sentence.words);
|
|
163
|
+
if (header.event === 'task-started') { send('continue-task', { input: { text } }); send('finish-task', { input: {} }); }
|
|
164
|
+
else if (header.event === 'task-finished') {
|
|
165
|
+
finish(null, { audio: Buffer.concat([...chunks, silence_frames(silence)]).toString('base64'), format: 'mp3', marks: aliyun_marks(sentences) });
|
|
166
|
+
} else if (header.event === 'task-failed') finish(new Error(`语音合成失败(${header.error_code}):${header.error_message || '请核对 API Key 与音色'}`));
|
|
167
|
+
};
|
|
168
|
+
socket.onerror = () => finish(new Error('连接阿里云百炼失败:请核对 API Key,并确认已开通 CosyVoice 语音合成'));
|
|
169
|
+
socket.onclose = () => finish(new Error('阿里云百炼提前断开了连接,请稍后再试'));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Escape text for SSML. */
|
|
174
|
+
const xml = (text) => text.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`);
|
|
175
|
+
|
|
176
|
+
/** Azure neural voices over REST with SSML; the REST answer carries no word timings. */
|
|
177
|
+
async function azure_speak({ key, region, text, speaker, rate, silence }, request) {
|
|
178
|
+
if (!region) throw new Error('尚未填写 Azure 区域:请在设置的“语音”中填写语音资源所在区域');
|
|
179
|
+
const ssml = `<speak version="1.0" xml:lang="zh-CN"><voice name="${xml(speaker)}"><prosody rate="${rate >= 0 ? '+' : ''}${rate}%">${xml(text)}</prosody></voice></speak>`;
|
|
180
|
+
let response;
|
|
181
|
+
try {
|
|
182
|
+
response = await request(`https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`, { method: 'POST', redirect: 'error',
|
|
183
|
+
signal: AbortSignal.timeout(TTS_TIMEOUT_MS), headers: { 'Ocp-Apim-Subscription-Key': key, 'Content-Type': 'application/ssml+xml',
|
|
184
|
+
'X-Microsoft-OutputFormat': AZURE_FORMAT, 'User-Agent': 'codiscuss' }, body: ssml });
|
|
185
|
+
} catch { throw new Error('语音合成请求超时或连接失败,请检查网络与 Azure 区域后重试'); }
|
|
186
|
+
if (response.status === 401 || response.status === 403) throw new Error('Azure 拒绝了请求:请核对 API Key 与区域是否属于同一个语音资源');
|
|
187
|
+
if (response.status === 429) throw new Error('Azure 请求过于频繁(免费档每分钟 20 次),请稍后再试');
|
|
188
|
+
if (!response.ok) throw new Error(`语音合成失败:Azure 返回 HTTP ${response.status},请核对音色名称`);
|
|
189
|
+
const audio = Buffer.from(await response.arrayBuffer());
|
|
190
|
+
return { audio: Buffer.concat([audio, silence_frames(silence)]).toString('base64'), format: 'mp3', marks: [] };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const SPEAKERS = { volcengine: volc_speak, aliyun: aliyun_speak, azure: azure_speak };
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Synthesize one paragraph with one provider.
|
|
197
|
+
* @param {string} provider One of TTS_PROVIDERS' ids.
|
|
198
|
+
* @param {{ key: string, text: string, speaker: string, rate: number, context?: string, silence: number, section_id?: string, region?: string }} input
|
|
199
|
+
* `rate` is -50..100 (percent faster), `silence` the trailing silence in ms.
|
|
200
|
+
* @param {{ request?: Function, open?: Function }} [transport] `fetch` and WebSocket factory, replaceable in tests.
|
|
201
|
+
* @returns {Promise<{ audio: string, format: string, marks: { w: string, t: number }[] }>}
|
|
202
|
+
*/
|
|
203
|
+
export function provider_speak(provider, input, { request = fetch, open = (url, init) => new WebSocket(url, init) } = {}) {
|
|
204
|
+
return SPEAKERS[provider](input, provider === 'aliyun' ? open : request);
|
|
205
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey (WebAuthn) checks for this computer, with Node's built-in WebCrypto only: registration yields the credential's
|
|
3
|
+
* public key, and an assertion proves the user verified on the device. Only ES256 and RS256 keys are accepted, and
|
|
4
|
+
* attestation is not checked (attestation "none" is requested). backend/worker.mjs carries the same checks for login.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
const FLAG_USER_PRESENT = 0x01;
|
|
9
|
+
const FLAG_USER_VERIFIED = 0x04;
|
|
10
|
+
const FLAG_ATTESTED_DATA = 0x40;
|
|
11
|
+
const ES256 = -7;
|
|
12
|
+
const RS256 = -257;
|
|
13
|
+
|
|
14
|
+
export const b64url = bytes => Buffer.from(bytes).toString('base64url');
|
|
15
|
+
export const from_b64url = text => new Uint8Array(Buffer.from(String(text || ''), 'base64url'));
|
|
16
|
+
const sha256 = bytes => new Uint8Array(createHash('sha256').update(bytes).digest());
|
|
17
|
+
|
|
18
|
+
/** Decode one CBOR item at `offset`; supports the subset WebAuthn uses (ints, bytes, text, arrays, maps, simple values). */
|
|
19
|
+
function cbor_item(bytes, offset) {
|
|
20
|
+
const head = bytes[offset];
|
|
21
|
+
if (head === undefined) throw new Error('CBOR 数据不完整');
|
|
22
|
+
const major = head >> 5, info = head & 31;
|
|
23
|
+
let length, next = offset + 1;
|
|
24
|
+
if (info < 24) length = info;
|
|
25
|
+
else if (info === 24) { length = bytes[next]; next += 1; }
|
|
26
|
+
else if (info === 25) { length = (bytes[next] << 8) | bytes[next + 1]; next += 2; }
|
|
27
|
+
else if (info === 26) { length = ((bytes[next] << 24) >>> 0) + (bytes[next + 1] << 16) + (bytes[next + 2] << 8) + bytes[next + 3]; next += 4; }
|
|
28
|
+
else throw new Error('不支持的 CBOR 长度');
|
|
29
|
+
if (major === 0) return [length, next];
|
|
30
|
+
if (major === 1) return [-1 - length, next];
|
|
31
|
+
if (major === 2 || major === 3) {
|
|
32
|
+
if (next + length > bytes.length) throw new Error('CBOR 数据不完整');
|
|
33
|
+
const chunk = bytes.subarray(next, next + length);
|
|
34
|
+
return [major === 2 ? chunk : new TextDecoder().decode(chunk), next + length];
|
|
35
|
+
}
|
|
36
|
+
if (major === 4) {
|
|
37
|
+
const items = [];
|
|
38
|
+
for (let index = 0; index < length; index++) { const [item, after] = cbor_item(bytes, next); items.push(item); next = after; }
|
|
39
|
+
return [items, next];
|
|
40
|
+
}
|
|
41
|
+
if (major === 5) {
|
|
42
|
+
const map = new Map();
|
|
43
|
+
for (let index = 0; index < length; index++) {
|
|
44
|
+
const [key, after_key] = cbor_item(bytes, next);
|
|
45
|
+
const [value, after_value] = cbor_item(bytes, after_key);
|
|
46
|
+
map.set(key, value);
|
|
47
|
+
next = after_value;
|
|
48
|
+
}
|
|
49
|
+
return [map, next];
|
|
50
|
+
}
|
|
51
|
+
if (major === 7 && [20, 21, 22].includes(info)) return [info === 21 ? true : info === 20 ? false : null, next];
|
|
52
|
+
throw new Error('不支持的 CBOR 类型');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Decode a whole CBOR value; returns [value, bytes used]. */
|
|
56
|
+
export function decode_cbor(bytes) {
|
|
57
|
+
return cbor_item(bytes, 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Turn a COSE public key into a JWK and its WebCrypto algorithm; anything but ES256 or RS256 is refused. */
|
|
61
|
+
function cose_to_jwk(cose) {
|
|
62
|
+
const alg = cose.get(3);
|
|
63
|
+
if (alg === ES256 && cose.get(1) === 2 && cose.get(-1) === 1) {
|
|
64
|
+
return { alg, jwk: { kty: 'EC', crv: 'P-256', x: b64url(cose.get(-2)), y: b64url(cose.get(-3)) } };
|
|
65
|
+
}
|
|
66
|
+
if (alg === RS256 && cose.get(1) === 3) return { alg, jwk: { kty: 'RSA', n: b64url(cose.get(-1)), e: b64url(cose.get(-2)), alg: 'RS256' } };
|
|
67
|
+
throw new Error('通行密钥的算法不受支持,只接受 ES256 或 RS256');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Split authenticator data into its fields; the attested credential is present only on registration. */
|
|
71
|
+
function parse_auth_data(bytes) {
|
|
72
|
+
if (bytes.length < 37) throw new Error('通行密钥数据不完整');
|
|
73
|
+
const result = { rp_id_hash: bytes.subarray(0, 32), flags: bytes[32], sign_count: new DataView(bytes.buffer, bytes.byteOffset + 33, 4).getUint32(0) };
|
|
74
|
+
if (result.flags & FLAG_ATTESTED_DATA) {
|
|
75
|
+
const id_length = (bytes[53] << 8) | bytes[54];
|
|
76
|
+
result.credential_id = bytes.subarray(55, 55 + id_length);
|
|
77
|
+
[result.cose] = decode_cbor(bytes.subarray(55 + id_length));
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Check the browser's client data: ceremony type, exact challenge and exact origin. */
|
|
83
|
+
function check_client_data(client_data_json, { type, challenge, origin }) {
|
|
84
|
+
let data;
|
|
85
|
+
try { data = JSON.parse(new TextDecoder().decode(client_data_json)); } catch { throw new Error('通行密钥数据无效'); }
|
|
86
|
+
if (data.type !== type) throw new Error('通行密钥操作类型不符');
|
|
87
|
+
if (data.challenge !== challenge) throw new Error('通行密钥挑战不符或已过期');
|
|
88
|
+
if (data.origin !== origin) throw new Error('通行密钥来自其他网站');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Check the relying party and that the user was present and verified (fingerprint, face or device PIN). */
|
|
92
|
+
function check_auth_data(auth, rp_id) {
|
|
93
|
+
if (Buffer.compare(Buffer.from(auth.rp_id_hash), Buffer.from(sha256(new TextEncoder().encode(rp_id)))) !== 0) throw new Error('通行密钥不属于这个网站');
|
|
94
|
+
if (!(auth.flags & FLAG_USER_PRESENT) || !(auth.flags & FLAG_USER_VERIFIED)) throw new Error('需要在设备上验证身份(指纹、面容或设备密码)');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Verify a new passkey from navigator.credentials.create(); `challenge` is the base64url challenge the browser signed.
|
|
99
|
+
* Returns { id, public_key, alg, sign_count } to keep.
|
|
100
|
+
*/
|
|
101
|
+
export function verify_registration({ client_data_json, attestation_object }, { challenge, origin, rp_id }) {
|
|
102
|
+
const client_data = from_b64url(client_data_json);
|
|
103
|
+
check_client_data(client_data, { type: 'webauthn.create', challenge, origin });
|
|
104
|
+
const [attestation] = decode_cbor(from_b64url(attestation_object));
|
|
105
|
+
if (!(attestation instanceof Map) || !(attestation.get('authData') instanceof Uint8Array)) throw new Error('通行密钥数据无效');
|
|
106
|
+
const auth = parse_auth_data(attestation.get('authData'));
|
|
107
|
+
check_auth_data(auth, rp_id);
|
|
108
|
+
if (!auth.credential_id || !auth.cose) throw new Error('通行密钥数据缺少公钥');
|
|
109
|
+
const { alg, jwk } = cose_to_jwk(auth.cose);
|
|
110
|
+
return { id: b64url(auth.credential_id), public_key: jwk, alg, sign_count: auth.sign_count };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Convert a DER ECDSA signature into the raw r‖s form WebCrypto verifies. */
|
|
114
|
+
function der_to_raw(der) {
|
|
115
|
+
if (der[0] !== 0x30) throw new Error('通行密钥签名格式无效');
|
|
116
|
+
let offset = 2;
|
|
117
|
+
const part = () => {
|
|
118
|
+
if (der[offset] !== 0x02) throw new Error('通行密钥签名格式无效');
|
|
119
|
+
const length = der[offset + 1];
|
|
120
|
+
let value = der.subarray(offset + 2, offset + 2 + length);
|
|
121
|
+
offset += 2 + length;
|
|
122
|
+
while (value.length > 32 && value[0] === 0) value = value.subarray(1);
|
|
123
|
+
if (value.length > 32) throw new Error('通行密钥签名格式无效');
|
|
124
|
+
const padded = new Uint8Array(32);
|
|
125
|
+
padded.set(value, 32 - value.length);
|
|
126
|
+
return padded;
|
|
127
|
+
};
|
|
128
|
+
const r = part(), s = part();
|
|
129
|
+
return Uint8Array.from([...r, ...s]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Verify an assertion from navigator.credentials.get() against a kept passkey. Returns the new signature counter;
|
|
134
|
+
* a counter that did not grow (when either side is non-zero) suggests a cloned authenticator and is refused.
|
|
135
|
+
*/
|
|
136
|
+
export async function verify_assertion({ client_data_json, authenticator_data, signature }, passkey, { challenge, origin, rp_id }) {
|
|
137
|
+
const client_data = from_b64url(client_data_json), auth_bytes = from_b64url(authenticator_data);
|
|
138
|
+
check_client_data(client_data, { type: 'webauthn.get', challenge, origin });
|
|
139
|
+
const auth = parse_auth_data(auth_bytes);
|
|
140
|
+
check_auth_data(auth, rp_id);
|
|
141
|
+
const signed = Uint8Array.from([...auth_bytes, ...sha256(client_data)]);
|
|
142
|
+
const es256 = passkey.alg === ES256;
|
|
143
|
+
const algorithm = es256 ? { name: 'ECDSA', namedCurve: 'P-256' } : { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
|
|
144
|
+
const key = await crypto.subtle.importKey('jwk', passkey.public_key, algorithm, false, ['verify']);
|
|
145
|
+
const raw = from_b64url(signature);
|
|
146
|
+
const valid = await crypto.subtle.verify(es256 ? { name: 'ECDSA', hash: 'SHA-256' } : algorithm, key, es256 ? der_to_raw(raw) : raw, signed);
|
|
147
|
+
if (!valid) throw new Error('通行密钥签名无效');
|
|
148
|
+
if ((auth.sign_count || passkey.sign_count) && auth.sign_count <= passkey.sign_count) throw new Error('通行密钥计数异常,可能被复制,请撤销后重新配对');
|
|
149
|
+
return auth.sign_count;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The challenge a browser signs when one ceremony must satisfy two parties: SHA-256 of both random challenges. */
|
|
153
|
+
export function joint_challenge(first, second) {
|
|
154
|
+
return b64url(sha256(Uint8Array.from([...from_b64url(first), ...from_b64url(second)])));
|
|
155
|
+
}
|