esp-idf-provisioning-web 0.0.3 → 0.0.6
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/LICENSE +0 -0
- package/README.md +84 -21
- package/dist/ESPDevice.d.ts +4 -19
- package/dist/ESPDevice.d.ts.map +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.es.js +4623 -3094
- package/dist/security/Security0.d.ts.map +1 -1
- package/dist/security/Security1.d.ts +17 -0
- package/dist/security/Security1.d.ts.map +1 -0
- package/dist/types.d.ts +15 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/crypto.d.ts +9 -0
- package/dist/utils/crypto.d.ts.map +1 -1
- package/package.json +26 -24
- package/src/ESPDevice.ts +79 -74
- package/src/ESPProvisionManager.ts +0 -0
- package/src/constants.ts +0 -0
- package/src/generated/.gitkeep +0 -0
- package/src/generated/proto.d.ts +0 -0
- package/src/generated/proto.js +1864 -659
- package/src/index.ts +9 -4
- package/src/proto/constants.proto +0 -0
- package/src/proto/sec0.proto +0 -0
- package/src/proto/sec1.proto +0 -0
- package/src/proto/sec2.proto +0 -0
- package/src/proto/session.proto +0 -0
- package/src/proto/wifi_config.proto +0 -0
- package/src/proto/wifi_constants.proto +0 -0
- package/src/proto/wifi_ctrl.proto +0 -0
- package/src/proto/wifi_scan.proto +0 -0
- package/src/protocol/protocol.ts +0 -0
- package/src/protocol/wifiConfig.ts +6 -6
- package/src/protocol/wifiScan.ts +4 -4
- package/src/security/Security.ts +0 -0
- package/src/security/Security0.ts +5 -3
- package/src/security/Security1.ts +110 -0
- package/src/transport/BLETransport.ts +0 -0
- package/src/transport/SoftAPTransport.ts +2 -2
- package/src/transport/Transport.ts +0 -0
- package/src/types.ts +17 -1
- package/src/utils/crypto.ts +43 -9
package/src/index.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
/// <reference types="web-bluetooth" preserve="true" />
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* ESP-IDF Provisioning Web SDK
|
|
3
5
|
*
|
|
4
6
|
* A TypeScript library for provisioning ESP32 devices via Web Bluetooth API and HTTP.
|
|
5
|
-
* Supports both BLE and SoftAP transport modes with Security0
|
|
7
|
+
* Supports both BLE and SoftAP transport modes with Security0 and Security1.
|
|
6
8
|
*
|
|
7
9
|
* Note: Browser limitations apply:
|
|
8
10
|
* - BLE requires user interaction to select devices
|
|
9
11
|
* - SoftAP requires manual WiFi connection by the user
|
|
10
|
-
* -
|
|
12
|
+
* - Security1 requires Web Crypto with X25519 support (HTTPS or localhost)
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
export { ESPDevice } from './ESPDevice';
|
|
@@ -16,12 +18,15 @@ export { ESPProvisionManager } from './ESPProvisionManager';
|
|
|
16
18
|
export {
|
|
17
19
|
ESPWifiAuthMode,
|
|
18
20
|
ESPWifiStaState,
|
|
21
|
+
ESPConnectFailReason,
|
|
19
22
|
type ESPDeviceInterface,
|
|
23
|
+
type ESPSecurityConfig,
|
|
24
|
+
type ESPSecurityResolver,
|
|
25
|
+
type ESPProvisioningInfo,
|
|
26
|
+
type ESPProvisioningMetadata,
|
|
20
27
|
type ESPSecurity1Config,
|
|
21
28
|
type ESPSecurity2Config,
|
|
22
29
|
type ESPWifiAp,
|
|
23
|
-
type ESPScanStatusResponse as ESPStatusResponse,
|
|
24
|
-
type ESPConnectFailReason,
|
|
25
30
|
type ESPWifiStatus,
|
|
26
31
|
} from './types';
|
|
27
32
|
|
|
File without changes
|
package/src/proto/sec0.proto
CHANGED
|
File without changes
|
package/src/proto/sec1.proto
CHANGED
|
File without changes
|
package/src/proto/sec2.proto
CHANGED
|
File without changes
|
package/src/proto/session.proto
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/protocol/protocol.ts
CHANGED
|
File without changes
|
|
@@ -4,7 +4,7 @@ import { bytesToHex, bytesToString, stringToBytes } from '../utils/crypto.js';
|
|
|
4
4
|
import { wifiAuthModeToESPWifiAuthMode } from './protocol.js';
|
|
5
5
|
|
|
6
6
|
export function getConfigStatusRequest(): Uint8Array {
|
|
7
|
-
|
|
7
|
+
const configRequest = proto.WiFiConfigPayload.create({});
|
|
8
8
|
configRequest.msg = proto.WiFiConfigMsgType.TypeCmdGetStatus;
|
|
9
9
|
configRequest.cmdGetStatus = proto.CmdGetStatus.create({});
|
|
10
10
|
return proto.WiFiConfigPayload.encode(configRequest).finish();
|
|
@@ -61,13 +61,13 @@ export function processConfigStatusResponse(response: Uint8Array): ESPWifiStatus
|
|
|
61
61
|
failedReason,
|
|
62
62
|
connected,
|
|
63
63
|
};
|
|
64
|
-
} catch
|
|
64
|
+
} catch {
|
|
65
65
|
throw new Error('Failed to decode config status response');
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
export function getConfigUpdateRequest(ssid: string, passphrase: string): Uint8Array {
|
|
70
|
-
|
|
70
|
+
const configRequest = proto.WiFiConfigPayload.create({});
|
|
71
71
|
configRequest.msg = proto.WiFiConfigMsgType.TypeCmdSetConfig;
|
|
72
72
|
configRequest.cmdSetConfig = proto.CmdSetConfig.create({
|
|
73
73
|
ssid: stringToBytes(ssid),
|
|
@@ -83,13 +83,13 @@ export function processConfigUpdateResponse(response: Uint8Array): void {
|
|
|
83
83
|
if (result.status !== proto.Status.Success) {
|
|
84
84
|
throw new Error(`Config update failed: ${result.status}`);
|
|
85
85
|
}
|
|
86
|
-
} catch
|
|
86
|
+
} catch {
|
|
87
87
|
throw new Error('Failed to decode config update response');
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
export function getConfigApplyRequest(): Uint8Array {
|
|
92
|
-
|
|
92
|
+
const configRequest = proto.WiFiConfigPayload.create({});
|
|
93
93
|
configRequest.msg = proto.WiFiConfigMsgType.TypeCmdApplyConfig;
|
|
94
94
|
configRequest.cmdApplyConfig = proto.CmdApplyConfig.create({});
|
|
95
95
|
return proto.WiFiConfigPayload.encode(configRequest).finish();
|
|
@@ -102,7 +102,7 @@ export function processConfigApplyResponse(response: Uint8Array): void {
|
|
|
102
102
|
if (result.status !== proto.Status.Success) {
|
|
103
103
|
throw new Error(`Config apply failed: ${result.status}`);
|
|
104
104
|
}
|
|
105
|
-
} catch
|
|
105
|
+
} catch {
|
|
106
106
|
throw new Error('Failed to decode config apply response');
|
|
107
107
|
}
|
|
108
108
|
}
|
package/src/protocol/wifiScan.ts
CHANGED
|
@@ -9,7 +9,7 @@ export function getScanStartRequest(
|
|
|
9
9
|
groupChannels: number = 5,
|
|
10
10
|
periodMs: number = 120
|
|
11
11
|
): Uint8Array {
|
|
12
|
-
|
|
12
|
+
const cmd = proto.CmdScanStart.create({});
|
|
13
13
|
cmd.blocking = blocking;
|
|
14
14
|
cmd.passive = passive;
|
|
15
15
|
cmd.groupChannels = groupChannels;
|
|
@@ -51,13 +51,13 @@ export function processScanStatusResponse(response: Uint8Array): ESPScanStatusRe
|
|
|
51
51
|
scanFinished: result.scanFinished,
|
|
52
52
|
count: result.resultCount,
|
|
53
53
|
};
|
|
54
|
-
} catch
|
|
54
|
+
} catch {
|
|
55
55
|
throw new Error('Failed to decode scan status response');
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function getScanResultRequest(index: number, count: number): Uint8Array {
|
|
60
|
-
|
|
60
|
+
const cmd = proto.CmdScanResult.create({});
|
|
61
61
|
cmd.startIndex = index;
|
|
62
62
|
cmd.count = count;
|
|
63
63
|
const scanRequest = proto.WiFiScanPayload.create({
|
|
@@ -85,7 +85,7 @@ export function processScanResultResponse(response: Uint8Array): ESPWifiAp[] {
|
|
|
85
85
|
};
|
|
86
86
|
});
|
|
87
87
|
return wifiList;
|
|
88
|
-
} catch
|
|
88
|
+
} catch {
|
|
89
89
|
throw new Error('Failed to decode scan result response');
|
|
90
90
|
}
|
|
91
91
|
}
|
package/src/security/Security.ts
CHANGED
|
File without changes
|
|
@@ -16,18 +16,20 @@ export class Security0 extends Security {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
async getSessionSetupRequest(): Promise<Uint8Array> {
|
|
19
|
-
|
|
19
|
+
const setupReq = proto.SessionData.create({});
|
|
20
20
|
setupReq.secVer = proto.SecSchemeVersion.SecScheme0;
|
|
21
21
|
setupReq.sec0 = proto.Sec0Payload.create({
|
|
22
22
|
msg: proto.Sec0MsgType.S0_Session_Command,
|
|
23
23
|
sc: proto.S0SessionCmd.create({})
|
|
24
24
|
});
|
|
25
|
-
return proto.
|
|
25
|
+
return proto.SessionData.encode(setupReq).finish();
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
async processSessionSetupResponse(response: Uint8Array): Promise<void> {
|
|
29
29
|
const setupResp = proto.SessionData.decode(response);
|
|
30
|
-
if (setupResp.secVer === proto.SecSchemeVersion.SecScheme0 &&
|
|
30
|
+
if (setupResp.secVer === proto.SecSchemeVersion.SecScheme0 &&
|
|
31
|
+
setupResp.sec0?.msg === proto.Sec0MsgType.S0_Session_Response &&
|
|
32
|
+
setupResp.sec0.sr?.status === proto.Status.Success) {
|
|
31
33
|
this.established = true;
|
|
32
34
|
} else {
|
|
33
35
|
throw new Error('Invalid session setup response for Security0');
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Security } from './Security';
|
|
2
|
+
import * as proto from '../generated/proto.js';
|
|
3
|
+
import { AESCTRStream, sha256, stringToBytes } from '../utils/crypto';
|
|
4
|
+
|
|
5
|
+
/** ESP-IDF Security 1: X25519, optional PoP, and one continuous AES-CTR stream. */
|
|
6
|
+
export class Security1 extends Security {
|
|
7
|
+
private phase: 'command0' | 'response0' | 'command1' | 'response1' | 'established' = 'command0';
|
|
8
|
+
private keyPair?: CryptoKeyPair;
|
|
9
|
+
private publicKey?: Uint8Array;
|
|
10
|
+
private devicePublicKey?: Uint8Array;
|
|
11
|
+
private cipher?: AESCTRStream;
|
|
12
|
+
|
|
13
|
+
constructor(private proofOfPossession: string, patchVersion?: number) {
|
|
14
|
+
super();
|
|
15
|
+
if ((patchVersion ?? 0) !== 0) {
|
|
16
|
+
throw new Error('Unsupported Security1 patch version');
|
|
17
|
+
}
|
|
18
|
+
if (typeof proofOfPossession !== 'string') {
|
|
19
|
+
throw new Error('Unsupported security configuration');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getSessionSetupRequest(): Promise<Uint8Array> {
|
|
24
|
+
if (this.phase === 'command0') {
|
|
25
|
+
this.keyPair = await crypto.subtle.generateKey('X25519', false, ['deriveBits']) as CryptoKeyPair;
|
|
26
|
+
this.publicKey = new Uint8Array(await crypto.subtle.exportKey('raw', this.keyPair.publicKey));
|
|
27
|
+
this.phase = 'response0';
|
|
28
|
+
return proto.SessionData.encode({
|
|
29
|
+
secVer: proto.SecSchemeVersion.SecScheme1,
|
|
30
|
+
sec1: {
|
|
31
|
+
msg: proto.Sec1MsgType.Session_Command0,
|
|
32
|
+
sc0: { clientPubkey: this.publicKey },
|
|
33
|
+
},
|
|
34
|
+
}).finish();
|
|
35
|
+
}
|
|
36
|
+
if (this.phase === 'command1' && this.cipher && this.devicePublicKey) {
|
|
37
|
+
const clientVerifyData = await this.cipher.update(this.devicePublicKey);
|
|
38
|
+
this.phase = 'response1';
|
|
39
|
+
return proto.SessionData.encode({
|
|
40
|
+
secVer: proto.SecSchemeVersion.SecScheme1,
|
|
41
|
+
sec1: { msg: proto.Sec1MsgType.Session_Command1, sc1: { clientVerifyData } },
|
|
42
|
+
}).finish();
|
|
43
|
+
}
|
|
44
|
+
throw new Error('Invalid Security1 handshake state');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async processSessionSetupResponse(response: Uint8Array): Promise<void> {
|
|
48
|
+
const session = proto.SessionData.decode(response);
|
|
49
|
+
if (session.secVer !== proto.SecSchemeVersion.SecScheme1 || !session.sec1) {
|
|
50
|
+
throw new Error('Invalid Security1 session response');
|
|
51
|
+
}
|
|
52
|
+
const payload = session.sec1;
|
|
53
|
+
if (this.phase === 'response0' && this.keyPair) {
|
|
54
|
+
const reply = payload.sr0;
|
|
55
|
+
if (payload.msg !== proto.Sec1MsgType.Session_Response0 || !reply ||
|
|
56
|
+
reply.status !== proto.Status.Success || reply.devicePubkey?.length !== 32 ||
|
|
57
|
+
reply.deviceRandom?.length !== 16) {
|
|
58
|
+
throw new Error('Invalid Security1 key exchange response');
|
|
59
|
+
}
|
|
60
|
+
this.devicePublicKey = new Uint8Array(reply.devicePubkey);
|
|
61
|
+
const peerKey = await crypto.subtle.importKey(
|
|
62
|
+
'raw', new Uint8Array(this.devicePublicKey), 'X25519', false, []
|
|
63
|
+
);
|
|
64
|
+
const sharedKey = new Uint8Array(await crypto.subtle.deriveBits(
|
|
65
|
+
{ name: 'X25519', public: peerKey }, this.keyPair.privateKey, 256
|
|
66
|
+
));
|
|
67
|
+
this.keyPair = undefined;
|
|
68
|
+
try {
|
|
69
|
+
if (this.proofOfPossession.length > 0) {
|
|
70
|
+
const hash = await sha256(stringToBytes(this.proofOfPossession));
|
|
71
|
+
for (let i = 0; i < sharedKey.length; i++) sharedKey[i] ^= hash[i];
|
|
72
|
+
hash.fill(0);
|
|
73
|
+
}
|
|
74
|
+
this.proofOfPossession = '';
|
|
75
|
+
this.cipher = await AESCTRStream.create(sharedKey, reply.deviceRandom);
|
|
76
|
+
} finally {
|
|
77
|
+
sharedKey.fill(0);
|
|
78
|
+
}
|
|
79
|
+
this.phase = 'command1';
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (this.phase === 'response1' && this.cipher && this.publicKey) {
|
|
83
|
+
const reply = payload.sr1;
|
|
84
|
+
if (payload.msg !== proto.Sec1MsgType.Session_Response1 || !reply ||
|
|
85
|
+
reply.status !== proto.Status.Success || reply.deviceVerifyData?.length !== 32) {
|
|
86
|
+
throw new Error('Security1 authentication failed');
|
|
87
|
+
}
|
|
88
|
+
const verified = await this.cipher.update(reply.deviceVerifyData);
|
|
89
|
+
let difference = 0;
|
|
90
|
+
for (let i = 0; i < this.publicKey.length; i++) difference |= verified[i] ^ this.publicKey[i];
|
|
91
|
+
if (difference !== 0) throw new Error('Security1 device verification failed');
|
|
92
|
+
this.phase = 'established';
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
throw new Error('Unexpected Security1 handshake response');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async encrypt(data: Uint8Array): Promise<Uint8Array> {
|
|
99
|
+
if (!this.isEstablished() || !this.cipher) throw new Error('Security1 session not established');
|
|
100
|
+
return this.cipher.update(data);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async decrypt(data: Uint8Array): Promise<Uint8Array> {
|
|
104
|
+
return this.encrypt(data);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
isEstablished(): boolean {
|
|
108
|
+
return this.phase === 'established';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
File without changes
|
|
@@ -14,7 +14,7 @@ export class SoftAPTransport extends Transport {
|
|
|
14
14
|
*/
|
|
15
15
|
constructor(baseUrl: string) {
|
|
16
16
|
super();
|
|
17
|
-
this.baseUrl = baseUrl;
|
|
17
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
async connect(): Promise<void> {
|
|
@@ -54,7 +54,7 @@ export class SoftAPTransport extends Transport {
|
|
|
54
54
|
'Accept': 'text/plain',
|
|
55
55
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
56
56
|
},
|
|
57
|
-
body: data
|
|
57
|
+
body: new Uint8Array(data),
|
|
58
58
|
mode: 'cors',
|
|
59
59
|
});
|
|
60
60
|
|
|
File without changes
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/** Public proto-ver metadata, including application-defined capability lists. */
|
|
2
|
+
export interface ESPProvisioningMetadata {
|
|
3
|
+
ver?: string;
|
|
4
|
+
cap?: string[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ESPProvisioningInfo {
|
|
8
|
+
prov: ESPProvisioningMetadata & { sec_ver?: number; sec_patch_ver?: number };
|
|
9
|
+
[name: string]: ESPProvisioningMetadata;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Resolve the security configuration after reading public discovery data. */
|
|
13
|
+
export type ESPSecurityResolver = (
|
|
14
|
+
info: ESPProvisioningInfo
|
|
15
|
+
) => ESPSecurityConfig | undefined | Promise<ESPSecurityConfig | undefined>;
|
|
16
|
+
|
|
1
17
|
export type ESPSecurityConfig = ESPSecurity1Config | ESPSecurity2Config;
|
|
2
18
|
|
|
3
19
|
export interface ESPSecurity1Config {
|
|
@@ -72,7 +88,7 @@ export interface ESPScanStatusResponse {
|
|
|
72
88
|
* Device interface for ESP provisioning
|
|
73
89
|
*/
|
|
74
90
|
export interface ESPDeviceInterface {
|
|
75
|
-
connect(security?: ESPSecurityConfig): Promise<void>;
|
|
91
|
+
connect(security?: ESPSecurityConfig | ESPSecurityResolver): Promise<void>;
|
|
76
92
|
|
|
77
93
|
scanWifiList(): Promise<ESPWifiAp[]>;
|
|
78
94
|
|
package/src/utils/crypto.ts
CHANGED
|
@@ -49,7 +49,7 @@ export function bytesToString(bytes: Uint8Array): string {
|
|
|
49
49
|
* SHA-256 hash function
|
|
50
50
|
*/
|
|
51
51
|
export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
|
52
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', data
|
|
52
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', new Uint8Array(data));
|
|
53
53
|
return new Uint8Array(hashBuffer);
|
|
54
54
|
}
|
|
55
55
|
|
|
@@ -59,12 +59,12 @@ export async function sha256(data: Uint8Array): Promise<Uint8Array> {
|
|
|
59
59
|
export async function hmacSha256(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
|
|
60
60
|
const cryptoKey = await crypto.subtle.importKey(
|
|
61
61
|
'raw',
|
|
62
|
-
key
|
|
62
|
+
new Uint8Array(key),
|
|
63
63
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
64
64
|
false,
|
|
65
65
|
['sign']
|
|
66
66
|
);
|
|
67
|
-
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data
|
|
67
|
+
const signature = await crypto.subtle.sign('HMAC', cryptoKey, new Uint8Array(data));
|
|
68
68
|
return new Uint8Array(signature);
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -78,15 +78,15 @@ export async function aesEncrypt(
|
|
|
78
78
|
): Promise<Uint8Array> {
|
|
79
79
|
const cryptoKey = await crypto.subtle.importKey(
|
|
80
80
|
'raw',
|
|
81
|
-
key
|
|
81
|
+
new Uint8Array(key),
|
|
82
82
|
{ name: 'AES-CTR' },
|
|
83
83
|
false,
|
|
84
84
|
['encrypt']
|
|
85
85
|
);
|
|
86
86
|
const encrypted = await crypto.subtle.encrypt(
|
|
87
|
-
{ name: 'AES-CTR', counter: counter
|
|
87
|
+
{ name: 'AES-CTR', counter: new Uint8Array(counter), length: 128 },
|
|
88
88
|
cryptoKey,
|
|
89
|
-
data
|
|
89
|
+
new Uint8Array(data)
|
|
90
90
|
);
|
|
91
91
|
return new Uint8Array(encrypted);
|
|
92
92
|
}
|
|
@@ -101,15 +101,15 @@ export async function aesDecrypt(
|
|
|
101
101
|
): Promise<Uint8Array> {
|
|
102
102
|
const cryptoKey = await crypto.subtle.importKey(
|
|
103
103
|
'raw',
|
|
104
|
-
key
|
|
104
|
+
new Uint8Array(key),
|
|
105
105
|
{ name: 'AES-CTR' },
|
|
106
106
|
false,
|
|
107
107
|
['decrypt']
|
|
108
108
|
);
|
|
109
109
|
const decrypted = await crypto.subtle.decrypt(
|
|
110
|
-
{ name: 'AES-CTR', counter: counter
|
|
110
|
+
{ name: 'AES-CTR', counter: new Uint8Array(counter), length: 128 },
|
|
111
111
|
cryptoKey,
|
|
112
|
-
data
|
|
112
|
+
new Uint8Array(data)
|
|
113
113
|
);
|
|
114
114
|
return new Uint8Array(decrypted);
|
|
115
115
|
}
|
|
@@ -127,3 +127,37 @@ export function concatBytes(...arrays: Uint8Array[]): Uint8Array {
|
|
|
127
127
|
}
|
|
128
128
|
return result;
|
|
129
129
|
}
|
|
130
|
+
|
|
131
|
+
/** Stateful AES-CTR, preserving partial blocks across requests and responses. */
|
|
132
|
+
export class AESCTRStream {
|
|
133
|
+
private offset = 0;
|
|
134
|
+
|
|
135
|
+
private constructor(private key: CryptoKey, private counter: Uint8Array<ArrayBuffer>) {}
|
|
136
|
+
|
|
137
|
+
static async create(key: Uint8Array, counter: Uint8Array): Promise<AESCTRStream> {
|
|
138
|
+
if (key.length !== 32 || counter.length !== 16) throw new Error('Invalid AES-256-CTR parameters');
|
|
139
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
140
|
+
'raw', new Uint8Array(key), 'AES-CTR', false, ['encrypt']
|
|
141
|
+
);
|
|
142
|
+
return new AESCTRStream(cryptoKey, new Uint8Array(counter));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async update(data: Uint8Array): Promise<Uint8Array> {
|
|
146
|
+
// Web Crypto restarts at a block boundary. Skip the already-consumed bytes
|
|
147
|
+
// of the current block to match mbedTLS's continuous stream in both directions.
|
|
148
|
+
const input = new Uint8Array(this.offset + data.length);
|
|
149
|
+
input.set(data, this.offset);
|
|
150
|
+
const output = await crypto.subtle.encrypt(
|
|
151
|
+
{ name: 'AES-CTR', counter: this.counter, length: 128 }, this.key, input
|
|
152
|
+
);
|
|
153
|
+
const result = new Uint8Array(output).slice(this.offset);
|
|
154
|
+
let blocks = Math.floor(input.length / 16);
|
|
155
|
+
for (let i = this.counter.length - 1; i >= 0 && blocks > 0; i--) {
|
|
156
|
+
const sum = this.counter[i] + blocks;
|
|
157
|
+
this.counter[i] = sum & 0xff;
|
|
158
|
+
blocks = Math.floor(sum / 256);
|
|
159
|
+
}
|
|
160
|
+
this.offset = input.length % 16;
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
}
|