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> 693