Skip to content

Commit d5c0b02

Browse files
authored
Merge pull request #2 from inuEbisu/main
feat: Introduce WebAuthn Fido2Server and add COSE verification (ES256, EdDSA)
2 parents 94d11df + 1206a00 commit d5c0b02

22 files changed

Lines changed: 1826 additions & 47 deletions

lib/fido2.dart

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
/// Library to parse FIDO2 request / response and interactive with FIDO2 authenticators.
22
library fido2;
33

4-
export 'src/ctap.dart';
5-
export 'src/cose.dart';
6-
7-
export 'src/ctap2/base.dart';
8-
export 'src/ctap2/pin.dart';
9-
export 'src/ctap2/credmgmt.dart';
4+
export 'fido2_client.dart';
5+
export 'fido2_server.dart';

lib/fido2_client.dart

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/// Public API for the FIDO2 client (CTAP).
2+
library fido2_client;
3+
4+
export 'src/ctap.dart';
5+
export 'src/cose.dart';
6+
7+
export 'src/ctap2/base.dart';
8+
export 'src/ctap2/pin.dart';
9+
export 'src/ctap2/credmgmt.dart';
10+
export 'src/ctap2/constants.dart';
11+
12+
export 'src/ctap2/entities/authenticator_info.dart';
13+
export 'src/ctap2/entities/credential_entities.dart';
14+
15+
export 'src/ctap2/requests/client_pin.dart';
16+
export 'src/ctap2/requests/credential_mgmt.dart';
17+
export 'src/ctap2/requests/get_assertion.dart';
18+
export 'src/ctap2/requests/get_info.dart';
19+
export 'src/ctap2/requests/make_credential.dart';

lib/fido2_server.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/// Public API for the FIDO2 server (WebAuthn).
2+
library fido2_server;
3+
4+
export 'src/server/base.dart';
5+
export 'src/server/config.dart';
6+
export 'src/server/entities/authenticator_data.dart';
7+
export 'src/server/entities/registration_data.dart';

lib/src/cose.dart

Lines changed: 233 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,166 @@
11
import 'dart:collection';
2+
import 'dart:typed_data';
3+
4+
import 'package:cryptography/cryptography.dart' as crypto_api;
5+
import 'package:pointycastle/export.dart' as pc;
6+
import 'package:asn1lib/asn1lib.dart' as asn1;
27

38
import 'package:cbor/cbor.dart';
9+
import 'package:convert/convert.dart';
410

511
/// Represents a key as specified by RFC8152:
612
/// [CBOR Object Signing and Encryption (COSE)](https://tools.ietf.org/html/rfc8152)
713
///
814
/// Extended this class to support more COSE key types.
915
sealed class CoseKey extends MapView<int, dynamic> {
16+
// Key Objects (RFC8152 Section 7.1)
17+
static const int ktyIdx = 1;
18+
static const int algIdx = 3;
19+
20+
// Key Types (RFC8152 Section 13)
21+
static const int ktyOKP = 1;
22+
static const int ktyEC2 = 2;
23+
24+
// EC2 Keys (RFC8152 Section 13.1.1)
25+
static const int ec2CrvIdx = -1;
26+
static const int ec2XIdx = -2;
27+
static const int ec2YIdx = -3;
28+
29+
// EC2 Curves (RFC8152 Section 13.1)
30+
static const int ec2CrvP256 = 1;
31+
32+
// OKP Keys (RFC8152 Section 13.2)
33+
static const int okpCrvIdx = -1;
34+
static const int okpXIdx = -2;
35+
36+
// OKP Curves (RFC8152 Section 13.1)
37+
static const int okpCrvEd25519 = 6;
38+
1039
static const int? algorithm = null;
1140

1241
/// Constructor with optional parameters
1342
CoseKey(super.coseKeyParams);
1443

15-
/// Method to verify a signature
16-
void verify(List<int> message, List<int> signature) {
44+
/// Verifies a signature for the provided message using this COSE public key.
45+
///
46+
/// Throws an [Exception] if verification fails or the algorithm is unsupported.
47+
Future<void> verify(List<int> message, List<int> signature) async {
1748
throw UnimplementedError('Signature verification not supported.');
1849
}
1950

51+
/// Convert to a CBOR Map representation suitable for passing through APIs
52+
/// that expect a [CborMap] (e.g., server verification inputs).
53+
CborMap toCborMap() {
54+
throw UnimplementedError('toCborMap not supported.');
55+
}
56+
2057
/// Convert to standard CBOR encoded format
2158
CborValue toCbor() {
22-
throw UnimplementedError('toCbor not supported.');
59+
return CborValue(toCborMap());
2360
}
2461

2562
/// Static method to parse a COSE key
2663
static CoseKey parse(Map<int, dynamic> cose) {
27-
int? alg = cose[3];
64+
int? alg = cose[algIdx];
2865
if (alg == null) {
2966
throw ArgumentError('COSE alg identifier must be provided.');
3067
}
3168
switch (alg) {
3269
case ES256.algorithm:
3370
return ES256(cose);
71+
case EdDSA.algorithm:
72+
return EdDSA(cose);
73+
case EcdhEsHkdf256.algorithm:
74+
return EcdhEsHkdf256(cose);
3475
}
3576
return UnsupportedKey(cose);
3677
}
3778

3879
/// Static method to get all algorithms supported by fido2 library
3980
static List<int> supportedAlgorithms() {
40-
return [ES256.algorithm];
81+
return [ES256.algorithm, EdDSA.algorithm, EcdhEsHkdf256.algorithm];
82+
}
83+
84+
@override
85+
String toString() {
86+
final alg = this[algIdx];
87+
final buffer = StringBuffer();
88+
buffer.writeln('CoseKey(');
89+
buffer.writeln(' algorithm: $alg,');
90+
buffer.writeln(' params: ${Map<int, dynamic>.from(this)}');
91+
buffer.write(')');
92+
return buffer.toString();
4193
}
4294
}
4395

4496
/// Represents a currently unsupported COSE key type
4597
class UnsupportedKey extends CoseKey {
4698
UnsupportedKey(super.coseKeyParams);
99+
100+
@override
101+
String toString() {
102+
final alg = this[CoseKey.algIdx];
103+
final buffer = StringBuffer();
104+
buffer.writeln('UnsupportedKey(');
105+
buffer.writeln(' algorithm: $alg,');
106+
buffer.writeln(' params: ${Map<int, dynamic>.from(this)}');
107+
buffer.write(')');
108+
return buffer.toString();
109+
}
110+
}
111+
112+
/// Represents a COSE key of type EdDSA (Ed25519, see RFC8152 8.2)
113+
class EdDSA extends CoseKey {
114+
static const int algorithm = -8;
115+
116+
EdDSA(super.coseKeyParams);
117+
118+
/// Static method to create a new instance from public key coordinates
119+
static EdDSA fromPublicKey(List<int> x) {
120+
return EdDSA({
121+
CoseKey.ktyIdx: CoseKey.ktyOKP,
122+
CoseKey.algIdx: EdDSA.algorithm,
123+
CoseKey.okpCrvIdx: CoseKey.okpCrvEd25519,
124+
CoseKey.okpXIdx: x,
125+
});
126+
}
127+
128+
@override
129+
CborMap toCborMap() {
130+
return CborMap({
131+
CborInt(BigInt.from(CoseKey.ktyIdx)):
132+
CborInt(BigInt.from(CoseKey.ktyOKP)),
133+
CborInt(BigInt.from(CoseKey.algIdx)):
134+
CborInt(BigInt.from(EdDSA.algorithm)),
135+
CborInt(BigInt.from(CoseKey.okpCrvIdx)):
136+
CborInt(BigInt.from(CoseKey.okpCrvEd25519)),
137+
CborInt(BigInt.from(CoseKey.okpXIdx)): CborBytes(this[CoseKey.okpXIdx]),
138+
});
139+
}
140+
141+
@override
142+
Future<void> verify(List<int> message, List<int> signature) async {
143+
final xBytes = this[CoseKey.okpXIdx] as List<int>?;
144+
if (xBytes == null) {
145+
throw Exception('Ed25519 verification failed: missing public key (x).');
146+
}
147+
// Ed25519 signatures must be exactly 64 bytes (R || S)
148+
if (signature.length != 64) {
149+
throw Exception(
150+
'Assertion signature verification failed (Ed25519): invalid signature length ${signature.length}, expected 64 bytes.');
151+
}
152+
final pubKey = crypto_api.SimplePublicKey(
153+
xBytes,
154+
type: crypto_api.KeyPairType.ed25519,
155+
);
156+
final verified = await crypto_api.Ed25519().verify(
157+
message,
158+
signature: crypto_api.Signature(signature, publicKey: pubKey),
159+
);
160+
if (!verified) {
161+
throw Exception('Assertion signature verification failed (Ed25519).');
162+
}
163+
}
47164
}
48165

49166
/// Represents a COSE key of type ES256 (ECDSA w/ SHA-256, see RFC8152 8.1)
@@ -55,24 +172,95 @@ class ES256 extends CoseKey {
55172
/// Static method to create a new instance from public key coordinates
56173
static ES256 fromPublicKey(List<int> x, List<int> y) {
57174
return ES256({
58-
1: 2,
59-
3: ES256.algorithm,
60-
-1: 1,
61-
-2: x,
62-
-3: y,
175+
CoseKey.ktyIdx: CoseKey.ktyEC2,
176+
CoseKey.algIdx: ES256.algorithm,
177+
CoseKey.ec2CrvIdx: CoseKey.ec2CrvP256,
178+
CoseKey.ec2XIdx: x,
179+
CoseKey.ec2YIdx: y,
63180
});
64181
}
65182

66183
@override
67-
CborValue toCbor() {
68-
return CborValue({
69-
1: 2,
70-
3: ES256.algorithm,
71-
-1: 1,
72-
-2: CborBytes(this[-2]),
73-
-3: CborBytes(this[-3]),
184+
CborMap toCborMap() {
185+
return CborMap({
186+
CborInt(BigInt.from(CoseKey.ktyIdx)):
187+
CborInt(BigInt.from(CoseKey.ktyEC2)),
188+
CborInt(BigInt.from(CoseKey.algIdx)):
189+
CborInt(BigInt.from(ES256.algorithm)),
190+
CborInt(BigInt.from(CoseKey.ec2CrvIdx)):
191+
CborInt(BigInt.from(CoseKey.ec2CrvP256)),
192+
CborInt(BigInt.from(CoseKey.ec2XIdx)): CborBytes(this[CoseKey.ec2XIdx]),
193+
CborInt(BigInt.from(CoseKey.ec2YIdx)): CborBytes(this[CoseKey.ec2YIdx]),
74194
});
75195
}
196+
197+
@override
198+
String toString() {
199+
final x = this[CoseKey.ec2XIdx] as List<int>?;
200+
final y = this[CoseKey.ec2YIdx] as List<int>?;
201+
final buffer = StringBuffer();
202+
buffer.writeln('ES256(');
203+
buffer.writeln(' algorithm: $algorithm,');
204+
buffer.writeln(' x: ${x != null ? hex.encode(x) : null},');
205+
buffer.writeln(' y: ${y != null ? hex.encode(y) : null}');
206+
buffer.write(')');
207+
return buffer.toString();
208+
}
209+
210+
@override
211+
Future<void> verify(List<int> message, List<int> signature) async {
212+
final xBytes = this[CoseKey.ec2XIdx] as List<int>?;
213+
final yBytes = this[CoseKey.ec2YIdx] as List<int>?;
214+
if (xBytes == null || yBytes == null) {
215+
throw Exception('ES256 verification failed: missing public key x/y.');
216+
}
217+
218+
// Parse ASN.1 DER ECDSA signature: SEQUENCE(INTEGER r, INTEGER s)
219+
BigInt bytesToInt(List<int> bytes) =>
220+
bytes.fold<BigInt>(BigInt.zero, (a, b) => (a << 8) | BigInt.from(b));
221+
final parser = asn1.ASN1Parser(Uint8List.fromList(signature));
222+
final obj = parser.nextObject();
223+
if (obj is! asn1.ASN1Sequence || obj.elements.length != 2) {
224+
throw Exception('ES256 verification failed: malformed DER signature.');
225+
}
226+
// Reject trailing bytes beyond the DER SEQUENCE
227+
// If the parser can continue, the signature contains extra data.
228+
if (parser.hasNext()) {
229+
throw Exception(
230+
'ES256 verification failed: trailing bytes present in DER signature.');
231+
}
232+
final rObj = obj.elements[0];
233+
final sObj = obj.elements[1];
234+
if (rObj is! asn1.ASN1Integer || sObj is! asn1.ASN1Integer) {
235+
throw Exception(
236+
'ES256 verification failed: DER must contain two integers.');
237+
}
238+
final rBytes = rObj.valueBytes();
239+
final sBytes = sObj.valueBytes();
240+
final r = bytesToInt(rBytes);
241+
var s = bytesToInt(sBytes);
242+
243+
// Build PointyCastle public key
244+
final domain = pc.ECDomainParameters('secp256r1');
245+
final q = domain.curve.createPoint(bytesToInt(xBytes), bytesToInt(yBytes));
246+
final pubKey = pc.ECPublicKey(q, domain);
247+
248+
// Low-S normalization to prevent malleability: use s = min(s, n - s)
249+
final n = domain.n;
250+
final halfN = n >> 1;
251+
if (s > halfN) {
252+
s = n - s;
253+
}
254+
255+
// Verify using SHA-256/ECDSA
256+
final verifier = pc.Signer('SHA-256/ECDSA');
257+
verifier.init(false, pc.PublicKeyParameter<pc.ECPublicKey>(pubKey));
258+
final ok = verifier.verifySignature(
259+
Uint8List.fromList(message), pc.ECSignature(r, s));
260+
if (!ok) {
261+
throw Exception('Assertion signature verification failed (ES256).');
262+
}
263+
}
76264
}
77265

78266
/// Represents a COSE key of type ECDH-ES+HKDF-256 (see RFC8152 11.1)
@@ -84,22 +272,38 @@ class EcdhEsHkdf256 extends CoseKey {
84272
/// Static method to create a new instance from public key coordinates
85273
static EcdhEsHkdf256 fromPublicKey(List<int> x, List<int> y) {
86274
return EcdhEsHkdf256({
87-
1: 2,
88-
3: EcdhEsHkdf256.algorithm,
89-
-1: 1,
90-
-2: x,
91-
-3: y,
275+
CoseKey.ktyIdx: CoseKey.ktyEC2,
276+
CoseKey.algIdx: EcdhEsHkdf256.algorithm,
277+
CoseKey.ec2CrvIdx: CoseKey.ec2CrvP256,
278+
CoseKey.ec2XIdx: x,
279+
CoseKey.ec2YIdx: y,
92280
});
93281
}
94282

95283
@override
96-
CborValue toCbor() {
97-
return CborValue({
98-
1: 2,
99-
3: EcdhEsHkdf256.algorithm,
100-
-1: 1,
101-
-2: CborBytes(this[-2]),
102-
-3: CborBytes(this[-3]),
284+
CborMap toCborMap() {
285+
return CborMap({
286+
CborInt(BigInt.from(CoseKey.ktyIdx)):
287+
CborInt(BigInt.from(CoseKey.ktyEC2)),
288+
CborInt(BigInt.from(CoseKey.algIdx)):
289+
CborInt(BigInt.from(EcdhEsHkdf256.algorithm)),
290+
CborInt(BigInt.from(CoseKey.ec2CrvIdx)):
291+
CborInt(BigInt.from(CoseKey.ec2CrvP256)),
292+
CborInt(BigInt.from(CoseKey.ec2XIdx)): CborBytes(this[CoseKey.ec2XIdx]),
293+
CborInt(BigInt.from(CoseKey.ec2YIdx)): CborBytes(this[CoseKey.ec2YIdx]),
103294
});
104295
}
296+
297+
@override
298+
String toString() {
299+
final x = this[CoseKey.ec2XIdx] as List<int>?;
300+
final y = this[CoseKey.ec2YIdx] as List<int>?;
301+
final buffer = StringBuffer();
302+
buffer.writeln('EcdhEsHkdf256(');
303+
buffer.writeln(' algorithm: $algorithm,');
304+
buffer.writeln(' x: ${x != null ? hex.encode(x) : null},');
305+
buffer.writeln(' y: ${y != null ? hex.encode(y) : null}');
306+
buffer.write(')');
307+
return buffer.toString();
308+
}
105309
}

0 commit comments

Comments
 (0)