Skip to content

Commit 3b3d0d7

Browse files
committed
feat(api): api sensitive data encryption
1 parent 77bdb12 commit 3b3d0d7

27 files changed

+449
-58
lines changed

src/GZCTF.Test/SignatureTest.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,28 @@ public void SHA512WithRSATest()
166166
output.WriteLine(verified ? "Signature verified" : "Signature not verified");
167167
Assert.True(verified);
168168
}
169+
170+
[Fact]
171+
public void TestEncryptData()
172+
{
173+
SecureRandom sr = new();
174+
X25519KeyPairGenerator kpg = new();
175+
kpg.Init(new X25519KeyGenerationParameters(sr));
176+
177+
AsymmetricCipherKeyPair kp = kpg.GenerateKeyPair();
178+
var privateKey = (X25519PrivateKeyParameters)kp.Private;
179+
var publicKey = (X25519PublicKeyParameters)kp.Public;
180+
181+
output.WriteLine("私钥:");
182+
output.WriteLine(Base64.ToBase64String(privateKey.GetEncoded()));
183+
output.WriteLine("公钥:");
184+
output.WriteLine(Base64.ToBase64String(publicKey.GetEncoded()));
185+
186+
const string data = "Hello, GZCTF!";
187+
var encryptedData = CryptoUtils.EncryptData(Encoding.UTF8.GetBytes(data), publicKey);
188+
output.WriteLine($"加密数据:\n{Base64.ToBase64String(encryptedData)}");
189+
var decryptedData = CryptoUtils.DecryptData(encryptedData, privateKey);
190+
output.WriteLine($"解密数据:\n{Encoding.UTF8.GetString(decryptedData)}");
191+
Assert.Equal(data, Encoding.UTF8.GetString(decryptedData));
192+
}
169193
}

src/GZCTF.sln

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{D270C86C-F293-4A9C-97C7-2A10404E8283}"
77
ProjectSection(SolutionItems) = preProject
88
Directory.Packages.props = Directory.Packages.props
9-
Dockerfile = Dockerfile
109
EndProjectSection
1110
EndProject
1211
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GZCTF", "GZCTF\GZCTF.csproj", "{9AAEFC0F-73F3-44F0-B8AC-140640792603}"

src/GZCTF/ClientApp/src/Api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,8 @@ export interface GlobalConfig {
364364
footerInfo?: string | null;
365365
/** Custom theme color */
366366
customTheme?: string | null;
367+
/** Use asymmetric encryption for API requests */
368+
apiEncryption?: boolean;
367369
/** Platform logo hash */
368370
logoHash?: string | null;
369371
/** Platform favicon hash */
@@ -1785,6 +1787,8 @@ export interface ClientConfig {
17851787
footerInfo?: string | null;
17861788
/** Custom theme color */
17871789
customTheme?: string | null;
1790+
/** The public key used for API requests */
1791+
apiPublicKey?: string | null;
17881792
/** Platform logo URL */
17891793
logoUrl?: string | null;
17901794
/** Container port mapping type */

src/GZCTF/ClientApp/src/components/GameChallengeModal.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import React, { FC, useEffect, useState } from 'react'
77
import { useTranslation } from 'react-i18next'
88
import { ChallengeModal } from '@Components/ChallengeModal'
99
import { showErrorNotification } from '@Utils/ApiHelper'
10+
import { encryptApiData } from '@Utils/Crypto'
1011
import { ChallengeCategoryItemProps } from '@Utils/Shared'
12+
import { useConfig } from '@Hooks/useConfig'
1113
import api, { AnswerResult, ChallengeType, SubmissionType } from '@Api'
1214

1315
interface GameChallengeModalProps extends ModalProps {
@@ -28,6 +30,7 @@ export const GameChallengeModal: FC<GameChallengeModalProps> = (props) => {
2830
refreshInterval: 120 * 1000,
2931
})
3032

33+
const { config } = useConfig()
3134
const { t } = useTranslation()
3235

3336
const wrongFlagHints = t('challenge.content.wrong_flag_hints', {
@@ -137,7 +140,7 @@ export const GameChallengeModal: FC<GameChallengeModalProps> = (props) => {
137140

138141
try {
139142
const res = await api.game.gameSubmit(gameId, challengeId, {
140-
flag,
143+
flag: await encryptApiData(flag.trim(), config.apiPublicKey),
141144
})
142145
setSubmitId(res.data)
143146
notifications.clean()

src/GZCTF/ClientApp/src/components/PasswordChangeModal.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { useTranslation } from 'react-i18next'
88
import { useNavigate } from 'react-router'
99
import { StrengthPasswordInput } from '@Components/StrengthPasswordInput'
1010
import { showErrorNotification } from '@Utils/ApiHelper'
11+
import { encryptApiData } from '@Utils/Crypto'
12+
import { useConfig } from '@Hooks/useConfig'
1113
import api from '@Api'
1214

1315
export const PasswordChangeModal: FC<ModalProps> = (props) => {
@@ -18,6 +20,7 @@ export const PasswordChangeModal: FC<ModalProps> = (props) => {
1820
const navigate = useNavigate()
1921

2022
const { t } = useTranslation()
23+
const { config } = useConfig()
2124

2225
const onChangePwd = async () => {
2326
if (!pwd || !retypedPwd) {
@@ -30,8 +33,8 @@ export const PasswordChangeModal: FC<ModalProps> = (props) => {
3033
} else if (pwd === retypedPwd) {
3134
try {
3235
await api.account.accountChangePassword({
33-
old: oldPwd,
34-
new: pwd,
36+
old: await encryptApiData(oldPwd, config.apiPublicKey),
37+
new: await encryptApiData(pwd, config.apiPublicKey),
3538
})
3639
showNotification({
3740
color: 'teal',

src/GZCTF/ClientApp/src/locales/en-US/admin.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@
9090
"description": "All dynamic attachments will be downloaded under this filename",
9191
"label": "Global Attachment Name"
9292
},
93+
"blood_bonus": {
94+
"description": "Enable blood bonus for this challenge",
95+
"label": "Blood Bonus"
96+
},
9397
"bonus": {
9498
"description": "The Triple Blood Bonus refers to the additional points awarded when a challenge is solved by the first three teams. Each team can receive a scoring bonus based on the current points of the challenge. The bonus will be added to the team's score in the form of a fixed percentage.",
9599
"first_blood": "First Blood Reward (%)",
@@ -106,10 +110,6 @@
106110
"description": "Description",
107111
"difficulty": "Difficulty Factor",
108112
"disable": "Are you sure you want to disable challenge {{name}}?",
109-
"blood_bonus": {
110-
"description": "Enable blood bonus for this challenge",
111-
"label": "Blood Bonus"
112-
},
113113
"empty": {
114114
"description": "Click the top right to create the first challenge",
115115
"title": "Ouch! This game has no challenge yet."
@@ -296,6 +296,10 @@
296296
"title": "Container Policy"
297297
},
298298
"platform": {
299+
"api_encryption": {
300+
"description": "Encrypt sensitive data in some APIs",
301+
"label": "Enable API Encryption"
302+
},
299303
"color": {
300304
"description": "Custom color for the platform",
301305
"label": "Platform Color"

src/GZCTF/ClientApp/src/locales/zh-CN/admin.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@
9090
"description": "所有动态附件均会以此文件名下载",
9191
"label": "全局附件名"
9292
},
93+
"blood_bonus": {
94+
"description": "启用题目的三血加分",
95+
"label": "三血奖励"
96+
},
9397
"bonus": {
9498
"description": "三血奖励加成是指当一个题目被前三个队伍解出时,每个队伍可以得到的分值奖励。三血的奖励基于题目的当前分值,并以一个固定百分比的形式累加至该队伍的得分中。",
9599
"first_blood": "一血奖励 (%)",
@@ -106,10 +110,6 @@
106110
"description": "题目描述",
107111
"difficulty": "难度系数",
108112
"disable": "你确定要禁用题目 {{name}} 吗?",
109-
"blood_bonus": {
110-
"description": "启用题目的三血加分",
111-
"label": "三血奖励"
112-
},
113113
"empty": {
114114
"description": "点击右上角创建第一个题目",
115115
"title": "Ouch! 这个比赛还没有题目"
@@ -296,6 +296,10 @@
296296
"title": "容器策略"
297297
},
298298
"platform": {
299+
"api_encryption": {
300+
"description": "加密部分敏感数据,如用户密码、flag",
301+
"label": "启用 API 数据加密"
302+
},
299303
"color": {
300304
"description": "自定义平台颜色,用于生成主题色盘",
301305
"label": "平台颜色"

src/GZCTF/ClientApp/src/pages/account/Login.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { useTranslation } from 'react-i18next'
88
import { Link, useNavigate, useSearchParams } from 'react-router'
99
import { AccountView } from '@Components/AccountView'
1010
import { Captcha, useCaptchaRef } from '@Components/Captcha'
11+
import { encryptApiData } from '@Utils/Crypto'
12+
import { useConfig } from '@Hooks/useConfig'
1113
import { usePageTitle } from '@Hooks/usePageTitle'
1214
import { useUser } from '@Hooks/useUser'
1315
import api from '@Api'
@@ -24,6 +26,7 @@ const Login: FC = () => {
2426

2527
const { captchaRef, getToken, cleanUp } = useCaptchaRef()
2628
const { user, mutate } = useUser()
29+
const { config } = useConfig()
2730

2831
const { t } = useTranslation()
2932

@@ -78,7 +81,7 @@ const Login: FC = () => {
7881
try {
7982
await api.account.accountLogIn({
8083
userName: uname,
81-
password: pwd,
84+
password: await encryptApiData(pwd, config.apiPublicKey),
8285
challenge: token,
8386
})
8487

src/GZCTF/ClientApp/src/pages/account/Register.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { Link, useNavigate } from 'react-router'
99
import { AccountView } from '@Components/AccountView'
1010
import { Captcha, useCaptchaRef } from '@Components/Captcha'
1111
import { StrengthPasswordInput } from '@Components/StrengthPasswordInput'
12+
import { encryptApiData } from '@Utils/Crypto'
13+
import { useConfig } from '@Hooks/useConfig'
1214
import { usePageTitle } from '@Hooks/usePageTitle'
1315
import api, { RegisterStatus } from '@Api'
1416
import misc from '@Styles/Misc.module.css'
@@ -19,6 +21,7 @@ const Register: FC = () => {
1921
const [uname, setUname] = useInputState('')
2022
const [email, setEmail] = useInputState('')
2123
const [disabled, setDisabled] = useState(false)
24+
const { config } = useConfig()
2225

2326
const navigate = useNavigate()
2427
const { captchaRef, getToken, cleanUp } = useCaptchaRef()
@@ -90,7 +93,7 @@ const Register: FC = () => {
9093
try {
9194
const res = await api.account.accountRegister({
9295
userName: uname,
93-
password: pwd,
96+
password: await encryptApiData(pwd, config.apiPublicKey),
9497
email: email,
9598
challenge: token,
9699
})

src/GZCTF/ClientApp/src/pages/account/Reset.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { useLocation, useNavigate } from 'react-router'
99
import { AccountView } from '@Components/AccountView'
1010
import { StrengthPasswordInput } from '@Components/StrengthPasswordInput'
1111
import { showErrorNotification } from '@Utils/ApiHelper'
12+
import { encryptApiData } from '@Utils/Crypto'
13+
import { useConfig } from '@Hooks/useConfig'
1214
import { usePageTitle } from '@Hooks/usePageTitle'
1315
import api from '@Api'
1416

@@ -23,6 +25,7 @@ const Reset: FC = () => {
2325
const [disabled, setDisabled] = useState(false)
2426

2527
const { t } = useTranslation()
28+
const { config } = useConfig()
2629

2730
usePageTitle(t('account.title.reset'))
2831

@@ -52,7 +55,7 @@ const Reset: FC = () => {
5255
await api.account.accountPasswordReset({
5356
rToken: token,
5457
email: email,
55-
password: pwd,
58+
password: await encryptApiData(pwd, config.apiPublicKey),
5659
})
5760
showNotification({
5861
color: 'teal',

src/GZCTF/ClientApp/src/pages/admin/Settings.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ const Configs: FC = () => {
129129
<Stack gap="sm">
130130
<Title order={2}>{t('admin.content.settings.platform.title')}</Title>
131131
<Divider />
132-
<Grid columns={4}>
132+
<Grid columns={4} align="center">
133133
<Grid.Col span={1}>
134134
<TextInput
135135
label={t('admin.content.settings.platform.name.label')}
@@ -200,7 +200,6 @@ const Configs: FC = () => {
200200
}}
201201
/>
202202
</Grid.Col>
203-
204203
<Grid.Col span={1}>
205204
<ColorInput
206205
label={t('admin.content.settings.platform.color.label')}
@@ -225,7 +224,7 @@ const Configs: FC = () => {
225224
}}
226225
/>
227226
</Grid.Col>
228-
<Grid.Col span={4}>
227+
<Grid.Col span={3}>
229228
<TextInput
230229
label={t('admin.content.settings.platform.footer.label')}
231230
description={t('admin.content.settings.platform.footer.description')}
@@ -237,6 +236,22 @@ const Configs: FC = () => {
237236
}}
238237
/>
239238
</Grid.Col>
239+
<Grid.Col span={1} className={misc.alignCenter}>
240+
<Switch
241+
checked={globalConfig?.apiEncryption ?? false}
242+
disabled={disabled}
243+
label={SwitchLabel(
244+
t('admin.content.settings.platform.api_encryption.label'),
245+
t('admin.content.settings.platform.api_encryption.description')
246+
)}
247+
onChange={(e) =>
248+
setGlobalConfig({
249+
...globalConfig,
250+
apiEncryption: e.currentTarget.checked,
251+
})
252+
}
253+
/>
254+
</Grid.Col>
240255
</Grid>
241256
</Stack>
242257
<Stack gap="sm">
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
function base64ToUint8Array(base64: string): Uint8Array {
2+
const binaryString = atob(base64)
3+
const len = binaryString.length
4+
const bytes = new Uint8Array(len)
5+
for (let i = 0; i < len; i++) {
6+
bytes[i] = binaryString.charCodeAt(i)
7+
}
8+
return bytes
9+
}
10+
11+
function uint8ArrayToBase64(bytes: Uint8Array): string {
12+
let binary = ''
13+
const len = bytes.byteLength
14+
for (let i = 0; i < len; i++) {
15+
binary += String.fromCharCode(bytes[i])
16+
}
17+
return btoa(binary)
18+
}
19+
20+
async function encryptData(plainTextBytes: Uint8Array, recipientPublicKeyBase64: string): Promise<Uint8Array> {
21+
if (!plainTextBytes || plainTextBytes.length === 0) {
22+
throw new Error('Data to encrypt cannot be empty.')
23+
}
24+
if (!recipientPublicKeyBase64) {
25+
throw new Error('Recipient public key cannot be empty.')
26+
}
27+
28+
const recipientPublicKeyBytes = base64ToUint8Array(recipientPublicKeyBase64)
29+
if (recipientPublicKeyBytes.length !== 32) {
30+
throw new Error('Invalid X25519 public key length.')
31+
}
32+
33+
const recipientPublicKeyCryptoKey = await crypto.subtle.importKey('raw', recipientPublicKeyBytes, 'X25519', true, [])
34+
35+
const ephemeralKeyPair = (await crypto.subtle.generateKey('X25519', true, ['deriveBits'])) as CryptoKeyPair
36+
37+
const ephemeralPublicKeyBytes = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeralKeyPair.publicKey!))
38+
39+
const sharedSecret = new Uint8Array(
40+
await crypto.subtle.deriveBits(
41+
{ name: 'X25519', public: recipientPublicKeyCryptoKey },
42+
ephemeralKeyPair.privateKey!,
43+
256
44+
)
45+
)
46+
47+
const aesKeyBytes = new Uint8Array(await crypto.subtle.digest('SHA-256', sharedSecret))
48+
49+
const aesKeyCryptoKey = await crypto.subtle.importKey('raw', aesKeyBytes, { name: 'AES-GCM', length: 256 }, false, [
50+
'encrypt',
51+
])
52+
53+
const nonce = crypto.getRandomValues(new Uint8Array(12))
54+
55+
const ciphertextArrayBuffer = await crypto.subtle.encrypt(
56+
{ name: 'AES-GCM', iv: nonce, tagLength: 128 },
57+
aesKeyCryptoKey,
58+
plainTextBytes
59+
)
60+
const ciphertextBytes = new Uint8Array(ciphertextArrayBuffer)
61+
62+
const result = new Uint8Array(ephemeralPublicKeyBytes.length + nonce.length + ciphertextBytes.length)
63+
result.set(ephemeralPublicKeyBytes, 0)
64+
result.set(nonce, ephemeralPublicKeyBytes.length)
65+
result.set(ciphertextBytes, ephemeralPublicKeyBytes.length + nonce.length)
66+
67+
return result
68+
}
69+
70+
function isWebCryptoAvailable(): boolean {
71+
return typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'
72+
}
73+
74+
export async function encryptApiData(plainText: string, publicKey?: string | null): Promise<string> {
75+
if (!publicKey) {
76+
return plainText
77+
}
78+
79+
if (!isWebCryptoAvailable()) {
80+
throw new Error('Web Crypto API is not available in this environment.')
81+
}
82+
83+
const plainTextBytes = new TextEncoder().encode(plainText)
84+
const encryptedBytes = await encryptData(plainTextBytes, publicKey)
85+
return uint8ArrayToBase64(encryptedBytes)
86+
}

0 commit comments

Comments
 (0)