feat: 登录信息加密传输
This commit is contained in:
234
easyflow-ui-admin/app/src/utils/credential-encryption.ts
Normal file
234
easyflow-ui-admin/app/src/utils/credential-encryption.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
interface CredentialKeyMaterial {
|
||||
algorithm: string;
|
||||
expiresAt: string;
|
||||
fingerprint: string;
|
||||
keyId: string;
|
||||
nonce: string;
|
||||
publicKey: string;
|
||||
transitions?: CredentialKeyTransition[];
|
||||
}
|
||||
|
||||
interface CredentialKeyPin {
|
||||
fingerprint: string;
|
||||
keyId: string;
|
||||
publicKey: string;
|
||||
}
|
||||
|
||||
interface CredentialKeyTransition {
|
||||
fromKeyId: string;
|
||||
payload: string;
|
||||
signature: string;
|
||||
toKeyId: string;
|
||||
}
|
||||
|
||||
interface TransitionPayload {
|
||||
newFingerprint: string;
|
||||
newKeyId: string;
|
||||
newPublicKey: string;
|
||||
oldKeyId: string;
|
||||
}
|
||||
|
||||
const PIN_STORAGE_KEY = 'easyflow:credential-key-pin:v1';
|
||||
const SECURITY_ERROR = '登录安全校验失败,请刷新或联系管理员';
|
||||
const UNSUPPORTED_ERROR = '当前浏览器不支持登录安全加密,请升级浏览器';
|
||||
|
||||
/**
|
||||
* 加密密码类请求载荷。
|
||||
* @param loadMaterial 获取后端公钥材料的方法
|
||||
* @param payload 待加密载荷
|
||||
* @returns 加密信封
|
||||
*/
|
||||
export async function encryptCredentialPayload(
|
||||
loadMaterial: () => Promise<CredentialKeyMaterial>,
|
||||
payload: Record<string, any>,
|
||||
) {
|
||||
ensureWebCrypto();
|
||||
const material = await loadMaterial();
|
||||
await verifyAndPersistPin(material);
|
||||
const aesKey = await window.crypto.subtle.generateKey(
|
||||
{ length: 256, name: 'AES-GCM' },
|
||||
true,
|
||||
['encrypt'],
|
||||
);
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const plaintext = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
...payload,
|
||||
nonce: material.nonce,
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
);
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{ iv, name: 'AES-GCM' },
|
||||
aesKey,
|
||||
plaintext,
|
||||
);
|
||||
const rawAesKey = await window.crypto.subtle.exportKey('raw', aesKey);
|
||||
const publicKey = await importRsaPublicKey(material.publicKey, ['encrypt']);
|
||||
const encryptedKey = await window.crypto.subtle.encrypt(
|
||||
{ name: 'RSA-OAEP' },
|
||||
publicKey,
|
||||
rawAesKey,
|
||||
);
|
||||
return {
|
||||
ciphertext: bytesToBase64(new Uint8Array(ciphertext)),
|
||||
encryptedKey: bytesToBase64(new Uint8Array(encryptedKey)),
|
||||
iv: bytesToBase64(iv),
|
||||
keyId: material.keyId,
|
||||
nonce: material.nonce,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyAndPersistPin(material: CredentialKeyMaterial) {
|
||||
const current = readPin();
|
||||
if (!current) {
|
||||
writePin(material);
|
||||
return;
|
||||
}
|
||||
if (current.fingerprint === material.fingerprint) {
|
||||
writePin(material);
|
||||
return;
|
||||
}
|
||||
const nextPin = await verifyTransitionChain(current, material);
|
||||
if (!nextPin || nextPin.fingerprint !== material.fingerprint) {
|
||||
throw new Error(SECURITY_ERROR);
|
||||
}
|
||||
writePin(material);
|
||||
}
|
||||
|
||||
async function verifyTransitionChain(
|
||||
current: CredentialKeyPin,
|
||||
material: CredentialKeyMaterial,
|
||||
) {
|
||||
let cursor = { ...current };
|
||||
const transitions = material.transitions || [];
|
||||
const visited = new Set<string>();
|
||||
while (cursor.fingerprint !== material.fingerprint) {
|
||||
if (visited.has(cursor.keyId)) {
|
||||
return null;
|
||||
}
|
||||
visited.add(cursor.keyId);
|
||||
const transition = transitions.find((item) => item.fromKeyId === cursor.keyId);
|
||||
if (!transition) {
|
||||
return null;
|
||||
}
|
||||
const payload = decodeTransitionPayload(transition.payload);
|
||||
if (
|
||||
payload.oldKeyId !== cursor.keyId ||
|
||||
payload.newKeyId !== transition.toKeyId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const verified = await verifyTransition(cursor.publicKey, transition);
|
||||
if (!verified) {
|
||||
return null;
|
||||
}
|
||||
cursor = {
|
||||
fingerprint: payload.newFingerprint,
|
||||
keyId: payload.newKeyId,
|
||||
publicKey: payload.newPublicKey,
|
||||
};
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
async function verifyTransition(
|
||||
publicKeyPem: string,
|
||||
transition: CredentialKeyTransition,
|
||||
) {
|
||||
const publicKey = await importSignPublicKey(publicKeyPem);
|
||||
return window.crypto.subtle.verify(
|
||||
{ name: 'RSASSA-PKCS1-v1_5' },
|
||||
publicKey,
|
||||
base64ToBytes(transition.signature),
|
||||
new TextEncoder().encode(transition.payload),
|
||||
);
|
||||
}
|
||||
|
||||
function decodeTransitionPayload(payload: string): TransitionPayload {
|
||||
const json = new TextDecoder().decode(base64UrlToBytes(payload));
|
||||
return JSON.parse(json) as TransitionPayload;
|
||||
}
|
||||
|
||||
async function importRsaPublicKey(publicKeyPem: string, usages: KeyUsage[]) {
|
||||
return window.crypto.subtle.importKey(
|
||||
'spki',
|
||||
pemToBytes(publicKeyPem),
|
||||
{ hash: 'SHA-256', name: 'RSA-OAEP' },
|
||||
false,
|
||||
usages,
|
||||
);
|
||||
}
|
||||
|
||||
async function importSignPublicKey(publicKeyPem: string) {
|
||||
return window.crypto.subtle.importKey(
|
||||
'spki',
|
||||
pemToBytes(publicKeyPem),
|
||||
{ hash: 'SHA-256', name: 'RSASSA-PKCS1-v1_5' },
|
||||
false,
|
||||
['verify'],
|
||||
);
|
||||
}
|
||||
|
||||
function ensureWebCrypto() {
|
||||
if (!window.crypto?.subtle || !window.crypto.getRandomValues) {
|
||||
throw new Error(UNSUPPORTED_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
function readPin(): CredentialKeyPin | null {
|
||||
const raw = window.localStorage.getItem(PIN_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as CredentialKeyPin;
|
||||
} catch {
|
||||
window.localStorage.removeItem(PIN_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePin(material: CredentialKeyMaterial) {
|
||||
window.localStorage.setItem(
|
||||
PIN_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
fingerprint: material.fingerprint,
|
||||
keyId: material.keyId,
|
||||
publicKey: material.publicKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function pemToBytes(pem: string) {
|
||||
const base64 = pem
|
||||
.replace('-----BEGIN PUBLIC KEY-----', '')
|
||||
.replace('-----END PUBLIC KEY-----', '')
|
||||
.replace(/\s/g, '');
|
||||
return base64ToBytes(base64);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string) {
|
||||
const binary = window.atob(value);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string) {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||
return base64ToBytes(padded);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = '';
|
||||
bytes.forEach((item) => {
|
||||
binary += String.fromCharCode(item);
|
||||
});
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
export type { CredentialKeyMaterial };
|
||||
Reference in New Issue
Block a user