diff options
Diffstat (limited to 'regress')
-rw-r--r-- | regress/unittests/sshsig/testdata/ecdsa_sk_webauthn.pub | 1 | ||||
-rw-r--r-- | regress/unittests/sshsig/testdata/ecdsa_sk_webauthn.sig | 13 | ||||
-rw-r--r-- | regress/unittests/sshsig/webauthn.html | 692 |
3 files changed, 706 insertions, 0 deletions
diff --git a/regress/unittests/sshsig/testdata/ecdsa_sk_webauthn.pub b/regress/unittests/sshsig/testdata/ecdsa_sk_webauthn.pub new file mode 100644 index 000000000..1597302ce --- /dev/null +++ b/regress/unittests/sshsig/testdata/ecdsa_sk_webauthn.pub | |||
@@ -0,0 +1 @@ | |||
sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBBRGwDjs4HhJFcn4tJ5Gr72KcmRmCS1OirETxaXvnsNApgoOLF1a/7rxldfSMHm73eT1nhHe97W8qicPPEAKDJQAAAALbWluZHJvdC5vcmc= | |||
diff --git a/regress/unittests/sshsig/testdata/ecdsa_sk_webauthn.sig b/regress/unittests/sshsig/testdata/ecdsa_sk_webauthn.sig new file mode 100644 index 000000000..4bdd8edc6 --- /dev/null +++ b/regress/unittests/sshsig/testdata/ecdsa_sk_webauthn.sig | |||
@@ -0,0 +1,13 @@ | |||
1 | -----BEGIN SSH SIGNATURE----- | ||
2 | U1NIU0lHAAAAAQAAAIYAAAAic2stZWNkc2Etc2hhMi1uaXN0cDI1NkBvcGVuc3NoLmNvbQ | ||
3 | AAAAhuaXN0cDI1NgAAAEEEFEbAOOzgeEkVyfi0nkavvYpyZGYJLU6KsRPFpe+ew0CmCg4s | ||
4 | XVr/uvGV19Iwebvd5PWeEd73tbyqJw88QAoMlAAAAAttaW5kcm90Lm9yZwAAAAh1bml0dG | ||
5 | VzdAAAAAAAAAAGc2hhNTEyAAABhwAAACt3ZWJhdXRobi1zay1lY2RzYS1zaGEyLW5pc3Rw | ||
6 | MjU2QG9wZW5zc2guY29tAAAASQAAACBj2oMT9tb5wRXe6mdmf4/lgAO8wrgr95ouozwNg4 | ||
7 | itnQAAACEAtU9g5wz3HchUiLfLD6plr9T4TiJ32lVCrATSjpiy0SMBAAADHwAAABdodHRw | ||
8 | czovL3d3dy5taW5kcm90Lm9yZwAAAON7InR5cGUiOiJ3ZWJhdXRobi5nZXQiLCJjaGFsbG | ||
9 | VuZ2UiOiJVMU5JVTBsSEFBQUFDSFZ1YVhSMFpYTjBBQUFBQUFBQUFBWnphR0UxTVRJQUFB | ||
10 | QkFMTHU4WmdjU3h0Nk1zRlV6dWlaZ0c2R3dNZEo5ZDd4ZUU3WW9SSXcwZzlpSEpfd3NGRD | ||
11 | cxbzRXbHllenZGV0VqYnFRMHFDN0Z3R3Bqa2pVUVAtTmQ2dyIsIm9yaWdpbiI6Imh0dHBz | ||
12 | Oi8vd3d3Lm1pbmRyb3Qub3JnIiwiY3Jvc3NPcmlnaW4iOmZhbHNlfQAAAAA= | ||
13 | -----END SSH SIGNATURE----- | ||
diff --git a/regress/unittests/sshsig/webauthn.html b/regress/unittests/sshsig/webauthn.html new file mode 100644 index 000000000..953041e61 --- /dev/null +++ b/regress/unittests/sshsig/webauthn.html | |||
@@ -0,0 +1,692 @@ | |||
1 | <html> | ||
2 | <head> | ||
3 | <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/> | ||
4 | <title>webauthn test</title> | ||
5 | </head> | ||
6 | <body onload="init()"> | ||
7 | <h1>webauthn test</h1> | ||
8 | <p> | ||
9 | This is a demo/test page for generating FIDO keys and signatures in SSH | ||
10 | formats. The page initially displays a form to generate a FIDO key and | ||
11 | convert it to a SSH public key. | ||
12 | </p> | ||
13 | <p> | ||
14 | Once a key has been generated, an additional form will be displayed to | ||
15 | allow signing of data using the just-generated key. The data may be signed | ||
16 | as either a raw SSH signature or wrapped in a sshsig message (the latter is | ||
17 | easier to test using command-line tools. | ||
18 | </p> | ||
19 | <p> | ||
20 | Lots of debugging is printed along the way. | ||
21 | </p> | ||
22 | <h2>Enroll</h2> | ||
23 | <span id="error" style="color: #800; font-weight: bold; font-size: 150%;"></span> | ||
24 | <form id="enrollform"> | ||
25 | <table> | ||
26 | <tr> | ||
27 | <td><b>Username:</b></td> | ||
28 | <td><input id="username" type="text" size="20" name="user" value="test" /></td> | ||
29 | </tr> | ||
30 | <tr><td></td><td><input id="assertsubmit" type="submit" value="submit" /></td></tr> | ||
31 | </table> | ||
32 | </form> | ||
33 | <span id="enrollresult" style="visibility: hidden;"> | ||
34 | <h2>clientData</h2> | ||
35 | <pre id="enrollresultjson" style="color: #008; font-family: monospace;"></pre> | ||
36 | <h2>attestationObject raw</h2> | ||
37 | <pre id="enrollresultraw" style="color: #008; font-family: monospace;"></pre> | ||
38 | <h2>attestationObject</h2> | ||
39 | <pre id="enrollresultattestobj" style="color: #008; font-family: monospace;"></pre> | ||
40 | <h2>authData raw</h2> | ||
41 | <pre id="enrollresultauthdataraw" style="color: #008; font-family: monospace;"></pre> | ||
42 | <h2>authData</h2> | ||
43 | <pre id="enrollresultauthdata" style="color: #008; font-family: monospace;"></pre> | ||
44 | <h2>SSH pubkey blob</h2> | ||
45 | <pre id="enrollresultpkblob" style="color: #008; font-family: monospace;"></pre> | ||
46 | <h2>SSH pubkey string</h2> | ||
47 | <pre id="enrollresultpk" style="color: #008; font-family: monospace;"></pre> | ||
48 | </span> | ||
49 | <span id="assertsection" style="visibility: hidden;"> | ||
50 | <h2>Assert</h2> | ||
51 | <form id="assertform"> | ||
52 | <span id="asserterror" style="color: #800; font-weight: bold;"></span> | ||
53 | <table> | ||
54 | <tr> | ||
55 | <td><b>Data to sign:</b></td> | ||
56 | <td><input id="message" type="text" size="20" name="message" value="test" /></td> | ||
57 | </tr> | ||
58 | <tr> | ||
59 | <td><input id="message_sshsig" type="checkbox" checked /> use sshsig format</td> | ||
60 | </tr> | ||
61 | <tr> | ||
62 | <td><b>Signature namespace:</b></td> | ||
63 | <td><input id="message_namespace" type="text" size="20" name="namespace" value="test" /></td> | ||
64 | </tr> | ||
65 | <tr><td></td><td><input type="submit" value="submit" /></td></tr> | ||
66 | </table> | ||
67 | </form> | ||
68 | </span> | ||
69 | <span id="assertresult" style="visibility: hidden;"> | ||
70 | <h2>clientData</h2> | ||
71 | <pre id="assertresultjson" style="color: #008; font-family: monospace;"></pre> | ||
72 | <h2>signature raw</h2> | ||
73 | <pre id="assertresultsigraw" style="color: #008; font-family: monospace;"></pre> | ||
74 | <h2>authenticatorData raw</h2> | ||
75 | <pre id="assertresultauthdataraw" style="color: #008; font-family: monospace;"></pre> | ||
76 | <h2>authenticatorData</h2> | ||
77 | <pre id="assertresultauthdata" style="color: #008; font-family: monospace;"></pre> | ||
78 | <h2>signature in SSH format</h2> | ||
79 | <pre id="assertresultsshsigraw" style="color: #008; font-family: monospace;"></pre> | ||
80 | <h2>signature in SSH format (base64 encoded)</h2> | ||
81 | <pre id="assertresultsshsigb64" style="color: #008; font-family: monospace;"></pre> | ||
82 | </span> | ||
83 | </body> | ||
84 | <script> | ||
85 | // ------------------------------------------------------------------ | ||
86 | // a crappy CBOR decoder - 20200401 djm@openbsd.org | ||
87 | |||
88 | var CBORDecode = function(buffer) { | ||
89 | this.buf = buffer | ||
90 | this.v = new DataView(buffer) | ||
91 | this.offset = 0 | ||
92 | } | ||
93 | |||
94 | CBORDecode.prototype.empty = function() { | ||
95 | return this.offset >= this.buf.byteLength | ||
96 | } | ||
97 | |||
98 | CBORDecode.prototype.getU8 = function() { | ||
99 | let r = this.v.getUint8(this.offset) | ||
100 | this.offset += 1 | ||
101 | return r | ||
102 | } | ||
103 | |||
104 | CBORDecode.prototype.getU16 = function() { | ||
105 | let r = this.v.getUint16(this.offset) | ||
106 | this.offset += 2 | ||
107 | return r | ||
108 | } | ||
109 | |||
110 | CBORDecode.prototype.getU32 = function() { | ||
111 | let r = this.v.getUint32(this.offset) | ||
112 | this.offset += 4 | ||
113 | return r | ||
114 | } | ||
115 | |||
116 | CBORDecode.prototype.getU64 = function() { | ||
117 | let r = this.v.getUint64(this.offset) | ||
118 | this.offset += 8 | ||
119 | return r | ||
120 | } | ||
121 | |||
122 | CBORDecode.prototype.getCBORTypeLen = function() { | ||
123 | let tl, t, l | ||
124 | tl = this.getU8() | ||
125 | t = (tl & 0xe0) >> 5 | ||
126 | l = tl & 0x1f | ||
127 | return [t, this.decodeInteger(l)] | ||
128 | } | ||
129 | |||
130 | CBORDecode.prototype.decodeInteger = function(len) { | ||
131 | switch (len) { | ||
132 | case 0x18: return this.getU8() | ||
133 | case 0x19: return this.getU16() | ||
134 | case 0x20: return this.getU32() | ||
135 | case 0x21: return this.getU64() | ||
136 | default: | ||
137 | if (len <= 23) { | ||
138 | return len | ||
139 | } | ||
140 | throw new Error("Unsupported int type 0x" + len.toString(16)) | ||
141 | } | ||
142 | } | ||
143 | |||
144 | CBORDecode.prototype.decodeNegint = function(len) { | ||
145 | let r = -(this.decodeInteger(len) + 1) | ||
146 | return r | ||
147 | } | ||
148 | |||
149 | CBORDecode.prototype.decodeByteString = function(len) { | ||
150 | let r = this.buf.slice(this.offset, this.offset + len) | ||
151 | this.offset += len | ||
152 | return r | ||
153 | } | ||
154 | |||
155 | CBORDecode.prototype.decodeTextString = function(len) { | ||
156 | let u8dec = new TextDecoder('utf-8') | ||
157 | r = u8dec.decode(this.decodeByteString(len)) | ||
158 | return r | ||
159 | } | ||
160 | |||
161 | CBORDecode.prototype.decodeArray = function(len, level) { | ||
162 | let r = [] | ||
163 | for (let i = 0; i < len; i++) { | ||
164 | let v = this.decodeInternal(level) | ||
165 | r.push(v) | ||
166 | // console.log("decodeArray level " + level.toString() + " index " + i.toString() + " value " + JSON.stringify(v)) | ||
167 | } | ||
168 | return r | ||
169 | } | ||
170 | |||
171 | CBORDecode.prototype.decodeMap = function(len, level) { | ||
172 | let r = {} | ||
173 | for (let i = 0; i < len; i++) { | ||
174 | let k = this.decodeInternal(level) | ||
175 | let v = this.decodeInternal(level) | ||
176 | r[k] = v | ||
177 | // console.log("decodeMap level " + level.toString() + " key " + k.toString() + " value " + JSON.stringify(v)) | ||
178 | // XXX check string keys, duplicates | ||
179 | } | ||
180 | return r | ||
181 | } | ||
182 | |||
183 | CBORDecode.prototype.decodePrimitive = function(t) { | ||
184 | switch (t) { | ||
185 | case 20: return false | ||
186 | case 21: return true | ||
187 | case 22: return null | ||
188 | case 23: return undefined | ||
189 | default: | ||
190 | throw new Error("Unsupported primitive 0x" + t.toString(2)) | ||
191 | } | ||
192 | } | ||
193 | |||
194 | CBORDecode.prototype.decodeInternal = function(level) { | ||
195 | if (level > 256) { | ||
196 | throw new Error("CBOR nesting too deep") | ||
197 | } | ||
198 | let t, l, r | ||
199 | [t, l] = this.getCBORTypeLen() | ||
200 | // console.log("decode level " + level.toString() + " type " + t.toString() + " len " + l.toString()) | ||
201 | switch (t) { | ||
202 | case 0: | ||
203 | r = this.decodeInteger(l) | ||
204 | break | ||
205 | case 1: | ||
206 | r = this.decodeNegint(l) | ||
207 | break | ||
208 | case 2: | ||
209 | r = this.decodeByteString(l) | ||
210 | break | ||
211 | case 3: | ||
212 | r = this.decodeTextString(l) | ||
213 | break | ||
214 | case 4: | ||
215 | r = this.decodeArray(l, level + 1) | ||
216 | break | ||
217 | case 5: | ||
218 | r = this.decodeMap(l, level + 1) | ||
219 | break | ||
220 | case 6: | ||
221 | console.log("XXX ignored semantic tag " + this.decodeInteger(l).toString()) | ||
222 | break; | ||
223 | case 7: | ||
224 | r = this.decodePrimitive(l) | ||
225 | break | ||
226 | default: | ||
227 | throw new Error("Unsupported type 0x" + t.toString(2) + " len " + l.toString()) | ||
228 | } | ||
229 | // console.log("decode level " + level.toString() + " value " + JSON.stringify(r)) | ||
230 | return r | ||
231 | } | ||
232 | |||
233 | CBORDecode.prototype.decode = function() { | ||
234 | return this.decodeInternal(0) | ||
235 | } | ||
236 | |||
237 | // ------------------------------------------------------------------ | ||
238 | // a crappy SSH message packer - 20200401 djm@openbsd.org | ||
239 | |||
240 | var SSHMSG = function() { | ||
241 | this.r = [] | ||
242 | } | ||
243 | |||
244 | SSHMSG.prototype.serialise = function() { | ||
245 | let len = 0 | ||
246 | for (buf of this.r) { | ||
247 | len += buf.length | ||
248 | } | ||
249 | let r = new ArrayBuffer(len) | ||
250 | let v = new Uint8Array(r) | ||
251 | let offset = 0 | ||
252 | for (buf of this.r) { | ||
253 | v.set(buf, offset) | ||
254 | offset += buf.length | ||
255 | } | ||
256 | if (offset != r.byteLength) { | ||
257 | throw new Error("djm can't count") | ||
258 | } | ||
259 | return r | ||
260 | } | ||
261 | |||
262 | SSHMSG.prototype.serialiseBase64 = function(v) { | ||
263 | let b = this.serialise() | ||
264 | return btoa(String.fromCharCode(...new Uint8Array(b))); | ||
265 | } | ||
266 | |||
267 | SSHMSG.prototype.putU8 = function(v) { | ||
268 | this.r.push(new Uint8Array([v])) | ||
269 | } | ||
270 | |||
271 | SSHMSG.prototype.putU32 = function(v) { | ||
272 | this.r.push(new Uint8Array([ | ||
273 | (v >> 24) & 0xff, | ||
274 | (v >> 16) & 0xff, | ||
275 | (v >> 8) & 0xff, | ||
276 | (v & 0xff) | ||
277 | ])) | ||
278 | } | ||
279 | |||
280 | SSHMSG.prototype.put = function(v) { | ||
281 | this.r.push(new Uint8Array(v)) | ||
282 | } | ||
283 | |||
284 | SSHMSG.prototype.putString = function(v) { | ||
285 | let enc = new TextEncoder(); | ||
286 | let venc = enc.encode(v) | ||
287 | this.putU32(venc.length) | ||
288 | this.put(venc) | ||
289 | } | ||
290 | |||
291 | SSHMSG.prototype.putSSHMSG = function(v) { | ||
292 | let msg = v.serialise() | ||
293 | this.putU32(msg.byteLength) | ||
294 | this.put(msg) | ||
295 | } | ||
296 | |||
297 | SSHMSG.prototype.putBytes = function(v) { | ||
298 | this.putU32(v.byteLength) | ||
299 | this.put(v) | ||
300 | } | ||
301 | |||
302 | SSHMSG.prototype.putECPoint = function(x, y) { | ||
303 | let x8 = new Uint8Array(x) | ||
304 | let y8 = new Uint8Array(y) | ||
305 | this.putU32(1 + x8.length + y8.length) | ||
306 | this.putU8(0x04) // Uncompressed point format. | ||
307 | this.put(x8) | ||
308 | this.put(y8) | ||
309 | } | ||
310 | |||
311 | // ------------------------------------------------------------------ | ||
312 | // webauthn to SSH glue - djm@openbsd.org 20200408 | ||
313 | |||
314 | function error(msg, ...args) { | ||
315 | document.getElementById("error").innerText = msg | ||
316 | console.log(msg) | ||
317 | for (const arg of args) { | ||
318 | console.dir(arg) | ||
319 | } | ||
320 | } | ||
321 | function hexdump(buf) { | ||
322 | const hex = Array.from(new Uint8Array(buf)).map( | ||
323 | b => b.toString(16).padStart(2, "0")) | ||
324 | const fmt = new Array() | ||
325 | for (let i = 0; i < hex.length; i++) { | ||
326 | if ((i % 16) == 0) { | ||
327 | // Prepend length every 16 bytes. | ||
328 | fmt.push(i.toString(16).padStart(4, "0")) | ||
329 | fmt.push(" ") | ||
330 | } | ||
331 | fmt.push(hex[i]) | ||
332 | fmt.push(" ") | ||
333 | if ((i % 16) == 15) { | ||
334 | fmt.push("\n") | ||
335 | } | ||
336 | } | ||
337 | return fmt.join("") | ||
338 | } | ||
339 | function enrollform_submit(event) { | ||
340 | event.preventDefault(); | ||
341 | console.log("submitted") | ||
342 | username = event.target.elements.username.value | ||
343 | if (username === "") { | ||
344 | error("no username specified") | ||
345 | return false | ||
346 | } | ||
347 | enrollStart(username) | ||
348 | } | ||
349 | function enrollStart(username) { | ||
350 | let challenge = new Uint8Array(32) | ||
351 | window.crypto.getRandomValues(challenge) | ||
352 | let userid = new Uint8Array(8) | ||
353 | window.crypto.getRandomValues(userid) | ||
354 | |||
355 | console.log("challenge:" + btoa(challenge)) | ||
356 | console.log("userid:" + btoa(userid)) | ||
357 | |||
358 | let pkopts = { | ||
359 | challenge: challenge, | ||
360 | rp: { | ||
361 | name: "mindrot.org", | ||
362 | id: "mindrot.org", | ||
363 | }, | ||
364 | user: { | ||
365 | id: userid, | ||
366 | name: username, | ||
367 | displayName: username, | ||
368 | }, | ||
369 | authenticatorSelection: { | ||
370 | authenticatorAttachment: "cross-platform", | ||
371 | userVerification: "discouraged", | ||
372 | }, | ||
373 | pubKeyCredParams: [{alg: -7, type: "public-key"}], // ES256 | ||
374 | timeout: 30 * 1000, | ||
375 | }; | ||
376 | console.dir(pkopts) | ||
377 | window.enrollOpts = pkopts | ||
378 | let credpromise = navigator.credentials.create({ publicKey: pkopts }); | ||
379 | credpromise.then(enrollSuccess, enrollFailure) | ||
380 | } | ||
381 | function enrollFailure(result) { | ||
382 | error("Enroll failed", result) | ||
383 | } | ||
384 | function enrollSuccess(result) { | ||
385 | console.log("Enroll succeeded") | ||
386 | console.dir(result) | ||
387 | window.enrollResult = result | ||
388 | document.getElementById("enrollresult").style.visibility = "visible" | ||
389 | |||
390 | // Show the clientData | ||
391 | let u8dec = new TextDecoder('utf-8') | ||
392 | clientData = u8dec.decode(result.response.clientDataJSON) | ||
393 | document.getElementById("enrollresultjson").innerText = clientData | ||
394 | |||
395 | // Decode and show the attestationObject | ||
396 | document.getElementById("enrollresultraw").innerText = hexdump(result.response.attestationObject) | ||
397 | let aod = new CBORDecode(result.response.attestationObject) | ||
398 | let attestationObject = aod.decode() | ||
399 | console.log("attestationObject") | ||
400 | console.dir(attestationObject) | ||
401 | document.getElementById("enrollresultattestobj").innerText = JSON.stringify(attestationObject) | ||
402 | |||
403 | // Decode and show the authData | ||
404 | document.getElementById("enrollresultauthdataraw").innerText = hexdump(attestationObject.authData) | ||
405 | let authData = decodeAuthenticatorData(attestationObject.authData, true) | ||
406 | console.log("authData") | ||
407 | console.dir(authData) | ||
408 | window.enrollAuthData = authData | ||
409 | document.getElementById("enrollresultauthdata").innerText = JSON.stringify(authData) | ||
410 | |||
411 | // Reformat the pubkey as a SSH key for easy verification | ||
412 | window.rawKey = reformatPubkey(authData.attestedCredentialData.credentialPublicKey, window.enrollOpts.rp.id) | ||
413 | console.log("SSH pubkey blob") | ||
414 | console.dir(window.rawKey) | ||
415 | document.getElementById("enrollresultpkblob").innerText = hexdump(window.rawKey) | ||
416 | let pk64 = btoa(String.fromCharCode(...new Uint8Array(window.rawKey))); | ||
417 | let pk = "sk-ecdsa-sha2-nistp256@openssh.com " + pk64 | ||
418 | document.getElementById("enrollresultpk").innerText = pk | ||
419 | |||
420 | // Success: show the assertion form. | ||
421 | document.getElementById("assertsection").style.visibility = "visible" | ||
422 | } | ||
423 | |||
424 | function decodeAuthenticatorData(authData, expectCred) { | ||
425 | let r = new Object() | ||
426 | let v = new DataView(authData) | ||
427 | |||
428 | r.rpIdHash = authData.slice(0, 32) | ||
429 | r.flags = v.getUint8(32) | ||
430 | r.signCount = v.getUint32(33) | ||
431 | |||
432 | // Decode attestedCredentialData if present. | ||
433 | let offset = 37 | ||
434 | let acd = new Object() | ||
435 | if (expectCred) { | ||
436 | acd.aaguid = authData.slice(offset, offset+16) | ||
437 | offset += 16 | ||
438 | let credentialIdLength = v.getUint16(offset) | ||
439 | offset += 2 | ||
440 | acd.credentialIdLength = credentialIdLength | ||
441 | acd.credentialId = authData.slice(offset, offset+credentialIdLength) | ||
442 | offset += credentialIdLength | ||
443 | r.attestedCredentialData = acd | ||
444 | } | ||
445 | console.log("XXXXX " + offset.toString()) | ||
446 | let pubkeyrest = authData.slice(offset, authData.byteLength) | ||
447 | let pkdecode = new CBORDecode(pubkeyrest) | ||
448 | if (expectCred) { | ||
449 | // XXX unsafe: doesn't mandate COSE canonical format. | ||
450 | acd.credentialPublicKey = pkdecode.decode() | ||
451 | } | ||
452 | if (!pkdecode.empty()) { | ||
453 | // Decode extensions if present. | ||
454 | r.extensions = pkdecode.decode() | ||
455 | } | ||
456 | return r | ||
457 | } | ||
458 | |||
459 | function reformatPubkey(pk, rpid) { | ||
460 | // pk is in COSE format. We only care about a tiny subset. | ||
461 | if (pk[1] != 2) { | ||
462 | console.dir(pk) | ||
463 | throw new Error("pubkey is not EC") | ||
464 | } | ||
465 | if (pk[-1] != 1) { | ||
466 | throw new Error("pubkey is not in P256") | ||
467 | } | ||
468 | if (pk[3] != -7) { | ||
469 | throw new Error("pubkey is not ES256") | ||
470 | } | ||
471 | if (pk[-2].byteLength != 32 || pk[-3].byteLength != 32) { | ||
472 | throw new Error("pubkey EC coords have bad length") | ||
473 | } | ||
474 | let msg = new SSHMSG() | ||
475 | msg.putString("sk-ecdsa-sha2-nistp256@openssh.com") // Key type | ||
476 | msg.putString("nistp256") // Key curve | ||
477 | msg.putECPoint(pk[-2], pk[-3]) // EC key | ||
478 | msg.putString(rpid) // RP ID | ||
479 | return msg.serialise() | ||
480 | } | ||
481 | |||
482 | async function assertform_submit(event) { | ||
483 | event.preventDefault(); | ||
484 | console.log("submitted") | ||
485 | message = event.target.elements.message.value | ||
486 | if (message === "") { | ||
487 | error("no message specified") | ||
488 | return false | ||
489 | } | ||
490 | let enc = new TextEncoder() | ||
491 | let encmsg = enc.encode(message) | ||
492 | window.assertSignRaw = !event.target.elements.message_sshsig.checked | ||
493 | console.log("using sshsig ", !window.assertSignRaw) | ||
494 | if (window.assertSignRaw) { | ||
495 | assertStart(encmsg) | ||
496 | return | ||
497 | } | ||
498 | // Format a sshsig-style message. | ||
499 | window.sigHashAlg = "sha512" | ||
500 | let msghash = await crypto.subtle.digest("SHA-512", encmsg); | ||
501 | console.log("raw message hash") | ||
502 | console.dir(msghash) | ||
503 | window.sigNamespace = event.target.elements.message_namespace.value | ||
504 | let sigbuf = new SSHMSG() | ||
505 | sigbuf.put(enc.encode("SSHSIG")) | ||
506 | sigbuf.putString(window.sigNamespace) | ||
507 | sigbuf.putU32(0) // Reserved string | ||
508 | sigbuf.putString(window.sigHashAlg) | ||
509 | sigbuf.putBytes(msghash) | ||
510 | let msg = sigbuf.serialise() | ||
511 | console.log("sigbuf") | ||
512 | console.dir(msg) | ||
513 | assertStart(msg) | ||
514 | } | ||
515 | |||
516 | function assertStart(message) { | ||
517 | let assertReqOpts = { | ||
518 | challenge: message, | ||
519 | rpId: "mindrot.org", | ||
520 | allowCredentials: [{ | ||
521 | type: 'public-key', | ||
522 | id: window.enrollResult.rawId, | ||
523 | }], | ||
524 | userVerification: "discouraged", | ||
525 | timeout: (30 * 1000), | ||
526 | } | ||
527 | console.log("assertReqOpts") | ||
528 | console.dir(assertReqOpts) | ||
529 | window.assertReqOpts = assertReqOpts | ||
530 | let assertpromise = navigator.credentials.get({ | ||
531 | publicKey: assertReqOpts | ||
532 | }); | ||
533 | assertpromise.then(assertSuccess, assertFailure) | ||
534 | } | ||
535 | function assertFailure(result) { | ||
536 | error("Assertion failed", result) | ||
537 | } | ||
538 | function linewrap(s) { | ||
539 | const linelen = 70 | ||
540 | let ret = "" | ||
541 | for (let i = 0; i < s.length; i += linelen) { | ||
542 | end = i + linelen | ||
543 | if (end > s.length) { | ||
544 | end = s.length | ||
545 | } | ||
546 | if (i > 0) { | ||
547 | ret += "\n" | ||
548 | } | ||
549 | ret += s.slice(i, end) | ||
550 | } | ||
551 | return ret + "\n" | ||
552 | } | ||
553 | function assertSuccess(result) { | ||
554 | console.log("Assertion succeeded") | ||
555 | console.dir(result) | ||
556 | window.assertResult = result | ||
557 | document.getElementById("assertresult").style.visibility = "visible" | ||
558 | |||
559 | // show the clientData. | ||
560 | let u8dec = new TextDecoder('utf-8') | ||
561 | clientData = u8dec.decode(result.response.clientDataJSON) | ||
562 | document.getElementById("assertresultjson").innerText = clientData | ||
563 | |||
564 | // show the signature. | ||
565 | document.getElementById("assertresultsigraw").innerText = hexdump(result.response.signature) | ||
566 | |||
567 | // decode and show the authData. | ||
568 | document.getElementById("assertresultauthdataraw").innerText = hexdump(result.response.authenticatorData) | ||
569 | authData = decodeAuthenticatorData(result.response.authenticatorData, false) | ||
570 | document.getElementById("assertresultauthdata").innerText = JSON.stringify(authData) | ||
571 | |||
572 | // Parse and reformat the signature to an SSH style signature. | ||
573 | let sshsig = reformatSignature(result.response.signature, clientData, authData) | ||
574 | document.getElementById("assertresultsshsigraw").innerText = hexdump(sshsig) | ||
575 | let sig64 = btoa(String.fromCharCode(...new Uint8Array(sshsig))); | ||
576 | if (window.assertSignRaw) { | ||
577 | document.getElementById("assertresultsshsigb64").innerText = sig64 | ||
578 | } else { | ||
579 | document.getElementById("assertresultsshsigb64").innerText = | ||
580 | "-----BEGIN SSH SIGNATURE-----\n" + linewrap(sig64) + | ||
581 | "-----END SSH SIGNATURE-----\n"; | ||
582 | } | ||
583 | } | ||
584 | |||
585 | function reformatSignature(sig, clientData, authData) { | ||
586 | if (sig.byteLength < 2) { | ||
587 | throw new Error("signature is too short") | ||
588 | } | ||
589 | let offset = 0 | ||
590 | let v = new DataView(sig) | ||
591 | // Expect an ASN.1 SEQUENCE that exactly spans the signature. | ||
592 | if (v.getUint8(offset) != 0x30) { | ||
593 | throw new Error("signature not an ASN.1 sequence") | ||
594 | } | ||
595 | offset++ | ||
596 | let seqlen = v.getUint8(offset) | ||
597 | offset++ | ||
598 | if ((seqlen & 0x80) != 0 || seqlen != sig.byteLength - offset) { | ||
599 | throw new Error("signature has unexpected length " + seqlen.toString() + " vs expected " + (sig.byteLength - offset).toString()) | ||
600 | } | ||
601 | |||
602 | // Parse 'r' INTEGER value. | ||
603 | if (v.getUint8(offset) != 0x02) { | ||
604 | throw new Error("signature r not an ASN.1 integer") | ||
605 | } | ||
606 | offset++ | ||
607 | let rlen = v.getUint8(offset) | ||
608 | offset++ | ||
609 | if ((rlen & 0x80) != 0 || rlen > sig.byteLength - offset) { | ||
610 | throw new Error("signature r has unexpected length " + rlen.toString() + " vs buffer " + (sig.byteLength - offset).toString()) | ||
611 | } | ||
612 | let r = sig.slice(offset, offset + rlen) | ||
613 | offset += rlen | ||
614 | console.log("sig_r") | ||
615 | console.dir(r) | ||
616 | |||
617 | // Parse 's' INTEGER value. | ||
618 | if (v.getUint8(offset) != 0x02) { | ||
619 | throw new Error("signature r not an ASN.1 integer") | ||
620 | } | ||
621 | offset++ | ||
622 | let slen = v.getUint8(offset) | ||
623 | offset++ | ||
624 | if ((slen & 0x80) != 0 || slen > sig.byteLength - offset) { | ||
625 | throw new Error("signature s has unexpected length " + slen.toString() + " vs buffer " + (sig.byteLength - offset).toString()) | ||
626 | } | ||
627 | let s = sig.slice(offset, offset + slen) | ||
628 | console.log("sig_s") | ||
629 | console.dir(s) | ||
630 | offset += slen | ||
631 | |||
632 | if (offset != sig.byteLength) { | ||
633 | throw new Error("unexpected final offset during signature parsing " + offset.toString() + " expected " + sig.byteLength.toString()) | ||
634 | } | ||
635 | |||
636 | // Reformat as an SSH signature. | ||
637 | let clientDataParsed = JSON.parse(clientData) | ||
638 | let innersig = new SSHMSG() | ||
639 | innersig.putBytes(r) | ||
640 | innersig.putBytes(s) | ||
641 | |||
642 | let rawsshsig = new SSHMSG() | ||
643 | rawsshsig.putString("webauthn-sk-ecdsa-sha2-nistp256@openssh.com") | ||
644 | rawsshsig.putSSHMSG(innersig) | ||
645 | rawsshsig.putU8(authData.flags) | ||
646 | rawsshsig.putU32(authData.signCount) | ||
647 | rawsshsig.putString(clientDataParsed.origin) | ||
648 | rawsshsig.putString(clientData) | ||
649 | if (authData.extensions == undefined) { | ||
650 | rawsshsig.putU32(0) | ||
651 | } else { | ||
652 | rawsshsig.putBytes(authData.extensions) | ||
653 | } | ||
654 | |||
655 | if (window.assertSignRaw) { | ||
656 | return rawsshsig.serialise() | ||
657 | } | ||
658 | // Format as SSHSIG. | ||
659 | let enc = new TextEncoder() | ||
660 | let sshsig = new SSHMSG() | ||
661 | sshsig.put(enc.encode("SSHSIG")) | ||
662 | sshsig.putU32(0x01) // Signature version. | ||
663 | sshsig.putBytes(window.rawKey) | ||
664 | sshsig.putString(window.sigNamespace) | ||
665 | sshsig.putU32(0) // Reserved string | ||
666 | sshsig.putString(window.sigHashAlg) | ||
667 | sshsig.putBytes(rawsshsig.serialise()) | ||
668 | return sshsig.serialise() | ||
669 | } | ||
670 | |||
671 | function toggleNamespaceVisibility() { | ||
672 | const assertsigtype = document.getElementById('message_sshsig'); | ||
673 | const assertsignamespace = document.getElementById('message_namespace'); | ||
674 | assertsignamespace.disabled = !assertsigtype.checked; | ||
675 | } | ||
676 | |||
677 | function init() { | ||
678 | if (document.location.protocol != "https:") { | ||
679 | error("This page must be loaded via https") | ||
680 | const assertsubmit = document.getElementById('assertsubmit') | ||
681 | assertsubmit.disabled = true | ||
682 | } | ||
683 | const enrollform = document.getElementById('enrollform'); | ||
684 | enrollform.addEventListener('submit', enrollform_submit); | ||
685 | const assertform = document.getElementById('assertform'); | ||
686 | assertform.addEventListener('submit', assertform_submit); | ||
687 | const assertsigtype = document.getElementById('message_sshsig'); | ||
688 | assertsigtype.onclick = toggleNamespaceVisibility; | ||
689 | } | ||
690 | </script> | ||
691 | |||
692 | </html> | ||