Skip to content

Commit 5545ae2

Browse files
author
Ivan Siziy
committed
fix https crypto puzzle
1 parent 45a3d96 commit 5545ae2

3 files changed

Lines changed: 105 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
"@types/uuid": "^10.0.0",
125125
"@typescript-eslint/eslint-plugin": "^8.32.1",
126126
"@vitejs/plugin-react": "^5.2.0",
127+
"asmcrypto.js": "^0.22.0",
127128
"ckeditor5": "^45.0.0",
128129
"class-variance-authority": "^0.7.1",
129130
"clsx": "^2.1.1",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
/**
2+
* `crypto.subtle` is only available in secure contexts (https / localhost),
3+
* so the POW captcha (crypto-puzzle -> tiny-encryptor / crypto-sha) breaks
4+
* when the admin panel is served over plain http.
5+
*
6+
* `ensureSubtleCrypto()` installs a minimal pure-JS fallback implementing only
7+
* the operations crypto-puzzle needs: SHA-256 digest, PBKDF2-HMAC-SHA256
8+
* deriveBits and AES-GCM encrypt/decrypt. The asmcrypto.js implementation is
9+
* loaded dynamically, so secure contexts never download it.
10+
*/
11+
12+
type FallbackKey = {
13+
__raw: Uint8Array;
14+
algorithm: { name: string };
15+
usages: string[];
16+
type: 'secret';
17+
extractable: false;
18+
};
19+
20+
function toBytes(data: BufferSource): Uint8Array {
21+
if (data instanceof Uint8Array) return data;
22+
if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
23+
return new Uint8Array(data);
24+
}
25+
26+
function toBuffer(bytes: Uint8Array): ArrayBuffer {
27+
return bytes.slice().buffer;
28+
}
29+
30+
function algoName(algorithm: string | { name: string }): string {
31+
return (typeof algorithm === 'string' ? algorithm : algorithm.name).toUpperCase();
32+
}
33+
34+
let installed: Promise<void> | undefined;
35+
36+
export function ensureSubtleCrypto(): Promise<void> {
37+
if (typeof crypto === 'undefined' || crypto.subtle) return Promise.resolve();
38+
installed ??= installFallback();
39+
return installed;
40+
}
41+
42+
async function installFallback(): Promise<void> {
43+
const { SHA256, PBKDF2_HMAC_SHA256, AES_GCM } = await import('asmcrypto.js');
44+
45+
const subtleFallback = {
46+
async digest(algorithm: string | { name: string }, data: BufferSource): Promise<ArrayBuffer> {
47+
if (algoName(algorithm) !== 'SHA-256') throw new Error(`Unsupported digest algorithm: ${algoName(algorithm)}`);
48+
return toBuffer(SHA256.bytes(toBytes(data)));
49+
},
50+
51+
async importKey(
52+
format: string,
53+
keyData: BufferSource,
54+
algorithm: string | { name: string },
55+
_extractable: boolean,
56+
usages: string[],
57+
): Promise<FallbackKey> {
58+
if (format !== 'raw') throw new Error(`Unsupported key format: ${format}`);
59+
return {
60+
__raw: toBytes(keyData).slice(),
61+
algorithm: { name: algoName(algorithm) },
62+
usages,
63+
type: 'secret',
64+
extractable: false,
65+
};
66+
},
67+
68+
async deriveBits(
69+
params: { name: string; salt: BufferSource; iterations: number; hash: string | { name: string } },
70+
key: FallbackKey,
71+
length: number,
72+
): Promise<ArrayBuffer> {
73+
if (algoName(params) !== 'PBKDF2' || algoName(params.hash) !== 'SHA-256') {
74+
throw new Error(`Unsupported deriveBits params: ${algoName(params)}/${algoName(params.hash)}`);
75+
}
76+
return toBuffer(PBKDF2_HMAC_SHA256.bytes(key.__raw, toBytes(params.salt), params.iterations, length / 8));
77+
},
78+
79+
async encrypt(
80+
params: { name: string; iv: BufferSource; tagLength?: number },
81+
key: FallbackKey,
82+
data: BufferSource,
83+
): Promise<ArrayBuffer> {
84+
if (algoName(params) !== 'AES-GCM') throw new Error(`Unsupported encrypt algorithm: ${algoName(params)}`);
85+
return toBuffer(AES_GCM.encrypt(toBytes(data), key.__raw, toBytes(params.iv), undefined, (params.tagLength ?? 128) / 8));
86+
},
87+
88+
async decrypt(
89+
params: { name: string; iv: BufferSource; tagLength?: number },
90+
key: FallbackKey,
91+
data: BufferSource,
92+
): Promise<ArrayBuffer> {
93+
if (algoName(params) !== 'AES-GCM') throw new Error(`Unsupported decrypt algorithm: ${algoName(params)}`);
94+
return toBuffer(AES_GCM.decrypt(toBytes(data), key.__raw, toBytes(params.iv), undefined, (params.tagLength ?? 128) / 8));
95+
},
96+
};
97+
98+
Object.defineProperty(crypto, 'subtle', {
99+
value: subtleFallback,
100+
configurable: true,
101+
});
102+
}

src/assets/js/pages/login.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Loader2, User, Lock, Eye, EyeOff } from "lucide-react";
66
import { SharedData } from "@/types";
77
import { Button } from "@/components/ui/button.tsx";
88
import { FormEventHandler, useEffect, useState } from "react";
9+
import { ensureSubtleCrypto } from '@/lib/insecure-context-crypto.ts';
910
import Puzzle from 'crypto-puzzle';
1011
import { Toaster } from "@/components/ui/sonner.tsx";
1112
import { toast } from "sonner";
@@ -86,6 +87,7 @@ export default function Login() {
8687
await sleep(100)
8788

8889
// Start solving
90+
await ensureSubtleCrypto()
8991
const puzzle = new Uint8Array(page.props.captchaTask)
9092

9193
const solution = await Puzzle.solve(puzzle);

0 commit comments

Comments
 (0)