summaryrefslogtreecommitdiff
path: root/regress/unittests/sshsig/webauthn.html
diff options
context:
space:
mode:
Diffstat (limited to 'regress/unittests/sshsig/webauthn.html')
-rw-r--r--regress/unittests/sshsig/webauthn.html692
1 files changed, 692 insertions, 0 deletions
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>
9This is a demo/test page for generating FIDO keys and signatures in SSH
10formats. The page initially displays a form to generate a FIDO key and
11convert it to a SSH public key.
12</p>
13<p>
14Once a key has been generated, an additional form will be displayed to
15allow signing of data using the just-generated key. The data may be signed
16as either a raw SSH signature or wrapped in a sshsig message (the latter is
17easier to test using command-line tools.
18</p>
19<p>
20Lots 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
88var CBORDecode = function(buffer) {
89 this.buf = buffer
90 this.v = new DataView(buffer)
91 this.offset = 0
92}
93
94CBORDecode.prototype.empty = function() {
95 return this.offset >= this.buf.byteLength
96}
97
98CBORDecode.prototype.getU8 = function() {
99 let r = this.v.getUint8(this.offset)
100 this.offset += 1
101 return r
102}
103
104CBORDecode.prototype.getU16 = function() {
105 let r = this.v.getUint16(this.offset)
106 this.offset += 2
107 return r
108}
109
110CBORDecode.prototype.getU32 = function() {
111 let r = this.v.getUint32(this.offset)
112 this.offset += 4
113 return r
114}
115
116CBORDecode.prototype.getU64 = function() {
117 let r = this.v.getUint64(this.offset)
118 this.offset += 8
119 return r
120}
121
122CBORDecode.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
130CBORDecode.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
144CBORDecode.prototype.decodeNegint = function(len) {
145 let r = -(this.decodeInteger(len) + 1)
146 return r
147}
148
149CBORDecode.prototype.decodeByteString = function(len) {
150 let r = this.buf.slice(this.offset, this.offset + len)
151 this.offset += len
152 return r
153}
154
155CBORDecode.prototype.decodeTextString = function(len) {
156 let u8dec = new TextDecoder('utf-8')
157 r = u8dec.decode(this.decodeByteString(len))
158 return r
159}
160
161CBORDecode.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
171CBORDecode.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
183CBORDecode.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
194CBORDecode.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
233CBORDecode.prototype.decode = function() {
234 return this.decodeInternal(0)
235}
236
237// ------------------------------------------------------------------
238// a crappy SSH message packer - 20200401 djm@openbsd.org
239
240var SSHMSG = function() {
241 this.r = []
242}
243
244SSHMSG.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
262SSHMSG.prototype.serialiseBase64 = function(v) {
263 let b = this.serialise()
264 return btoa(String.fromCharCode(...new Uint8Array(b)));
265}
266
267SSHMSG.prototype.putU8 = function(v) {
268 this.r.push(new Uint8Array([v]))
269}
270
271SSHMSG.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
280SSHMSG.prototype.put = function(v) {
281 this.r.push(new Uint8Array(v))
282}
283
284SSHMSG.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
291SSHMSG.prototype.putSSHMSG = function(v) {
292 let msg = v.serialise()
293 this.putU32(msg.byteLength)
294 this.put(msg)
295}
296
297SSHMSG.prototype.putBytes = function(v) {
298 this.putU32(v.byteLength)
299 this.put(v)
300}
301
302SSHMSG.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
314function 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}
321function 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}
339function 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}
349function 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}
381function enrollFailure(result) {
382 error("Enroll failed", result)
383}
384function 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
424function 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
459function 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
482async 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
516function 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}
535function assertFailure(result) {
536 error("Assertion failed", result)
537}
538function 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}
553function 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
585function 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
671function toggleNamespaceVisibility() {
672 const assertsigtype = document.getElementById('message_sshsig');
673 const assertsignamespace = document.getElementById('message_namespace');
674 assertsignamespace.disabled = !assertsigtype.checked;
675}
676
677function 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>