k_card/k_phone/lib/fido2_ops.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));
}