Move stuff around.

- Rename fido2/pyu2f -> fido2/_pyu2f
- Partial docstrings, with type information.
- Rewrote server example.
This commit is contained in:
Dain Nilsson 2018-07-03 14:57:00 +02:00
parent 7b2b5ba232
commit 1edb0d9d4e
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
35 changed files with 1296 additions and 282 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@
build/
dist/
.eggs/
.idea/
.ropeproject/
ChangeLog
man/*.1

View File

@ -4,4 +4,4 @@ repos:
hooks:
- id: flake8
- id: double-quote-string-fixer
exclude: '^(fido2|test)/pyu2f/.*'
exclude: '^(fido2|test)/_pyu2f/.*'

View File

@ -1,4 +1,4 @@
== fido2
== python-fido2
image:https://travis-ci.org/Yubico/python-fido2.svg?branch=master["Travis CI Status", link="https://travis-ci.org/Yubico/python-fido2"]
image:https://ci.appveyor.com/api/projects/status/8orx9nbdfq52w47s/branch/master?svg=true["Appveyor Status", link="https://ci.appveyor.com/project/Yubico53275/python-fido-host/branch/master"]

View File

@ -38,193 +38,96 @@ Now navigate to https://localhost:5000 in a supported web browser.
from __future__ import print_function, absolute_import, unicode_literals
from fido2.client import ClientData
from fido2.server import Fido2Server
from fido2.ctap2 import AttestationObject, AuthenticatorData
from flask import Flask, request
from fido2 import cbor
from flask import Flask, session, request, redirect, abort
import os
HTML = """
<html>
<head><title>Fido 2.0 webauthn demo</title></head>
<body>
<h1>Webauthn demo</h1>
<p>
<strong>This demo requires a browser supporting the WebAuthn API!</strong>
</p>
<hr>
{content}
</body>
</html>
"""
app = Flask(__name__, static_url_path='')
app.secret_key = os.urandom(32) # Used for session.
INDEX_HTML = HTML.format(content="""
<a href="/register">Register</a><br>
<a href="/authenticate">Authenticate</a><br>
""")
REGISTER_HTML = HTML.format(content="""
<h2>Register a credential</h2>
<p>Touch your authenticator device now...</p>
<script>
navigator.credentials.create({{
publicKey: {{
rp: {{
id: document.domain,
name: 'Demo server'
}},
user: {{
id: {user_id},
name: 'a_user',
displayName: 'A. User',
icon: 'https://example.com/image.png'
}},
challenge: {challenge},
pubKeyCredParams: [
{{
alg: -7,
type: 'public-key'
}}
],
excludeCredentials: [],
attestation: 'direct',
timeout: 60000
}}
}}).then(function(attestation) {{
console.log(attestation);
console.log(JSON.stringify({{
attestationObject: Array.from(new Uint8Array(attestation.response.attestationObject)),
clientData: Array.from(new Uint8Array(attestation.response.clientDataJSON))
}}));
fetch('/register', {{
method: 'POST',
body: JSON.stringify({{
attestationObject: Array.from(new Uint8Array(attestation.response.attestationObject)),
clientData: Array.from(new Uint8Array(attestation.response.clientDataJSON))
}})
}}).then(function() {{
alert('Registration successful. More details in server log...');
window.location = '/';
}});
}}, function(reason) {{
console.log('Failed', reason);
}});
</script>
""") # noqa
server = Fido2Server('localhost')
rp = {
'id': 'localhost',
'name': 'Demo server'
}
AUTH_HTML = HTML.format(content="""
<h2>Authenticate using a credential</h2>
<p>Touch your authenticator device now...</p>
<script>
navigator.credentials.get({{
publicKey: {{
rpId: document.domain,
challenge: {challenge},
allowCredentials: [
{{
type: 'public-key',
id: {credential_id}
}}
],
timeout: 60000
}}
}}).then(function(attestation) {{
console.log(attestation);
fetch('/authenticate', {{
method: 'POST',
body: JSON.stringify({{
authenticatorData: Array.from(new Uint8Array(attestation.response.authenticatorData)),
clientData: Array.from(new Uint8Array(attestation.response.clientDataJSON)),
signature: Array.from(new Uint8Array(attestation.response.signature))
}})
}}).then(function() {{
alert('Authentication successful. More details in server log...');
window.location = '/';
}});
}}, function(reason) {{
console.log('Failed', reason);
}});
</script>
""") # noqa
def to_js_array(value):
return 'new Uint8Array(%r)' % list(bytearray(value))
def from_js_array(value):
return bytes(bytearray(value))
app = Flask(__name__)
global credential, last_challenge
credential, last_challenge = None, None
# Registered credentials are stored globally, in memory only. Single user
# support, state is lost when the server terminates.
credentials = []
@app.route('/')
def index():
return INDEX_HTML
return redirect('/index.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
global credential, last_challenge
if request.method == 'POST':
data = request.get_json(force=True)
client_data = ClientData(from_js_array(data['clientData']))
att_obj = AttestationObject(from_js_array(data['attestationObject']))
print('clientData', client_data)
print('AttestationObject:', att_obj)
@app.route('/api/register/begin', methods=['POST'])
def register_begin():
registration_data = server.register_begin(rp, {
'id': b'user_id',
'name': 'a_user',
'displayName': 'A. User',
'icon': 'https://example.com/image.png'
}, credentials)
# Verify the challenge
if client_data.challenge != last_challenge:
raise ValueError('Challenge mismatch!')
# Verify the signature
att_obj.verify(client_data.hash)
credential = att_obj.auth_data.credential_data
print('REGISTERED CREDENTIAL:', credential)
return 'OK'
last_challenge = os.urandom(32)
return REGISTER_HTML.format(
user_id=to_js_array(b'user_id'),
challenge=to_js_array(last_challenge)
)
session['challenge'] = registration_data['publicKey']['challenge']
print('\n\n\n\n')
print(registration_data)
print('\n\n\n\n')
return cbor.dumps(registration_data)
@app.route('/authenticate', methods=['GET', 'POST'])
def authenticate():
global credential, last_challenge
if not credential:
return HTML.format(content='No credential registered!')
@app.route('/api/register/complete', methods=['POST'])
def register_complete():
data = cbor.loads(request.get_data())[0]
client_data = ClientData(data['clientDataJSON'])
att_obj = AttestationObject(data['attestationObject'])
print('clientData', client_data)
print('AttestationObject:', att_obj)
if request.method == 'POST':
data = request.get_json(force=True)
client_data = ClientData(from_js_array(data['clientData']))
auth_data = AuthenticatorData(from_js_array(data['authenticatorData']))
signature = from_js_array(data['signature'])
print('clientData', client_data)
print('AuthenticatorData', auth_data)
auth_data = server.register_complete(
session['challenge'], client_data, att_obj)
# Verify the challenge
if client_data.challenge != last_challenge:
raise ValueError('Challenge mismatch!')
credentials.append(auth_data.credential_data)
print('REGISTERED CREDENTIAL:', auth_data.credential_data)
return cbor.dumps({'status': 'OK'})
# Verify the signature
credential.public_key.verify(auth_data + client_data.hash, signature)
print('ASSERTION OK')
return 'OK'
last_challenge = os.urandom(32)
return AUTH_HTML.format(
challenge=to_js_array(last_challenge),
credential_id=to_js_array(credential.credential_id)
)
@app.route('/api/authenticate/begin', methods=['POST'])
def authenticate_begin():
if not credentials:
abort(404)
auth_data = server.authenticate_begin(rp['id'], credentials)
session['challenge'] = auth_data['publicKey']['challenge']
return cbor.dumps(auth_data)
@app.route('/api/authenticate/complete', methods=['POST'])
def authenticate_complete():
if not credentials:
abort(404)
data = cbor.loads(request.get_data())[0]
credential_id = data['credentialId']
client_data = ClientData(data['clientDataJSON'])
auth_data = AuthenticatorData(data['authenticatorData'])
signature = data['signature']
print('clientData', client_data)
print('AuthenticatorData', auth_data)
server.authenticate_complete(credentials, credential_id,
session.pop('challenge'), client_data,
auth_data, signature)
print('ASSERTION OK')
return cbor.dumps({'status': 'OK'})
if __name__ == '__main__':
print(__doc__)
app.run(ssl_context='adhoc', debug=True)

View File

@ -0,0 +1,47 @@
<html>
<head>
<title>Fido 2.0 webauthn demo</title>
<script src="/cbor.js"></script>
</head>
<body>
<h1>Webauthn demo</h1>
<p>
<strong>This demo requires a browser supporting the WebAuthn API!</strong>
</p>
<hr>
<h2>Authenticate using a credential</h2>
<p>Touch your authenticator device now...</p>
<script>
fetch('/api/authenticate/begin', {
method: 'POST',
}).then(function(response) {
return response.arrayBuffer();
}).then(function(data) {
return CBOR.decode(data);
}).then(function(options) {
navigator.credentials.get(options).then(function(assertion) {
console.log(assertion);
fetch('/api/authenticate/complete', {
method: 'POST',
headers: {'Content-Type': 'application/cbor'},
body: CBOR.encode({
"credentialId": new Uint8Array(assertion.rawId),
"authenticatorData": new Uint8Array(assertion.response.authenticatorData),
"clientDataJSON": new Uint8Array(assertion.response.clientDataJSON),
"signature": new Uint8Array(assertion.response.signature)
})
}).then(function() {
alert('Authentication successful. More details in server log...');
window.location = '/index.html';
});
}, function(reason) {
console.log('Failed', reason);
alert(reason);
window.location = '/index.html';
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,406 @@
/*
* The MIT License (MIT)
*
* Copyright (c) 2014-2016 Patrick Gansterer <paroga@paroga.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
(function(global, undefined) { "use strict";
var POW_2_24 = 5.960464477539063e-8,
POW_2_32 = 4294967296,
POW_2_53 = 9007199254740992;
function encode(value) {
var data = new ArrayBuffer(256);
var dataView = new DataView(data);
var lastLength;
var offset = 0;
function prepareWrite(length) {
var newByteLength = data.byteLength;
var requiredLength = offset + length;
while (newByteLength < requiredLength)
newByteLength <<= 1;
if (newByteLength !== data.byteLength) {
var oldDataView = dataView;
data = new ArrayBuffer(newByteLength);
dataView = new DataView(data);
var uint32count = (offset + 3) >> 2;
for (var i = 0; i < uint32count; ++i)
dataView.setUint32(i << 2, oldDataView.getUint32(i << 2));
}
lastLength = length;
return dataView;
}
function commitWrite() {
offset += lastLength;
}
function writeFloat64(value) {
commitWrite(prepareWrite(8).setFloat64(offset, value));
}
function writeUint8(value) {
commitWrite(prepareWrite(1).setUint8(offset, value));
}
function writeUint8Array(value) {
var dataView = prepareWrite(value.length);
for (var i = 0; i < value.length; ++i)
dataView.setUint8(offset + i, value[i]);
commitWrite();
}
function writeUint16(value) {
commitWrite(prepareWrite(2).setUint16(offset, value));
}
function writeUint32(value) {
commitWrite(prepareWrite(4).setUint32(offset, value));
}
function writeUint64(value) {
var low = value % POW_2_32;
var high = (value - low) / POW_2_32;
var dataView = prepareWrite(8);
dataView.setUint32(offset, high);
dataView.setUint32(offset + 4, low);
commitWrite();
}
function writeTypeAndLength(type, length) {
if (length < 24) {
writeUint8(type << 5 | length);
} else if (length < 0x100) {
writeUint8(type << 5 | 24);
writeUint8(length);
} else if (length < 0x10000) {
writeUint8(type << 5 | 25);
writeUint16(length);
} else if (length < 0x100000000) {
writeUint8(type << 5 | 26);
writeUint32(length);
} else {
writeUint8(type << 5 | 27);
writeUint64(length);
}
}
function encodeItem(value) {
var i;
if (value === false)
return writeUint8(0xf4);
if (value === true)
return writeUint8(0xf5);
if (value === null)
return writeUint8(0xf6);
if (value === undefined)
return writeUint8(0xf7);
switch (typeof value) {
case "number":
if (Math.floor(value) === value) {
if (0 <= value && value <= POW_2_53)
return writeTypeAndLength(0, value);
if (-POW_2_53 <= value && value < 0)
return writeTypeAndLength(1, -(value + 1));
}
writeUint8(0xfb);
return writeFloat64(value);
case "string":
var utf8data = [];
for (i = 0; i < value.length; ++i) {
var charCode = value.charCodeAt(i);
if (charCode < 0x80) {
utf8data.push(charCode);
} else if (charCode < 0x800) {
utf8data.push(0xc0 | charCode >> 6);
utf8data.push(0x80 | charCode & 0x3f);
} else if (charCode < 0xd800) {
utf8data.push(0xe0 | charCode >> 12);
utf8data.push(0x80 | (charCode >> 6) & 0x3f);
utf8data.push(0x80 | charCode & 0x3f);
} else {
charCode = (charCode & 0x3ff) << 10;
charCode |= value.charCodeAt(++i) & 0x3ff;
charCode += 0x10000;
utf8data.push(0xf0 | charCode >> 18);
utf8data.push(0x80 | (charCode >> 12) & 0x3f);
utf8data.push(0x80 | (charCode >> 6) & 0x3f);
utf8data.push(0x80 | charCode & 0x3f);
}
}
writeTypeAndLength(3, utf8data.length);
return writeUint8Array(utf8data);
default:
var length;
if (Array.isArray(value)) {
length = value.length;
writeTypeAndLength(4, length);
for (i = 0; i < length; ++i)
encodeItem(value[i]);
} else if (value instanceof Uint8Array) {
writeTypeAndLength(2, value.length);
writeUint8Array(value);
} else {
var keys = Object.keys(value);
length = keys.length;
writeTypeAndLength(5, length);
for (i = 0; i < length; ++i) {
var key = keys[i];
encodeItem(key);
encodeItem(value[key]);
}
}
}
}
encodeItem(value);
if ("slice" in data)
return data.slice(0, offset);
var ret = new ArrayBuffer(offset);
var retView = new DataView(ret);
for (var i = 0; i < offset; ++i)
retView.setUint8(i, dataView.getUint8(i));
return ret;
}
function decode(data, tagger, simpleValue) {
var dataView = new DataView(data);
var offset = 0;
if (typeof tagger !== "function")
tagger = function(value) { return value; };
if (typeof simpleValue !== "function")
simpleValue = function() { return undefined; };
function commitRead(length, value) {
offset += length;
return value;
}
function readArrayBuffer(length) {
return commitRead(length, new Uint8Array(data, offset, length));
}
function readFloat16() {
var tempArrayBuffer = new ArrayBuffer(4);
var tempDataView = new DataView(tempArrayBuffer);
var value = readUint16();
var sign = value & 0x8000;
var exponent = value & 0x7c00;
var fraction = value & 0x03ff;
if (exponent === 0x7c00)
exponent = 0xff << 10;
else if (exponent !== 0)
exponent += (127 - 15) << 10;
else if (fraction !== 0)
return (sign ? -1 : 1) * fraction * POW_2_24;
tempDataView.setUint32(0, sign << 16 | exponent << 13 | fraction << 13);
return tempDataView.getFloat32(0);
}
function readFloat32() {
return commitRead(4, dataView.getFloat32(offset));
}
function readFloat64() {
return commitRead(8, dataView.getFloat64(offset));
}
function readUint8() {
return commitRead(1, dataView.getUint8(offset));
}
function readUint16() {
return commitRead(2, dataView.getUint16(offset));
}
function readUint32() {
return commitRead(4, dataView.getUint32(offset));
}
function readUint64() {
return readUint32() * POW_2_32 + readUint32();
}
function readBreak() {
if (dataView.getUint8(offset) !== 0xff)
return false;
offset += 1;
return true;
}
function readLength(additionalInformation) {
if (additionalInformation < 24)
return additionalInformation;
if (additionalInformation === 24)
return readUint8();
if (additionalInformation === 25)
return readUint16();
if (additionalInformation === 26)
return readUint32();
if (additionalInformation === 27)
return readUint64();
if (additionalInformation === 31)
return -1;
throw "Invalid length encoding";
}
function readIndefiniteStringLength(majorType) {
var initialByte = readUint8();
if (initialByte === 0xff)
return -1;
var length = readLength(initialByte & 0x1f);
if (length < 0 || (initialByte >> 5) !== majorType)
throw "Invalid indefinite length element";
return length;
}
function appendUtf16Data(utf16data, length) {
for (var i = 0; i < length; ++i) {
var value = readUint8();
if (value & 0x80) {
if (value < 0xe0) {
value = (value & 0x1f) << 6
| (readUint8() & 0x3f);
length -= 1;
} else if (value < 0xf0) {
value = (value & 0x0f) << 12
| (readUint8() & 0x3f) << 6
| (readUint8() & 0x3f);
length -= 2;
} else {
value = (value & 0x0f) << 18
| (readUint8() & 0x3f) << 12
| (readUint8() & 0x3f) << 6
| (readUint8() & 0x3f);
length -= 3;
}
}
if (value < 0x10000) {
utf16data.push(value);
} else {
value -= 0x10000;
utf16data.push(0xd800 | (value >> 10));
utf16data.push(0xdc00 | (value & 0x3ff));
}
}
}
function decodeItem() {
var initialByte = readUint8();
var majorType = initialByte >> 5;
var additionalInformation = initialByte & 0x1f;
var i;
var length;
if (majorType === 7) {
switch (additionalInformation) {
case 25:
return readFloat16();
case 26:
return readFloat32();
case 27:
return readFloat64();
}
}
length = readLength(additionalInformation);
if (length < 0 && (majorType < 2 || 6 < majorType))
throw "Invalid length";
switch (majorType) {
case 0:
return length;
case 1:
return -1 - length;
case 2:
if (length < 0) {
var elements = [];
var fullArrayLength = 0;
while ((length = readIndefiniteStringLength(majorType)) >= 0) {
fullArrayLength += length;
elements.push(readArrayBuffer(length));
}
var fullArray = new Uint8Array(fullArrayLength);
var fullArrayOffset = 0;
for (i = 0; i < elements.length; ++i) {
fullArray.set(elements[i], fullArrayOffset);
fullArrayOffset += elements[i].length;
}
return fullArray;
}
return readArrayBuffer(length);
case 3:
var utf16data = [];
if (length < 0) {
while ((length = readIndefiniteStringLength(majorType)) >= 0)
appendUtf16Data(utf16data, length);
} else
appendUtf16Data(utf16data, length);
return String.fromCharCode.apply(null, utf16data);
case 4:
var retArray;
if (length < 0) {
retArray = [];
while (!readBreak())
retArray.push(decodeItem());
} else {
retArray = new Array(length);
for (i = 0; i < length; ++i)
retArray[i] = decodeItem();
}
return retArray;
case 5:
var retObject = {};
for (i = 0; i < length || length < 0 && !readBreak(); ++i) {
var key = decodeItem();
retObject[key] = decodeItem();
}
return retObject;
case 6:
return tagger(decodeItem(), length);
case 7:
switch (length) {
case 20:
return false;
case 21:
return true;
case 22:
return null;
case 23:
return undefined;
default:
return simpleValue(length);
}
}
}
var ret = decodeItem();
if (offset !== data.byteLength)
throw "Remaining bytes";
return ret;
}
var obj = { encode: encode, decode: decode };
if (typeof define === "function" && define.amd)
define("cbor/cbor", obj);
else if (typeof module !== "undefined" && module.exports)
module.exports = obj;
else if (!global.CBOR)
global.CBOR = obj;
})(this);

View File

@ -0,0 +1,17 @@
<html>
<head>
<title>Fido 2.0 webauthn demo</title>
<script src="/cbor.js"></script>
</head>
<body>
<h1>Webauthn demo</h1>
<p>
<strong>This demo requires a browser supporting the WebAuthn API!</strong>
</p>
<hr>
<a href="/register.html">Register</a><br>
<a href="/authenticate.html">Authenticate</a><br>
</body>
</html>

View File

@ -0,0 +1,46 @@
<html>
<head>
<title>Fido 2.0 webauthn demo</title>
<script src="/cbor.js"></script>
</head>
<body>
<h1>Webauthn demo</h1>
<p>
<strong>This demo requires a browser supporting the WebAuthn API!</strong>
</p>
<hr>
<h2>Register a credential</h2>
<p>Touch your authenticator device now...</p>
<script>
fetch('/api/register/begin', {
method: 'POST',
}).then(function(response) {
return response.arrayBuffer();
}).then(function(data) {
return CBOR.decode(data);
}).then(function(options) {
navigator.credentials.create(options).then(function(attestation) {
console.log(attestation.response);
console.log(CBOR.encode(attestation.response));
fetch('/api/register/complete', {
method: 'POST',
headers: {'Content-Type': 'application/cbor'},
body: CBOR.encode({
"attestationObject": new Uint8Array(attestation.response.attestationObject),
"clientDataJSON": new Uint8Array(attestation.response.clientDataJSON),
})
}).then(function() {
alert('Registration successful. More details in server log...');
window.location = '/index.html';
});
}, function(reason) {
console.log('Failed', reason);
alert(reason);
window.location = '/index.html';
});
});
</script>
</body>
</html>

View File

@ -34,7 +34,6 @@ if six.PY2:
class ABC(object):
pass
abc.ABC = ABC
abc.abstractclassmethod = abc.abstractmethod
__version__ = '0.3.1-dev0'

View File

@ -210,7 +210,7 @@ class UsbHidTransport(object):
self.packet_size = in_size
self.read_timeout_secs = read_timeout_secs
self.logger = logging.getLogger('pyu2f.hidtransport')
self.logger = logging.getLogger('_pyu2f.hidtransport')
self.InternalInit()

View File

@ -25,7 +25,7 @@ import threading
from . import base
logger = logging.getLogger('pyu2f.macos')
logger = logging.getLogger('_pyu2f.macos')
# Constants
DEVICE_PATH_BUFFER_SIZE = 512

View File

@ -64,7 +64,7 @@ def dump_list(data):
def _sort_keys(entry):
key = entry[0]
return (six.indexbytes(key, 0), len(key), key)
return six.indexbytes(key, 0), len(key), key
def dump_dict(data):
@ -78,8 +78,8 @@ def dump_bytes(data):
def dump_text(data):
data = data.encode('utf8')
return dump_int(len(data), mt=3) + data
data_bytes = data.encode('utf8')
return dump_int(len(data_bytes), mt=3) + data_bytes
_SERIALIZERS = [

View File

@ -40,8 +40,9 @@ import six
class ClientData(bytes):
def __init__(self, data):
self.data = json.loads(data.decode())
def __init__(self, _):
super(ClientData, self).__init__()
self.data = json.loads(self.decode())
def get(self, key):
return self.data[key]
@ -74,7 +75,6 @@ class ClientData(bytes):
class ClientError(Exception):
@unique
class ERR(IntEnum):
OTHER_ERROR = 1
@ -161,7 +161,7 @@ class U2fClient(object):
self.poll_delay = 0.25
self.ctap = CTAP1(device)
self.origin = origin
self._verify = verify_app_id
self._verify = verify
def _verify_app_id(self, app_id):
try:
@ -171,12 +171,12 @@ class U2fClient(object):
pass # Fall through to ClientError
raise ClientError.ERR.BAD_REQUEST()
def register(self, app_id, register_requests, registered_keys,
timeout=None, on_keepalive=None):
def register(self, app_id, register_requests, registered_keys, timeout=None,
on_keepalive=None):
self._verify_app_id(app_id)
version = self.ctap.get_version()
dummy_param = b'\0'*32
dummy_param = b'\0' * 32
for key in registered_keys:
if key['version'] != version:
continue
@ -263,12 +263,12 @@ class Fido2Client(object):
self.origin = origin
self._verify = verify
try:
self.ctap = CTAP2(device)
self.pin_protocol = PinProtocolV1(self.ctap)
self.ctap2 = CTAP2(device)
self.pin_protocol = PinProtocolV1(self.ctap2)
self._do_make_credential = self._ctap2_make_credential
self._do_get_assertion = self._ctap2_get_assertion
except ValueError:
self.ctap = CTAP1(device)
self.ctap1 = CTAP1(device)
self._do_make_credential = self._ctap1_make_credential
self._do_get_assertion = self._ctap1_get_assertion
@ -283,6 +283,7 @@ class Fido2Client(object):
def make_credential(self, rp, user, challenge, algos=[ES256.ALGORITHM],
exclude_list=None, extensions=None, rk=False, uv=False,
pin=None, timeout=None, on_keepalive=None):
self._verify_rp_id(rp['id'])
client_data = ClientData.build(
@ -304,7 +305,7 @@ class Fido2Client(object):
extensions, rk, uv, pin, timeout, on_keepalive):
key_params = [{'type': 'public-key', 'alg': alg} for alg in algos]
info = self.ctap.get_info()
info = self.ctap2.get_info()
pin_auth = None
pin_protocol = None
if pin:
@ -326,10 +327,10 @@ class Fido2Client(object):
if uv:
options['uv'] = True
return self.ctap.make_credential(client_data.hash, rp, user,
key_params, exclude_list,
extensions, options, pin_auth,
pin_protocol, timeout, on_keepalive)
return self.ctap2.make_credential(client_data.hash, rp, user,
key_params, exclude_list,
extensions, options, pin_auth,
pin_protocol, timeout, on_keepalive)
def _ctap1_make_credential(self, client_data, rp, user, algos, exclude_list,
extensions, rk, uv, pin, timeout, on_keepalive):
@ -338,27 +339,29 @@ class Fido2Client(object):
app_param = sha256(rp['id'].encode())
dummy_param = b'\0'*32
dummy_param = b'\0' * 32
for cred in exclude_list or []:
key_handle = cred['id']
try:
self.ctap.authenticate(dummy_param, app_param, key_handle, True)
self.ctap1.authenticate(
dummy_param, app_param, key_handle, True)
raise ClientError.ERR.OTHER_ERROR() # Shouldn't happen
except ApduError as e:
if e.code == APDU.USE_NOT_SATISFIED:
_call_polling(self.ctap1_poll_delay, timeout, on_keepalive,
self.ctap.register, dummy_param, dummy_param)
self.ctap1.register, dummy_param, dummy_param)
raise ClientError.ERR.DEVICE_INELIGIBLE()
return AttestationObject.from_ctap1(
app_param,
_call_polling(self.ctap1_poll_delay, timeout, on_keepalive,
self.ctap.register, client_data.hash, app_param)
self.ctap1.register, client_data.hash, app_param)
)
def get_assertion(self, rp_id, challenge, allow_list=None, extensions=None,
up=True, uv=False, pin=None, timeout=None,
on_keepalive=None):
self._verify_rp_id(rp_id)
client_data = ClientData.build(
@ -399,12 +402,12 @@ class Fido2Client(object):
if len(options) == 0:
options = None
assertions = [self.ctap.get_assertion(
assertions = [self.ctap2.get_assertion(
rp_id, client_data.hash, allow_list, extensions, options, pin_auth,
pin_protocol, timeout, on_keepalive
)]
for _ in range((assertions[0].number_of_credentials or 1) - 1):
assertions.append(self.ctap.get_next_assertion())
assertions.append(self.ctap2.get_next_assertion())
return assertions
def _ctap1_get_assertion(self, client_data, rp_id, allow_list, extensions,
@ -418,7 +421,7 @@ class Fido2Client(object):
try:
auth_resp = _call_polling(
self.ctap1_poll_delay, timeout, on_keepalive,
self.ctap.authenticate, client_param, app_param, cred['id']
self.ctap1.authenticate, client_param, app_param, cred['id']
)
return [
AssertionResponse.from_ctap1(app_param, cred, auth_resp)

View File

@ -34,27 +34,51 @@ from cryptography.hazmat.primitives.asymmetric import ec, rsa, padding
class CoseKey(dict):
"""A COSE formatted public key.
:param _: The COSE key paramters.
:cvar ALGORITHM: COSE algorithm identifier.
"""
ALGORITHM = None
def verify(self, message, signature):
"""Validates a digital signature over a given message.
:param message: The message which was signed.
:param signature: The signature to check.
"""
raise NotImplementedError('Signature verification not supported.')
@classmethod
def from_cryptography_key(cls, public_key):
"""Converts a PublicKey object from Cryptography into a COSE key.
:param public_key: Either an EC or RSA public key.
:return: A CoseKey.
"""
raise NotImplementedError('Creation from cryptography not supported.')
@staticmethod
def for_alg(alg):
"""Get a subclass of CoseKey corresponding to an algorithm identifier.
:param alg: The COSE identifier of the algorithm.
:return: A CoseKey.
"""
for cls in CoseKey.__subclasses__():
if getattr(cls, 'ALGORITHM', None) == alg:
if cls.ALGORITHM == alg:
return cls
return UnsupportedKey
@staticmethod
def parse(cose):
"""Create a CoseKey from a dict"""
return CoseKey.for_alg(cose[3])(cose)
class UnsupportedKey(CoseKey):
pass
"""A COSE key with an unsupported algorithm."""
class ES256(CoseKey):
@ -80,6 +104,11 @@ class ES256(CoseKey):
@classmethod
def from_ctap1(cls, data):
"""Creates an ES256 key from a CTAP1 formatted public key byte string.
:param data: A 65 byte SECP256R1 public key.
:return: A ES256 key.
"""
return cls({
1: 2,
3: cls.ALGORITHM,

View File

@ -39,22 +39,22 @@ class CtapDevice(abc.ABC):
@abc.abstractmethod
def call(self, cmd, data=b'', event=None, on_keepalive=None):
"""
cmd is the integer value of the command.
data is the binary string value of the payload.
event is an instance of threading.Event which can be used to cancel the
invocation.
on_keepalive is an optional callback function that is invoked on
keepalive message from the authenticator, with the keepalive status code
as an argument. The callback is only invoked once for consecutive
keepalive messages with the same status.
"""Sends a command to the authenticator, and reads the response.
:param cmd: The integer value of the command.
:param data: The payload of the command.
:param event: An optional threading.Event which can be used to cancel
the invocation.
:param on_keepalive: An optional callback to handle keep-alive messages
from the authenticator. The function is only called once for
consecutive keep-alive messages with the same status.
:return: The response from the authenticator.
"""
@abc.abstractclassmethod
@classmethod
@abc.abstractmethod
def list_devices(cls):
"""
Generates instances of cls for discoverable devices.
"""
"""Generates instances of cls for discoverable devices."""
class CtapError(Exception):

View File

@ -39,12 +39,20 @@ import six
@unique
class APDU(IntEnum):
"""APDU response codes."""
OK = 0x9000
USE_NOT_SATISFIED = 0x6985
WRONG_DATA = 0x6a80
class ApduError(Exception):
"""An Exception thrown when a response APDU doesn't have an OK (0x9000)
status.
:param code: APDU response code.
:param data: APDU response body.
"""
def __init__(self, code, data=b''):
self.code = code
self.data = data
@ -55,7 +63,18 @@ class ApduError(Exception):
class RegistrationData(bytes):
"""Binary response data for a CTAP1 registration.
:param _: The binary contents of the response data.
:ivar public_key: Binary representation of the credential public key.
:ivar key_handle: Binary key handle of the credential.
:ivar certificate: Attestation certificate of the authenticator, DER
encoded.
:ivar signature: Attestation signature.
"""
def __init__(self, _):
super(RegistrationData, self).__init__()
if six.indexbytes(self, 0) != 0x05:
raise ValueError('Reserved byte != 0x05')
@ -75,9 +94,16 @@ class RegistrationData(bytes):
@property
def b64(self):
"""Websafe base64 encoded string of the RegistrationData."""
return websafe_encode(self)
def verify(self, app_param, client_param):
"""Verify the included signature with regard to the given app and client
params.
:param app_param: SHA256 hash of the app ID used for the request.
:param client_param: SHA256 hash of the ClientData used for the request.
"""
FidoU2FAttestation.verify_signature(
app_param, client_param, self.key_handle, self.public_key,
self.certificate, self.signature)
@ -98,19 +124,42 @@ class RegistrationData(bytes):
@classmethod
def from_b64(cls, data):
"""Parse a RegistrationData from a websafe base64 encoded string.
:param data: Websafe base64 encoded string.
:return: The decoded and parsed RegistrationData.
"""
return cls(websafe_decode(data))
class SignatureData(bytes):
"""Binary response data for a CTAP1 authentication.
:param _: The binary contents of the response data.
:ivar user_presence: User presence byte.
:ivar counter: Signature counter.
:ivar signature: Cryptographic signature.
"""
def __init__(self, _):
self.user_presence, self.counter = struct.unpack('>BI', self[:5])
super(SignatureData, self).__init__()
self.user_presence = six.indexbytes(self, 0)
self.counter = struct.unpack('>I', self[1:5])[0]
self.signature = self[5:]
@property
def b64(self):
"""str: Websafe base64 encoded string of the SignatureData."""
return websafe_encode(self)
def verify(self, app_param, client_param, public_key):
"""Verify the included signature with regard to the given app and client
params, using the given public key.
:param app_param: SHA256 hash of the app ID used for the request.
:param client_param: SHA256 hash of the ClientData used for the request.
:param public_key: Binary representation of the credential public key.
"""
m = app_param + self[:5] + client_param
ES256.from_ctap1(public_key).verify(m, self.signature)
@ -124,10 +173,19 @@ class SignatureData(bytes):
@classmethod
def from_b64(cls, data):
"""Parse a SignatureData from a websafe base64 encoded string.
:param data: Websafe base64 encoded string.
:return: The decoded and parsed SignatureData.
"""
return cls(websafe_decode(data))
class CTAP1(object):
"""Implementation of the CTAP1 specification.
:param device: A CtapHidDevice handle supporting CTAP1.
"""
@unique
class INS(IntEnum):
REGISTER = 0x01
@ -135,9 +193,23 @@ class CTAP1(object):
VERSION = 0x03
def __init__(self, device):
self.device = device
def send_apdu(self, cla=0, ins=0, p1=0, p2=0, data=b''):
"""Packs and sends an APDU for use in CTAP1 commands.
This is a low-level method mainly used internally. Avoid calling it
directly if possible, and use the get_version, register, and
authenticate methods if possible instead.
:param cla: The CLA parameter of the request.
:param ins: The INS parameter of the request.
:param p1: The P1 parameter of the request.
:param p2: The P2 parameter of the request.
:param data: The body of the request.
:return: The response APDU data of a successful request.
:raise: ApduError
"""
size = len(data)
size_h = size >> 16 & 0xff
size_l = size & 0xffff
@ -152,15 +224,35 @@ class CTAP1(object):
return data
def get_version(self):
"""Get the U2F version implemented by the authenticator.
The only version specified is "U2F_V2".
:return: A U2F version string.
"""
return self.send_apdu(ins=CTAP1.INS.VERSION).decode()
def register(self, client_param, app_param):
"""Register a new U2F credential.
:param client_param: SHA256 hash of the ClientData used for the request.
:param app_param: SHA256 hash of the app ID used for the request.
:return: The registration response from the authenticator.
"""
data = client_param + app_param
response = self.send_apdu(ins=CTAP1.INS.REGISTER, data=data)
return RegistrationData(response)
def authenticate(self, client_param, app_param, key_handle,
check_only=False):
"""Authenticate a previously registered credential.
:param client_param: SHA256 hash of the ClientData used for the request.
:param app_param: SHA256 hash of the app ID used for the request.
:param key_handle: The binary key handle of the credential.
:param check_only: True to send a "check-only" request, which is used to
determine if a key handle is known.
:return: The authentication response from the authenticator.
"""
data = client_param + app_param \
+ struct.pack('>B', len(key_handle)) + key_handle
p1 = 0x07 if check_only else 0x03

View File

@ -45,16 +45,22 @@ import six
import re
def args(*args):
def args(*params):
"""Constructs a dict from a list of arguments for sending a CBOR command.
None elements will be omitted.
:param params: Arguments, in order, to add to the command.
:return: The input parameters as a dict.
"""
Constructs a dict from a list of arguments for sending a CBOR command.
"""
if args:
return dict((i, v) for i, v in enumerate(args, 1) if v is not None)
return None
return dict((i, v) for i, v in enumerate(params, 1) if v is not None)
def hexstr(bs):
"""Formats a byte string as a human readable hex string.
:param bs: The bytes to format.
:return: A readable string representation of the input.
"""
return "h'%s'" % b2a_hex(bs).decode()
@ -66,6 +72,19 @@ def _parse_cbor(data):
class Info(bytes):
"""Binary CBOR encoded response data returned by the CTAP2 GET_INFO command.
:param _: The binary content of the Info data.
:ivar versions: The versions supported by the authenticator.
:ivar extensions: The extensions supported by the authenticator.
:ivar aaguid: The AAGUID of the authenticator.
:ivar options: The options supported by the authenticator.
:ivar max_msg_size: The max message size supported by the authenticator.
:ivar pin_protocols: The PIN protocol versions supported by the
authenticator.
:ivar data: The Info members, in the form of a dict.
"""
@unique
class KEY(IntEnum):
VERSIONS = 1
@ -75,14 +94,17 @@ class Info(bytes):
MAX_MSG_SIZE = 5
PIN_PROTOCOLS = 6
def __init__(self, data):
data = dict((Info.KEY(k), v) for (k, v) in _parse_cbor(data).items())
def __init__(self, _):
super(Info, self).__init__()
data = dict((Info.KEY(k), v) for (k, v) in _parse_cbor(self).items())
self.versions = data[Info.KEY.VERSIONS]
self.extensions = data.get(Info.KEY.EXTENSIONS, [])
self.aaguid = data[Info.KEY.AAGUID]
self.options = data.get(Info.KEY.OPTIONS, {})
self.max_msg_size = data.get(Info.KEY.MAX_MSG_SIZE, 1024)
self.pin_protocols = data.get(Info.KEY.PIN_PROTOCOLS, [])
self.pin_protocols = data.get(
Info.KEY.PIN_PROTOCOLS, [])
self.data = data
def __repr__(self):
@ -102,11 +124,22 @@ class Info(bytes):
class AttestedCredentialData(bytes):
"""Binary encoding of the attested credential data.
:param _: The binary representation of the attested credential data.
:ivar aaguid: The AAGUID of the authenticator.
:ivar credential_id: The binary ID of the credential.
:ivar public_key: The public key of the credential.
"""
def __init__(self, _):
self.aaguid, self.credential_id, self.public_key, rest = \
AttestedCredentialData.parse(self)
if rest:
super(AttestedCredentialData, self).__init__()
parsed = AttestedCredentialData.parse(self)
self.aaguid = parsed[0]
self.credential_id = parsed[1]
self.public_key = parsed[2]
if parsed[3]:
raise ValueError('Wrong length')
def __repr__(self):
@ -120,6 +153,12 @@ class AttestedCredentialData(bytes):
@staticmethod
def parse(data):
"""Parse the components of an AttestedCredentialData from a binary
string, and return them.
:param data: A binary string containing an attested credential data.
:return: AAGUID, credential ID, public key, and remaining data.
"""
aaguid = data[:16]
c_len = struct.unpack('>H', data[16:18])[0]
cred_id = data[18:18+c_len]
@ -128,16 +167,41 @@ class AttestedCredentialData(bytes):
@classmethod
def create(cls, aaguid, credential_id, public_key):
"""Create an AttestedCredentialData by providing its components.
:param aaguid: The AAGUID of the authenticator.
:param credential_id: The binary ID of the credential.
:param public_key: A COSE formatted public key.
:return: The attested credential data.
"""
return cls(aaguid + struct.pack('>H', len(credential_id))
+ credential_id + cbor.dumps(public_key))
@classmethod
def unpack_from(cls, data):
args = cls.parse(data)
return cls.create(*args[:-1]), args[-1]
"""Unpack an AttestedCredentialData from a byte string, returning it and
any remaining data.
:param data: A binary string containing an attested credential data.
:return: The parsed AttestedCredentialData, and any remaining data from
the input.
"""
parts = cls.parse(data)
return cls.create(*parts[:-1]), parts[-1]
class AuthenticatorData(bytes):
"""Binary encoding of the authenticator data.
:param _: The binary representation of the authenticator data.
:ivar rp_id_hash: SHA256 hash of the RP ID.
:ivar flags: The flags of the authenticator data, see
AuthenticatorData.FLAG.
:ivar counter: The signature counter of the authenticator.
:ivar credential_data: Attested credential data, if available.
:ivar extensions: Authenticator extensions, if available.
"""
@unique
class FLAG(IntEnum):
UP = 0x01
@ -145,9 +209,12 @@ class AuthenticatorData(bytes):
AT = 0x40
ED = 0x80
def __init__(self, data):
def __init__(self, _):
super(AuthenticatorData, self).__init__()
self.rp_id_hash = self[:32]
self.flags, self.counter = struct.unpack('>BI', self[32:32+5])
self.flags = six.indexbytes(self, 32)
self.counter = struct.unpack('>I', self[33:33+4])[0]
rest = self[37:]
if self.flags & AuthenticatorData.FLAG.AT:
@ -167,6 +234,16 @@ class AuthenticatorData(bytes):
@classmethod
def create(cls, rp_id_hash, flags, counter, credential_data=b'',
extensions=None):
"""Create an AuthenticatorData instance.
:param rp_id_hash: SHA256 hash of the RP ID.
:param flags: Flags of the AuthenticatorData.
:param counter: Signature counter of the authenticator data.
:param credential_data: Authenticated credential data (only if AT flag
is set).
:param extensions: Authenticator extensions (only if ED flag is set).
:return: The authenticator data.
"""
return cls(
rp_id_hash + struct.pack('>BI', flags, counter) + credential_data +
(cbor.dumps(extensions) if extensions is not None else b'')
@ -186,6 +263,20 @@ class AuthenticatorData(bytes):
class AttestationObject(bytes):
"""Binary CBOR encoded attestation object.
:param _: The binary representation of the attestation object.
:type _: bytes
:ivar fmt: The type of attestation used.
:type fmt: str
:ivar auth_data: The attested authenticator data.
:type auth_data: AuthenticatorData
:ivar att_statement: The attestation statement.
:type att_statement: Dict[str, Any]
:ivar data: The AttestationObject members, in the form of a dict.
:type data: Dict[AttestationObject.KEY, Any]
"""
@unique
class KEY(IntEnum):
FMT = 1
@ -194,25 +285,40 @@ class AttestationObject(bytes):
@classmethod
def for_key(cls, key):
try:
"""Get an AttestationObject.KEY by number or by name, using the
numeric ID or the Webauthn key string.
:param key: The numeric key value, or the string name of a member.
:type key: Union[str, int]
:return: The KEY corresponding to the input.
:rtype: AttestationObject.KEY
"""
if isinstance(key, int):
return cls(key)
except ValueError:
name = re.sub('([a-z])([A-Z])', r'\1_\2', key).upper()
return getattr(cls, name)
name = re.sub('([a-z])([A-Z])', r'\1_\2', key).upper()
return getattr(cls, name)
@property
def string_key(self):
"""Get the string used for this key in the Webauthn specification.
:return: The Webauthn string used for a key.
:rtype: str
"""
value = ''.join(w.capitalize() for w in self.name.split('_'))
return value[0].lower() + value[1:]
def __init__(self, data):
def __init__(self, _):
super(AttestationObject, self).__init__()
data = dict((AttestationObject.KEY.for_key(k), v) for (k, v) in
_parse_cbor(data).items())
_parse_cbor(self).items())
self.fmt = data[AttestationObject.KEY.FMT]
self.auth_data = AuthenticatorData(
data[AttestationObject.KEY.AUTH_DATA])
self.auth_data = AuthenticatorData(data[AttestationObject.KEY.AUTH_DATA]
)
data[AttestationObject.KEY.AUTH_DATA] = self.auth_data
self.att_statement = data[AttestationObject.KEY.ATT_STMT]
self.att_statement = data[
AttestationObject.KEY.ATT_STMT]
self.data = data
def __repr__(self):
@ -223,6 +329,12 @@ class AttestationObject(bytes):
return self.__repr__()
def verify(self, client_param):
"""Verify the digital signature of an AttestationObject, with regard to
the given client_param.
:param client_param: SHA256 hash of the ClientData for the request.
:type client_param: bytes
"""
attestation = Attestation.for_type(self.fmt)
if attestation:
attestation().verify(self.att_statement, self.auth_data,
@ -232,10 +344,30 @@ class AttestationObject(bytes):
@classmethod
def create(cls, fmt, auth_data, att_stmt):
"""Create an AttestationObject instance.
:param fmt: The type of attestation used.
:type fmt: str
:param auth_data: Binary representation of the authenticator data.
:type auth_data: bytes
:param att_stmt: The attestation statement.
:type att_stmt: dict
:return: The attestation object.
:rtype: AttestationObject
"""
return cls(cbor.dumps(args(fmt, auth_data, att_stmt)))
@classmethod
def from_ctap1(cls, app_param, registration):
"""Create an AttestationObject from a CTAP1 RegistrationData instance.
:param app_param: SHA256 hash of the RP ID used for the CTAP1 request.
:type app_param: bytes
:param registration: The CTAP1 registration data.
:type registration: RegistrationData
:return: The attestation object, using the "fido-u2f" format.
:rtype: AttestationObject
"""
return cls.create(
FidoU2FAttestation.FORMAT,
AuthenticatorData.create(
@ -261,14 +393,37 @@ class AttestationObject(bytes):
)
def with_int_keys(self):
"""Get a copy of this AttestationObject, using CTAP2 integer values as
map keys in the CBOR representation.
:return: The attestation object, using int keys.
:rtype: AttestationObject
"""
return AttestationObject(cbor.dumps(self.data))
def with_string_keys(self):
"""Get a copy of this AttestationObject, using Webauthn string values as
map keys in the CBOR representation.
:return: The attestation object, using str keys.
:rtype: AttestationObject
"""
return AttestationObject(cbor.dumps(
dict((k.string_key, v) for k, v in self.data.items())))
class AssertionResponse(bytes):
"""Binary CBOR encoded assertion response.
:param _: The binary representation of the assertion response.
:ivar credential: The credential used for the assertion.
:ivar auth_data: The authenticator data part of the response.
:ivar signature: The digital signature of the assertion.
:ivar user: The user data of the credential.
:ivar number_of_credentials: The total number of responses available
(only set for the first response, if > 1).
"""
@unique
class KEY(IntEnum):
CREDENTIAL = 1
@ -277,15 +432,20 @@ class AssertionResponse(bytes):
USER = 4
N_CREDS = 5
def __init__(self, data):
def __init__(self, _):
super(AssertionResponse, self).__init__()
data = dict((AssertionResponse.KEY(k), v) for (k, v) in
_parse_cbor(data).items())
self.credential = data[AssertionResponse.KEY.CREDENTIAL]
_parse_cbor(self).items())
self.credential = data.get(
AssertionResponse.KEY.CREDENTIAL)
self.auth_data = AuthenticatorData(
data[AssertionResponse.KEY.AUTH_DATA])
self.signature = data[AssertionResponse.KEY.SIGNATURE]
self.user = data.get(AssertionResponse.KEY.USER)
self.number_of_credentials = data.get(AssertionResponse.KEY.N_CREDS)
self.user = data.get(
AssertionResponse.KEY.USER)
self.number_of_credentials = data.get(
AssertionResponse.KEY.N_CREDS)
self.data = data
def __repr__(self):
@ -301,15 +461,38 @@ class AssertionResponse(bytes):
return self.__repr__()
def verify(self, client_param, public_key):
"""Verify the digital signature of the response with regard to the
client_param, using the given public key.
:param client_param: SHA256 hash of the ClientData used for the request.
:param public_key: The public key of the credential, to verify.
"""
public_key.verify(self.auth_data + client_param, self.signature)
@classmethod
def create(cls, credential, auth_data, signature, user=None, n_creds=None):
"""Create an AssertionResponse instance.
:param credential: The credential used for the response.
:param auth_data: The binary encoded authenticator data.
:param signature: The digital signature of the response.
:param user: The user data of the credential, if any.
:param n_creds: The number of responses available.
:return: The assertion response.
"""
return cls(cbor.dumps(args(credential, auth_data, signature, user,
n_creds)))
@classmethod
def from_ctap1(cls, app_param, credential, authentication):
"""Create an AssertionResponse from a CTAP1 SignatureData instance.
:param app_param: SHA256 hash of the RP ID used for the CTAP1 request.
:param credential: Credential used for the CTAP1 request (from the
allowList).
:param authentication: The CTAP1 signature data.
:return: The assertion response.
"""
return cls.create(
credential,
AuthenticatorData.create(
@ -322,6 +505,11 @@ class AssertionResponse(bytes):
class CTAP2(object):
"""Implementation of the CTAP2 specification.
:param device: A CtapHidDevice handle supporting CTAP2.
"""
@unique
class CMD(IntEnum):
MAKE_CREDENTIAL = 0x01
@ -338,10 +526,22 @@ class CTAP2(object):
def send_cbor(self, cmd, data=None, timeout=None, parse=_parse_cbor,
on_keepalive=None):
"""
Sends a CBOR message to the device, and waits for a response.
"""Sends a CBOR message to the device, and waits for a response.
The optional parameter 'timeout' can either be a numeric time in seconds
or a threading.Event object used to cancel the request.
:param cmd: The command byte of the request.
:param data: The payload to send (to be CBOR encoded).
:param timeout: Optional timeout in seconds, or an instance of
threading.Event used to cancel the command.
:param parse: Function used to parse the binary response data, defaults
to parsing the CBOR.
:param on_keepalive: Optional function called when keep-alive is sent by
the authenticator.
:return: The result of calling the parse function on the response data
(defaults to the CBOR decoded value).
"""
request = struct.pack('>B', cmd)
if data is not None:
@ -360,6 +560,23 @@ class CTAP2(object):
exclude_list=None, extensions=None, options=None,
pin_auth=None, pin_protocol=None, timeout=None,
on_keepalive=None):
"""CTAP2 makeCredential operation,
:param client_data_hash: SHA256 hash of the ClientData.
:param rp: PublicKeyCredentialRpEntity parameters.
:param user: PublicKeyCredentialUserEntity parameters.
:param key_params: List of acceptable credential types.
:param exclude_list: Optional list of PublicKeyCredentialDescriptors.
:param extensions: Optional dict of extensions.
:param options: Optional dict of options.
:param pin_auth: Optional PIN auth parameter.
:param pin_protocol: The version of PIN protocol used, if any.
:param timeout: Optional timeout in seconds, or threading.Event object
used to cancel the request.
:param on_keepalive: Optional callback function to handle keep-alive
messages from the authenticator.
:return: The new credential.
"""
return self.send_cbor(CTAP2.CMD.MAKE_CREDENTIAL, args(
client_data_hash,
rp,
@ -375,6 +592,21 @@ class CTAP2(object):
def get_assertion(self, rp_id, client_data_hash, allow_list=None,
extensions=None, options=None, pin_auth=None,
pin_protocol=None, timeout=None, on_keepalive=None):
"""CTAP2 getAssertion command.
:param rp_id: SHA256 hash of the RP ID of the credential.
:param client_data_hash: SHA256 hash of the ClientData used.
:param allow_list: Optional list of PublicKeyCredentialDescriptors.
:param extensions: Optional dict of extensions.
:param options: Optional dict of options.
:param pin_auth: Optional PIN auth parameter.
:param pin_protocol: The version of PIN protocol used, if any.
:param timeout: Optional timeout in seconds, or threading.Event object
used to cancel the request.
:param on_keepalive: Optional callback function to handle keep-alive
messages from the authenticator.
:return: The new assertion.
"""
return self.send_cbor(CTAP2.CMD.GET_ASSERTION, args(
rp_id,
client_data_hash,
@ -386,10 +618,24 @@ class CTAP2(object):
), timeout, AssertionResponse, on_keepalive)
def get_info(self):
"""CTAP2 getInfo command.
:return: Information about the authenticator.
"""
return self.send_cbor(CTAP2.CMD.GET_INFO, parse=Info)
def client_pin(self, pin_protocol, sub_cmd, key_agreement=None,
pin_auth=None, new_pin_enc=None, pin_hash_enc=None):
"""CTAP2 clientPin command, used for various PIN operations.
:param pin_protocol: The PIN protocol version to use.
:param sub_cmd: A clientPin sub command.
:param key_agreement: The keyAgreement parameter.
:param pin_auth: The pinAuth parameter.
:param new_pin_enc: The newPinEnc parameter.
:param pin_hash_enc: The pinHashEnc parameter.
:return: The response of the command, decoded.
"""
return self.send_cbor(CTAP2.CMD.CLIENT_PIN, args(
pin_protocol,
sub_cmd,
@ -400,10 +646,21 @@ class CTAP2(object):
))
def reset(self, timeout=None, on_keepalive=None):
"""CTAP2 reset command, erases all credentials and PIN.
:param timeout: Optional timeout in seconds, or threading.Event object
used to cancel the request.
:param on_keepalive: Optional callback function to handle keep-alive
messages from the authenticator.
"""
self.send_cbor(CTAP2.CMD.RESET, timeout=timeout,
on_keepalive=on_keepalive)
def get_next_assertion(self):
"""CTAP2 getNextAssertion command.
:return: The next available assertion response.
"""
return self.send_cbor(CTAP2.CMD.GET_NEXT_ASSERTION,
parse=AssertionResponse)
@ -421,6 +678,12 @@ def _pad_pin(pin):
class PinProtocolV1(object):
"""Implementation of the CTAP1 PIN protocol v1.
:param ctap: An instance of a CTAP2 object.
:cvar VERSION: The version number of the PIV protocol.
:cvar IV: An all-zero IV used for some cryptographic operations.
"""
VERSION = 1
IV = b'\x00' * 16
@ -462,6 +725,11 @@ class PinProtocolV1(object):
return key_agreement, shared_secret
def get_pin_token(self, pin):
"""Get a PIN token from the authenticator.
:param pin: The PIN of the authenticator.
:return: A PIN token.
"""
key_agreement, shared_secret = self._init_shared_secret()
be = default_backend()
@ -479,11 +747,21 @@ class PinProtocolV1(object):
return dec.update(resp[PinProtocolV1.RESULT.PIN_TOKEN]) + dec.finalize()
def get_pin_retries(self):
"""Get the number of PIN retries remaining.
:return: The number or PIN attempts until the authenticator is locked.
"""
resp = self.ctap.client_pin(PinProtocolV1.VERSION,
PinProtocolV1.CMD.GET_RETRIES)
return resp[PinProtocolV1.RESULT.RETRIES]
def set_pin(self, pin):
"""Set the PIN of the autenticator.
This only works when no PIN is set. To change the PIN when set, use
change_pin.
:param pin: A PIN to set.
"""
pin = _pad_pin(pin)
key_agreement, shared_secret = self._init_shared_secret()
@ -499,6 +777,13 @@ class PinProtocolV1(object):
pin_auth=pin_auth)
def change_pin(self, old_pin, new_pin):
"""Change the PIN of the authenticator.
This only works when a PIN is already set. If no PIN is set, use
set_pin.
:param old_pin: The currently set PIN.
:param new_pin: The new PIN to set.
"""
new_pin = _pad_pin(new_pin)
key_agreement, shared_secret = self._init_shared_secret()

View File

@ -2,7 +2,7 @@
from __future__ import absolute_import
from .ctap import CtapDevice, CtapError
from .pyu2f import hidtransport
from ._pyu2f import hidtransport
from enum import IntEnum, unique
from threading import Event
@ -59,6 +59,8 @@ class _SingleEvent(object):
class CtapHidDevice(CtapDevice):
"""
CtapDevice implementation using the HID transport.
:cvar descriptor: Device descriptor.
"""
def __init__(self, descriptor, dev):
@ -70,14 +72,20 @@ class CtapHidDevice(CtapDevice):
@property
def version(self):
"""CTAP HID protocol version.
:rtype: Tuple[int, int, int]
"""
return self._dev.u2fhid_version
@property
def device_version(self):
"""Device version number."""
return self._dev.device_version
@property
def capabilities(self):
"""Capabilities supported by the device."""
return self._dev.capabilities
def call(self, cmd, data=b'', event=None, on_keepalive=None):
@ -108,12 +116,19 @@ class CtapHidDevice(CtapDevice):
raise CtapError(CtapError.ERR.KEEPALIVE_CANCEL)
def wink(self):
"""Causes the authenticator to blink."""
self.call(CTAPHID.WINK)
def ping(self, msg=b'Hello FIDO'):
"""Sends data to the authenticator, which echoes it back.
:param msg: The data to send.
:return: The response from the authenticator.
"""
return self.call(CTAPHID.PING, msg)
def lock(self, lock_time=10):
"""Locks the channel."""
self.call(CTAPHID.LOCK, struct.pack('>B', lock_time))
@classmethod

View File

@ -50,6 +50,12 @@ with open(tld_fname, 'rb') as f:
def verify_rp_id(rp_id, origin):
"""Checks if a Webauthn RP ID is usable for a given origin.
:param rp_id: The RP ID to validate.
:param origin: The origin of the request.
:return: True if the RP ID is usable by the origin, False if not.
"""
if isinstance(rp_id, six.binary_type):
rp_id = rp_id.decode()
if not rp_id:
@ -69,6 +75,12 @@ def verify_rp_id(rp_id, origin):
def verify_app_id(app_id, origin):
"""Checks if a FIDO U2F App ID is usable for a given origin.
:param app_id: The App ID to validate.
:param origin: The origin of the request.
:return: True if the App ID is usable by the origin, False if not.
"""
if isinstance(app_id, six.binary_type):
app_id = app_id.decode()
url = urlparse(app_id)

120
fido2/server.py Normal file
View File

@ -0,0 +1,120 @@
# Copyright (c) 2018 Yubico AB
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following
# disclaimer in the documentation and/or other materials provided
# with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
from __future__ import absolute_import, unicode_literals
from .rpid import verify_rp_id
from .cose import ES256
from .utils import sha256
import os
from cryptography.hazmat.primitives import constant_time
def _verify_origin_for_rp(rp_id):
return lambda o: verify_rp_id(rp_id, o)
class Fido2Server(object):
def __init__(self, rp_id, attestation=None, verify_origin=None):
self.rp_id = rp_id
self._verify = verify_origin or _verify_origin_for_rp(rp_id)
self.timeout = 30
self.attestation = attestation or 'none'
self.cred_algorithms = [ES256.ALGORITHM]
def register_begin(self, rp, user, credentials=None):
challenge = os.urandom(32)
return {
'publicKey': {
'rp': rp,
'user': user,
'challenge': challenge,
'pubKeyCredParams': [
{
'type': 'public-key',
'alg': alg
} for alg in self.cred_algorithms
],
'excludeCredentials': [
{
'type': 'public-key',
'id': cred.credential_id
} for cred in credentials or []
],
'timeout': int(self.timeout * 1000),
'attestation': self.attestation
}
}
def register_complete(self, challenge, client_data, attestation_object):
if client_data.get('type') != 'webauthn.create':
raise ValueError('Incorrect type in ClientData.')
if not self._verify(client_data.get('origin')):
raise ValueError('Invalid origin in ClientData.')
if not constant_time.bytes_eq(challenge, client_data.challenge):
raise ValueError('Wrong challenge in response.')
if not constant_time.bytes_eq(sha256(self.rp_id.encode()),
attestation_object.auth_data.rp_id_hash):
raise ValueError('Wrong RP ID hash in response.')
# TODO: Ensure that we're using an acceptable attestation format.
attestation_object.verify(client_data.hash)
return attestation_object.auth_data
def authenticate_begin(self, rp_id, credentials):
challenge = os.urandom(32)
return {
'publicKey': {
'rpId': rp_id,
'challenge': challenge,
'allowCredentials': [
{
'type': 'public-key',
'id': cred.credential_id
} for cred in credentials
],
'timeout': int(self.timeout * 1000)
}
}
def authenticate_complete(self, credentials, credential_id, challenge,
client_data, auth_data, signature):
if client_data.get('type') != 'webauthn.get':
raise ValueError('Incorrect type in ClientData.')
if not self._verify(client_data.get('origin')):
raise ValueError('Invalid origin in ClientData.')
if challenge != client_data.challenge:
raise ValueError('Wrong challenge in response.')
if not constant_time.bytes_eq(sha256(self.rp_id.encode()),
auth_data.rp_id_hash):
raise ValueError('Wrong RP ID hash in response.')
for cred in credentials:
if cred.credential_id == credential_id:
cred.public_key.verify(auth_data + client_data.hash, signature)
return cred
raise ValueError('Unknown credential ID.')

View File

@ -25,13 +25,18 @@
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
"""Various utility functions.
This module contains various functions used throughout the rest of the project.
"""
from base64 import urlsafe_b64decode, urlsafe_b64encode
from threading import Timer, Event
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hmac, hashes
from threading import Timer, Event
from binascii import b2a_hex
from numbers import Number
import six
import numbers
__all__ = [
'Timeout',
@ -45,32 +50,61 @@ __all__ = [
def sha256(data):
"""Produces a SHA256 hash of the input.
:param data: The input data to hash.
:return: The resulting hash.
"""
h = hashes.Hash(hashes.SHA256(), default_backend())
h.update(data)
return h.finalize()
def hmac_sha256(key, data):
"""Performs an HMAC-SHA256 operation on the given data, using the given key.
:param key: The key to use.
:param data: The input data to hash.
:return: The resulting hash.
"""
h = hmac.HMAC(key, hashes.SHA256(), default_backend())
h.update(data)
return h.finalize()
def bytes2int(value):
"""Parses an arbitrarily sized integer from a byte string.
:param value: A byte string encoding a big endian unsigned integer.
:return: The parsed int.
"""
return int(b2a_hex(value), 16)
def int2bytes(value, minlen=-1):
"""Encodes an int as a byte string.
:param value: The integer value to encode.
:param minlen: An optional minimum length for the resulting byte string.
:return: The value encoded as a big endian byte string.
"""
ba = []
while value > 0xff:
ba.append(0xff & value)
value >>= 8
ba.append(value)
ba.extend([0]*(minlen - len(ba)))
ba.extend([0] * (minlen - len(ba)))
return bytes(bytearray(reversed(ba)))
def websafe_decode(data):
"""Decodes a websafe-base64 encoded string (bytes or str).
See: "Base 64 Encoding with URL and Filename Safe Alphabet" from Section 5
in RFC4648 without padding.
:param data: The input to decode.
:return: The decoded bytes.
"""
if isinstance(data, six.text_type):
data = data.encode('ascii')
data += b'=' * (-len(data) % 4)
@ -78,16 +112,28 @@ def websafe_decode(data):
def websafe_encode(data):
if isinstance(data, six.text_type):
data = data.encode('ascii')
"""Encodes a byte string into websafe-base64 encoding.
:param data: The input to encode.
:return: The encoded string.
"""
return urlsafe_b64encode(data).replace(b'=', b'').decode('ascii')
class Timeout(object):
"""Utility class for adding a timeout to an event.
:param time_or_event: A number, in seconds, or a threading.Event object.
:ivar event: The Event associated with the Timeout.
:ivar timer: The Timer associated with the Timeout, if any.
"""
def __init__(self, time_or_event):
if isinstance(time_or_event, numbers.Number):
if isinstance(time_or_event, Number):
self.event = Event()
self.timer = Timer(time_or_event, self.event.set)
self.timer = Timer(time_or_event,
self.event.set)
else:
self.event = time_or_event
self.timer = None

View File

@ -12,14 +12,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for pyu2f.hidtransport."""
"""Tests for _pyu2f.hidtransport."""
from __future__ import absolute_import
import six
import mock
from fido2.pyu2f import hidtransport
from fido2._pyu2f import hidtransport
from . import util
import unittest

View File

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for pyu2f.hid.linux."""
"""Tests for _pyu2f.hid.linux."""
import base64
import os
@ -22,7 +22,7 @@ import mock
import six
from six.moves import builtins
from fido2.pyu2f import linux
from fido2._pyu2f import linux
try:
from pyfakefs import fake_filesystem # pylint: disable=g-import-not-at-top

View File

@ -12,13 +12,13 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for pyu2f.hid.macos."""
"""Tests for _pyu2f.hid.macos."""
import ctypes
import sys
import mock
from fido2.pyu2f import macos
from fido2._pyu2f import macos
if sys.version_info[:2] < (2, 7):

View File

@ -12,14 +12,14 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Testing utilties for pyu2f.
"""Testing utilties for _pyu2f.
Testing utilities such as a fake implementation of the pyhidapi device object
that implements parts of the U2FHID frame protocol. This makes it easy to tests
of higher level abstractions without having to use mock to mock out low level
framing details.
"""
from fido2.pyu2f import base, hidtransport
from fido2._pyu2f import base, hidtransport
class UnsupportedCommandError(Exception):

View File

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for pyu2f.tests.lib.util."""
"""Tests for _pyu2f.tests.lib.util."""
from __future__ import absolute_import

View File

@ -42,7 +42,7 @@ class TestAttestationObject(unittest.TestCase):
attestation = Attestation.for_type('__unsupported__')()
self.assertIsInstance(attestation, UnsupportedAttestation)
with self.assertRaises(NotImplementedError):
attestation.verify({}, 0, 0)
attestation.verify({}, 0, b'')
def test_none_attestation(self):
attestation = Attestation.for_type('none')()

View File

@ -331,9 +331,9 @@ class TestFido2Client(unittest.TestCase):
dev = mock.Mock()
dev.capabilities = CAPABILITY.CBOR
client = Fido2Client(dev, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_info.return_value = Info(_INFO_NO_PIN)
client.ctap.make_credential.side_effect = CtapError(
client.ctap2 = mock.MagicMock()
client.ctap2.get_info.return_value = Info(_INFO_NO_PIN)
client.ctap2.make_credential.side_effect = CtapError(
CtapError.ERR.CREDENTIAL_EXCLUDED)
try:
@ -347,16 +347,16 @@ class TestFido2Client(unittest.TestCase):
except ClientError as e:
self.assertEqual(e.code, ClientError.ERR.DEVICE_INELIGIBLE)
client.ctap.get_info.assert_called_with()
client.ctap.make_credential.assert_called_once()
client.ctap2.get_info.assert_called_with()
client.ctap2.make_credential.assert_called_once()
def test_make_credential_ctap2(self):
dev = mock.Mock()
dev.capabilities = CAPABILITY.CBOR
client = Fido2Client(dev, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_info.return_value = Info(_INFO_NO_PIN)
client.ctap.make_credential.return_value = AttestationObject(_MC_RESP)
client.ctap2 = mock.MagicMock()
client.ctap2.get_info.return_value = Info(_INFO_NO_PIN)
client.ctap2.make_credential.return_value = AttestationObject(_MC_RESP)
attestation, client_data = client.make_credential(
rp,
@ -368,8 +368,8 @@ class TestFido2Client(unittest.TestCase):
self.assertIsInstance(attestation, AttestationObject)
self.assertIsInstance(client_data, ClientData)
client.ctap.get_info.assert_called_with()
client.ctap.make_credential.assert_called_with(
client.ctap2.get_info.assert_called_with()
client.ctap2.make_credential.assert_called_with(
client_data.hash,
rp,
user,
@ -392,9 +392,9 @@ class TestFido2Client(unittest.TestCase):
dev.capabilities = 0 # No CTAP2
client = Fido2Client(dev, APP_ID)
client.ctap = mock.MagicMock()
client.ctap.get_version.return_value = 'U2F_V2'
client.ctap.register.return_value = REG_DATA
client.ctap1 = mock.MagicMock()
client.ctap1.get_version.return_value = 'U2F_V2'
client.ctap1.register.return_value = REG_DATA
attestation, client_data = client.make_credential(
rp,
@ -406,7 +406,7 @@ class TestFido2Client(unittest.TestCase):
self.assertIsInstance(attestation, AttestationObject)
self.assertIsInstance(client_data, ClientData)
client.ctap.register.assert_called_with(
client.ctap1.register.assert_called_with(
client_data.hash,
sha256(rp['id'].encode()),
)

View File

@ -28,7 +28,6 @@
from __future__ import absolute_import, unicode_literals
from fido2.ctap1 import CTAP1, ApduError
from fido2.client import ClientData
from binascii import a2b_hex
import unittest
import mock
@ -82,7 +81,6 @@ class TestCTAP1(unittest.TestCase):
self.assertEqual(resp.certificate, a2b_hex('3082013c3081e4a003020102020a47901280001155957352300a06082a8648ce3d0403023017311530130603550403130c476e756262792050696c6f74301e170d3132303831343138323933325a170d3133303831343138323933325a3031312f302d0603550403132650696c6f74476e756262792d302e342e312d34373930313238303030313135353935373335323059301306072a8648ce3d020106082a8648ce3d030107034200048d617e65c9508e64bcc5673ac82a6799da3c1446682c258c463fffdf58dfd2fa3e6c378b53d795c4a4dffb4199edd7862f23abaf0203b4b8911ba0569994e101300a06082a8648ce3d0403020347003044022060cdb6061e9c22262d1aac1d96d8c70829b2366531dda268832cb836bcd30dfa0220631b1459f09e6330055722c8d89b7f48883b9089b88d60d1d9795902b30410df')) # noqa
self.assertEqual(resp.signature, a2b_hex('304502201471899bcc3987e62e8202c9b39c33c19033f7340352dba80fcab017db9230e402210082677d673d891933ade6f617e5dbde2e247e70423fd5ad7804a6d3d3961ef871')) # noqa
client_data = ClientData(b'{"typ":"navigator.id.finishEnrollment","challenge":"vqrS6WXDe1JUs5_c3i4-LkKIHRr-3XVb3azuA5TifHo","cid_pubkey":{"kty":"EC","crv":"P-256","x":"HzQwlfXX7Q4S5MtCCnZUNBw3RMzPO9tOyWjBqRl4tJ8","y":"XVguGFLIZx1fXg3wNqfdbn75hi4-_7-BxhMljw42Ht4"},"origin":"http://example.com"}') # noqa
resp.verify(app_param, client_param)
def test_authenticate(self):
@ -103,7 +101,6 @@ class TestCTAP1(unittest.TestCase):
self.assertEqual(resp.signature, a2b_hex('304402204b5f0cd17534cedd8c34ee09570ef542a353df4436030ce43d406de870b847780220267bb998fac9b7266eb60e7cb0b5eabdfd5ba9614f53c7b22272ec10047a923f')) # noqa
public_key = a2b_hex(b'04d368f1b665bade3c33a20f1e429c7750d5033660c019119d29aa4ba7abc04aa7c80a46bbe11ca8cb5674d74f31f8a903f6bad105fb6ab74aefef4db8b0025e1d') # noqa
client_data = ClientData(b'{"typ":"navigator.id.getAssertion","challenge":"opsXqUifDriAAmWclinfbS0e-USY0CgyJHe_Otd7z8o","cid_pubkey":{"kty":"EC","crv":"P-256","x":"HzQwlfXX7Q4S5MtCCnZUNBw3RMzPO9tOyWjBqRl4tJ8","y":"XVguGFLIZx1fXg3wNqfdbn75hi4-_7-BxhMljw42Ht4"},"origin":"http://example.com"}') # noqa
resp.verify(app_param, client_param, public_key)
key_handle = b'\4'*8

View File

@ -87,10 +87,6 @@ class TestWebSafe(unittest.TestCase):
self.assertEqual(websafe_encode(b'fooba'), u'Zm9vYmE')
self.assertEqual(websafe_encode(b'foobar'), u'Zm9vYmFy')
def test_websafe_encode_unicode(self):
self.assertEqual(websafe_encode(u''), u'')
self.assertEqual(websafe_encode(u'foobar'), u'Zm9vYmFy')
class TestTimeout(unittest.TestCase):