-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfrontend.html
More file actions
189 lines (166 loc) · 7.68 KB
/
frontend.html
File metadata and controls
189 lines (166 loc) · 7.68 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Subtle-only X25519 + AES-GCM</title>
</head>
<p>Open the console to see the secure message exchanged</p>
<p>Refresh the page to send more secure messages</p>
<body>
<script type="module">
const url = "http://127.0.0.1:8080"
// byte array to base64
const toBase64 = b => btoa(String.fromCharCode(...new Uint8Array(b)));
//base 64 to byte array
const toByteArr = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
// HKDF -> raw AES-256 Key
async function hkdf(sharedSecret, info){
const hkdfAlg = {name :'HKDF', hash: "SHA-256", salt: new Uint8Array(32), info };
const raw = await crypto.subtle.deriveBits(
hkdfAlg,
await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveBits', 'deriveKey']),
256);
return raw;
}
// take the raw AES key bits and convert them into the correct format for encryption and decryption
async function importAes(raw){
return crypto.subtle.importKey(
"raw", raw, {name: 'AES-GCM', length: 256}, false, ['encrypt', 'decrypt']
);
}
function isResponseFresh(rfc3339, opts = {}) {
const { maxSkewSec = 60, futureSlackSec = 60 } = opts;
const serverMs = Date.parse(rfc3339);
if (Number.isNaN(serverMs)) throw new Error('Invalid RFC3339 timestamp');
const nowMs = Date.now();
// we want 60 seconds so 60*1000ms
const skewMs = maxSkewSec * 1000;
const futureMs = futureSlackSec * 1000;
return serverMs >= nowMs - skewMs && serverMs <= nowMs + futureMs;
}
async function ed25519Verify(signatureB64, data) {
const sigPublicKey ="W57lhSShASDfTDYPQpGpkJnbEAf84QtrGIZWtvi2+rk="; // it's base64 already
const pubKey = await crypto.subtle.importKey(
'raw', toByteArr(sigPublicKey), { name: 'Ed25519' },false,['verify']
);
return crypto.subtle.verify('Ed25519', pubKey,
toByteArr(signatureB64),
typeof data === 'string' ? new TextEncoder().encode(data) : data
);
}
async function verifyResponse(response, txCounter){
const res = response.clone();
const respTimestamp = res.headers.get('X-Timestamp');
const respSig = res.headers.get('X-Signature');
const contentType = res.headers.get('Content-Type');
// console.log([...res.headers.entries()]); // human-readable list
if (! isResponseFresh(respTimestamp)){
throw new Error('Response not fresh, timestamp outside of safe window. possible replay attac')
}
let data = ""
if (contentType === "text/plain"){
data = `${contentType}\n${respTimestamp}\n${await res.text()}`
} else if (contentType === "application/json"){
const json= await res.json();
const canonical = JSON.stringify(json, Object.keys(json).sort());
data = `${contentType}\n${respTimestamp}\n${canonical}`
} else {
throw new Error ('unknow content type')
}
if (! await ed25519Verify(respSig, data)){
throw new Error('verifying ed25519 signature failed');
};
console.log("Signature verified, response came from the real server.")
}
function base64ToBigInt(b64) {
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const view = new DataView(bytes.buffer);
return view.getBigUint64(0, false); // false = big-endian
}
function bigIntToBase64(n) {
if (n < 0n || n > 0xFFFFFFFFFFFFFFFFn) {
throw RangeError('value out of uint64 range');
}
const buf = new ArrayBuffer(8);
const view = new DataView(buf);
view.setBigUint64(0, n, false); // false = big-endian
const bytes = new Uint8Array(buf);
return btoa(String.fromCharCode(...bytes));
}
// -- Step 1. generate the ephemeral key exchange pair (X25519) -- //
const dh = {name: 'X25519'};
const keypair = await crypto.subtle.generateKey(
dh, false, ['deriveBits']
);
// -- Step 2 export public key
const pubRaw = await crypto.subtle.exportKey('raw', keypair.publicKey);
// -- Step 3 fetch server's public key
const rsp = await fetch(url + '/kx/pub');
if (!rsp.ok) throw new Error('Failed to fetch server pubkey');
await verifyResponse(rsp);
const serverPubB64 = await rsp.text();
const serverPub = await crypto.subtle.importKey(
'raw', toByteArr(serverPubB64), dh, false, []
);
// -- Step 4 derive 32 byte shared secret
const sharedSecret = await crypto.subtle.deriveBits({name: 'X25519', public: serverPub},
keypair.privateKey, 256
);
// -- Step 5 use HKDF to get AES-GCM encrypt and decrypt symmetric keys
// tx key is our transmit key
const tx = await importAes(
await hkdf(sharedSecret, new TextEncoder().encode('client-to-server'))
);
// rx is our receive key
const rx = await importAes(
await hkdf(sharedSecret, new TextEncoder().encode('server-to-client'))
);
// -- Step 6 encrypt a message to send to the server
const plaintext = new TextEncoder().encode('The client says hey!');
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV aka nonce
const cipher = await crypto.subtle.encrypt(
{name :'AES-GCM', iv},
tx,
plaintext
);
// -- Step 7 handshake to get session id
const hs = await fetch(url + '/kx/handshake', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify( {"clientPub": toBase64(pubRaw)})
});
const {sessionId, clientCounter, serverCounter} = await hs.json();
let rxCounter = base64ToBigInt(serverCounter);
let txCounter = base64ToBigInt(clientCounter);
// -- Step 8 send encrypted msg we made at step 6
const body = new Uint8Array(12 + cipher.byteLength);
body.set(iv, 0);
body.set(new Uint8Array(cipher), 12);
txCounter += BigInt(1);
const reply = await fetch(url + '/kx/send' ,{
method: 'POST',
headers: {'Content-Type': 'application/octet-stream',
'X-Session-ID': sessionId,
'X-Sequence': bigIntToBase64(txCounter),
},
body: body
});
if (!reply.ok) { console.error('server rejected'); }
if (!reply.headers.get("X-Sequence")) { console.error('no sequence header'); }
const replySeq = reply.headers.get("X-Sequence")
if (base64ToBigInt(replySeq) !== rxCounter + BigInt(1) ) {
console.error("server reply out of sequence");
}
// -- Step 9 decrypt the server's reply
const replyBuf = new Uint8Array(await reply.arrayBuffer())
const serverIvBytes = replyBuf.slice(0, 12);
const serverCtBytes = replyBuf.slice(12);
const plaintextReply = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: serverIvBytes},
rx,
serverCtBytes
);
console.log('server said:', new TextDecoder().decode(plaintextReply));
</script>
</body>
</html>