343 lines
12 KiB
Dart
343 lines
12 KiB
Dart
// CTAP2 FIDO2 operations — makeCredential, getAssertion, verifyAssertion.
|
|
// Mirrors the direct-CTAP2 path in k_proxy_app.py.
|
|
//
|
|
// Wire format: first byte of ctap2Cbor response is a CTAP status code (0x00 = OK),
|
|
// remaining bytes are a CBOR map. Request is a CBOR map with no status prefix.
|
|
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:cbor/cbor.dart';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:pointycastle/export.dart';
|
|
|
|
import 'ctaphid_channel.dart';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const String kRpId = 'localhost';
|
|
const String kOrigin = 'https://localhost';
|
|
const String kRpName = 'ChromeCard Proxy';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public result types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class MakeCredentialResult {
|
|
/// Raw AttestedCredentialData bytes: aaguid(16) + credIdLen(2) + credId + coseKey
|
|
final Uint8List credentialData;
|
|
|
|
/// base64url of credentialData — store this in EnrollmentDb
|
|
String get credentialDataB64 => _b64uEncode(credentialData);
|
|
|
|
/// The 32-byte user handle used during registration
|
|
final Uint8List userId;
|
|
|
|
/// base64url of userId — store this in EnrollmentDb
|
|
String get userIdB64 => _b64uEncode(userId);
|
|
|
|
MakeCredentialResult({required this.credentialData, required this.userId});
|
|
}
|
|
|
|
class GetAssertionResult {
|
|
final Uint8List authData;
|
|
final Uint8List signature;
|
|
final Uint8List clientDataHash;
|
|
|
|
GetAssertionResult({
|
|
required this.authData,
|
|
required this.signature,
|
|
required this.clientDataHash,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// makeCredential
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Runs CTAP2 authenticatorMakeCredential against the card on [cid].
|
|
/// Returns credential data that should be persisted in the enrollment store.
|
|
Future<MakeCredentialResult> makeCredential(
|
|
int cid,
|
|
String username, {
|
|
String? displayName,
|
|
Uint8List? userId,
|
|
}) async {
|
|
final uid = userId ?? _randomBytes(32);
|
|
final challenge = _randomBytes(32);
|
|
|
|
final clientDataJson = _buildClientDataJson('webauthn.create', challenge);
|
|
final clientDataHash = _sha256(utf8.encode(clientDataJson));
|
|
|
|
// CBOR map: authenticatorMakeCredential (CTAP2 spec integer keys throughout)
|
|
final requestMap = CborMap({
|
|
CborSmallInt(1): CborBytes(clientDataHash),
|
|
CborSmallInt(2): CborMap({
|
|
CborString('id'): CborString(kRpId),
|
|
CborString('name'): CborString(kRpName),
|
|
}),
|
|
CborSmallInt(3): CborMap({
|
|
CborString('id'): CborBytes(uid),
|
|
CborString('name'): CborString(username),
|
|
CborString('displayName'): CborString(displayName ?? username),
|
|
}),
|
|
CborSmallInt(4): CborList([
|
|
CborMap({
|
|
CborString('type'): CborString('public-key'),
|
|
CborString('alg'): CborSmallInt(-7),
|
|
}),
|
|
]),
|
|
CborSmallInt(7): CborMap({
|
|
// rk=false: non-resident — credential ID is stored externally in EnrollmentDb
|
|
// rather than on the card, so multiple users can enroll on one card.
|
|
// uv=false: no PIN; authentication uses user-presence (fingerprint touch) only.
|
|
CborString('rk'): CborBool(false),
|
|
CborString('uv'): CborBool(false),
|
|
}),
|
|
});
|
|
|
|
// CTAP2 over CTAPHID: first byte is the authenticatorMakeCredential (0x01) command code.
|
|
final encoded = Uint8List.fromList([0x01, ...cbor.encode(requestMap)]);
|
|
final response = await ctap2Cbor(cid, encoded);
|
|
|
|
final responseMap = _parseCtapResponse(response);
|
|
final authData = _requireBytes(responseMap, 2, 'makeCredential authData');
|
|
|
|
final credData = _extractAttestedCredentialData(authData);
|
|
return MakeCredentialResult(credentialData: credData, userId: uid);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// getAssertion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Runs CTAP2 authenticatorGetAssertion against the card on [cid].
|
|
/// [credentialDataB64] is the base64url of the stored AttestedCredentialData.
|
|
Future<GetAssertionResult> getAssertion(
|
|
int cid,
|
|
String credentialDataB64,
|
|
) async {
|
|
final credData = _b64uDecode(credentialDataB64);
|
|
final credId = _extractCredentialId(credData);
|
|
|
|
final challenge = _randomBytes(32);
|
|
final clientDataJson = _buildClientDataJson('webauthn.get', challenge);
|
|
final clientDataHash = _sha256(utf8.encode(clientDataJson));
|
|
|
|
final requestMap = CborMap({
|
|
CborSmallInt(1): CborString(kRpId),
|
|
CborSmallInt(2): CborBytes(clientDataHash),
|
|
CborSmallInt(3): CborList([
|
|
CborMap({
|
|
CborString('type'): CborString('public-key'),
|
|
CborString('id'): CborBytes(credId),
|
|
}),
|
|
]),
|
|
CborSmallInt(5): CborMap({
|
|
CborString('up'): CborBool(true), // require fingerprint touch (user presence)
|
|
CborString('uv'): CborBool(false), // no PIN
|
|
}),
|
|
});
|
|
|
|
// CTAP2 over CTAPHID: first byte is the authenticatorGetAssertion (0x02) command code.
|
|
final encoded = Uint8List.fromList([0x02, ...cbor.encode(requestMap)]);
|
|
final response = await ctap2Cbor(cid, encoded);
|
|
|
|
final responseMap = _parseCtapResponse(response);
|
|
final authData = _requireBytes(responseMap, 2, 'getAssertion authData');
|
|
final signature = _requireBytes(responseMap, 3, 'getAssertion signature');
|
|
|
|
return GetAssertionResult(
|
|
authData: authData,
|
|
signature: signature,
|
|
clientDataHash: clientDataHash,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// verifyAssertion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Verifies the ECDSA-P256 assertion signature.
|
|
/// [credentialDataB64] is the stored base64url AttestedCredentialData.
|
|
/// Returns true if the signature is valid.
|
|
bool verifyAssertion(
|
|
String credentialDataB64,
|
|
Uint8List authData,
|
|
Uint8List signature,
|
|
Uint8List clientDataHash,
|
|
) {
|
|
final credData = _b64uDecode(credentialDataB64);
|
|
final coseKey = _extractCoseKey(credData);
|
|
final pubKey = _coseKeyToEcPublicKey(coseKey);
|
|
|
|
// CTAP2/WebAuthn spec: the signed message is authData || SHA-256(clientDataJSON).
|
|
final message = Uint8List(authData.length + clientDataHash.length)
|
|
..setRange(0, authData.length, authData)
|
|
..setRange(authData.length, authData.length + clientDataHash.length, clientDataHash);
|
|
|
|
final (r, s) = _decodeDerSignature(signature);
|
|
|
|
final verifier = ECDSASigner(SHA256Digest())
|
|
..init(false, PublicKeyParameter<ECPublicKey>(pubKey));
|
|
|
|
try {
|
|
return verifier.verifySignature(message, ECSignature(r, s));
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AuthData parsing helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Extracts AttestedCredentialData from a full authData blob.
|
|
/// authData layout:
|
|
/// [0:32] rpIdHash
|
|
/// [32] flags
|
|
/// [33:37] signCount (uint32 BE)
|
|
/// [37:53] aaguid (16 bytes) ← attested cred data starts here
|
|
/// [53:55] credIdLen (uint16 BE)
|
|
/// [55:55+n] credId
|
|
/// [55+n:] COSE key (CBOR)
|
|
Uint8List _extractAttestedCredentialData(Uint8List authData) {
|
|
if (authData.length < 55) {
|
|
throw FormatException('authData too short for attested credential data: ${authData.length}');
|
|
}
|
|
// The attested credential data is everything from offset 37 onward.
|
|
return Uint8List.fromList(authData.sublist(37));
|
|
}
|
|
|
|
/// Extracts the credential ID from AttestedCredentialData bytes.
|
|
/// Layout: aaguid(16) + credIdLen(2) + credId(n) + coseKey
|
|
Uint8List _extractCredentialId(Uint8List credData) {
|
|
if (credData.length < 18) {
|
|
throw FormatException('credentialData too short: ${credData.length}');
|
|
}
|
|
final credIdLen = (credData[16] << 8) | credData[17];
|
|
if (credData.length < 18 + credIdLen) {
|
|
throw FormatException('credentialData truncated before credId end');
|
|
}
|
|
return Uint8List.fromList(credData.sublist(18, 18 + credIdLen));
|
|
}
|
|
|
|
/// Extracts the COSE key bytes from AttestedCredentialData.
|
|
Uint8List _extractCoseKey(Uint8List credData) {
|
|
if (credData.length < 18) {
|
|
throw FormatException('credentialData too short for COSE key');
|
|
}
|
|
final credIdLen = (credData[16] << 8) | credData[17];
|
|
final coseStart = 18 + credIdLen;
|
|
if (credData.length <= coseStart) {
|
|
throw FormatException('credentialData has no COSE key bytes');
|
|
}
|
|
return Uint8List.fromList(credData.sublist(coseStart));
|
|
}
|
|
|
|
/// Parses a COSE EC2 key and returns an ECPublicKey for pointycastle.
|
|
ECPublicKey _coseKeyToEcPublicKey(Uint8List coseKeyBytes) {
|
|
final decoded = cbor.decode(coseKeyBytes);
|
|
if (decoded is! CborMap) throw FormatException('COSE key is not a CBOR map');
|
|
|
|
Uint8List? x, y;
|
|
for (final entry in decoded.entries) {
|
|
final k = entry.key;
|
|
final v = entry.value;
|
|
// COSE key -2 = x, -3 = y (represented as CborSmallInt or CborInt)
|
|
final ki = _cborInt(k);
|
|
if (ki == -2 && v is CborBytes) x = Uint8List.fromList(v.bytes);
|
|
if (ki == -3 && v is CborBytes) y = Uint8List.fromList(v.bytes);
|
|
}
|
|
if (x == null || y == null) throw FormatException('COSE key missing x or y coordinate');
|
|
|
|
final domainParams = ECDomainParameters('prime256v1');
|
|
final point = domainParams.curve.createPoint(
|
|
BigInt.parse(x.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), radix: 16),
|
|
BigInt.parse(y.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), radix: 16),
|
|
);
|
|
return ECPublicKey(point, domainParams);
|
|
}
|
|
|
|
int _cborInt(CborValue v) {
|
|
if (v is CborSmallInt) return v.value;
|
|
if (v is CborInt) return v.toInt();
|
|
throw FormatException('expected CBOR int, got ${v.runtimeType}');
|
|
}
|
|
|
|
/// DER-decode an ECDSA signature into (r, s) BigInts.
|
|
(BigInt, BigInt) _decodeDerSignature(Uint8List der) {
|
|
// SEQUENCE { INTEGER r, INTEGER s }
|
|
if (der[0] != 0x30) throw FormatException('DER signature: expected SEQUENCE tag');
|
|
// P-256 signatures are always ≤72 bytes, so the SEQUENCE length fits in one byte.
|
|
// Multi-byte BER length encoding (0x81/0x82 prefix) is not handled here.
|
|
var offset = 2; // skip 0x30 + one-byte length
|
|
if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for r');
|
|
final rLen = der[offset + 1];
|
|
final rBytes = der.sublist(offset + 2, offset + 2 + rLen);
|
|
offset += 2 + rLen;
|
|
if (der[offset] != 0x02) throw FormatException('DER signature: expected INTEGER tag for s');
|
|
final sLen = der[offset + 1];
|
|
final sBytes = der.sublist(offset + 2, offset + 2 + sLen);
|
|
return (_bigIntFromBytes(rBytes), _bigIntFromBytes(sBytes));
|
|
}
|
|
|
|
BigInt _bigIntFromBytes(Uint8List bytes) {
|
|
var result = BigInt.zero;
|
|
for (final b in bytes) {
|
|
result = (result << 8) | BigInt.from(b);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CTAP response parsing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
CborMap _parseCtapResponse(Uint8List response) {
|
|
if (response.isEmpty) throw FormatException('empty CTAP response');
|
|
final status = response[0];
|
|
if (status != 0x00) throw FormatException('CTAP error: 0x${status.toRadixString(16)}');
|
|
final decoded = cbor.decode(response.sublist(1));
|
|
if (decoded is! CborMap) throw FormatException('CTAP response body is not a CBOR map');
|
|
return decoded;
|
|
}
|
|
|
|
Uint8List _requireBytes(CborMap map, int key, String field) {
|
|
final v = map[CborSmallInt(key)];
|
|
if (v == null) throw FormatException('$field: missing key $key in CTAP response');
|
|
if (v is! CborBytes) throw FormatException('$field: expected bytes, got ${v.runtimeType}');
|
|
return Uint8List.fromList(v.bytes);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
String _buildClientDataJson(String type, Uint8List challenge) {
|
|
final challengeB64 = _b64uEncode(challenge);
|
|
return '{"type":"$type","challenge":"$challengeB64","origin":"$kOrigin","crossOrigin":false}';
|
|
}
|
|
|
|
Uint8List _sha256(List<int> data) {
|
|
return Uint8List.fromList(sha256.convert(data).bytes);
|
|
}
|
|
|
|
Uint8List _randomBytes(int n) {
|
|
final rng = Random.secure();
|
|
return Uint8List.fromList(List.generate(n, (_) => rng.nextInt(256)));
|
|
}
|
|
|
|
String _b64uEncode(Uint8List data) {
|
|
return base64Url.encode(data).replaceAll('=', '');
|
|
}
|
|
|
|
Uint8List _b64uDecode(String s) {
|
|
// base64url strips trailing '='; restore padding to the nearest multiple of 4.
|
|
final padded = s + '=' * ((4 - s.length % 4) % 4);
|
|
return Uint8List.fromList(base64Url.decode(padded));
|
|
}
|