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