fix(auth): decoy encryption keys are keys a device could have made
An address nobody has got, as the recovery phrase's first step, an X25519 public key of 32 HMAC bytes, which is a point of Curve25519's prime-order subgroup one time in sixteen, as every real key is: whoever checked could tell nearly always whether an address has an account (the second reading's M1). The decoy's key is now made from a seed, as its Ed25519 key already was. The tests hold twenty decoys to the curve check the review used. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
aea0c447fc
commit
009970faab
5 changed files with 137 additions and 8 deletions
|
|
@ -51,3 +51,12 @@ Future<Uint8List> ed25519PublicKeyFromSeed(List<int> seed) async {
|
||||||
final pair = await Ed25519().newKeyPairFromSeed(seed);
|
final pair = await Ed25519().newKeyPairFromSeed(seed);
|
||||||
return Uint8List.fromList((await pair.extractPublicKey()).bytes);
|
return Uint8List.fromList((await pair.extractPublicKey()).bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An X25519 public key made from [seed] (32 bytes, clamped as a scalar): a
|
||||||
|
/// decoy's, which is a point of the prime-order subgroup as an account's
|
||||||
|
/// is; random bytes would be one only one time in sixteen, telling a decoy
|
||||||
|
/// from an account.
|
||||||
|
Future<Uint8List> x25519PublicKeyFromSeed(List<int> seed) async {
|
||||||
|
final pair = await X25519().newKeyPairFromSeed(seed);
|
||||||
|
return Uint8List.fromList((await pair.extractPublicKey()).bytes);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1086,8 +1086,9 @@ Uint8List _decoyBytes(
|
||||||
);
|
);
|
||||||
|
|
||||||
/// An address nobody has's keys, as a key proof's first step shows them:
|
/// An address nobody has's keys, as a key proof's first step shows them:
|
||||||
/// an X25519 public key (its top bit clear, as a point's u is), an Ed25519
|
/// X25519 and Ed25519 public keys made from seeds, so each is a point a
|
||||||
/// one made from a seed, and wrapped keys of an account's length; and the
|
/// device's key could be (random bytes mostly aren't, which told a decoy
|
||||||
|
/// from an account), and wrapped keys of an account's length; and the
|
||||||
/// phrase's proof key its second step checks against. Computed for every
|
/// phrase's proof key its second step checks against. Computed for every
|
||||||
/// proof begun, an account's too.
|
/// proof begun, an account's too.
|
||||||
Future<
|
Future<
|
||||||
|
|
@ -1100,12 +1101,9 @@ Future<
|
||||||
})
|
})
|
||||||
>
|
>
|
||||||
_decoyKeys(String decoyKey, String hash) async => (
|
_decoyKeys(String decoyKey, String hash) async => (
|
||||||
encryptionPublicKey: _decoyBytes(
|
encryptionPublicKey: await x25519PublicKeyFromSeed(
|
||||||
decoyKey,
|
_decoyBytes(decoyKey, 'encryption seed', hash, 32),
|
||||||
'encryption key',
|
),
|
||||||
hash,
|
|
||||||
_publicKeyLength,
|
|
||||||
)..[_publicKeyLength - 1] &= 0x7f,
|
|
||||||
signingPublicKey: await ed25519PublicKeyFromSeed(
|
signingPublicKey: await ed25519PublicKeyFromSeed(
|
||||||
_decoyBytes(decoyKey, 'signing seed', hash, 32),
|
_decoyBytes(decoyKey, 'signing seed', hash, 32),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import 'package:serverpod/serverpod.dart';
|
||||||
import 'package:serverpod_auth_idp_server/core.dart';
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../support/curve25519.dart';
|
||||||
import '../support/fixtures.dart';
|
import '../support/fixtures.dart';
|
||||||
import '../support/mailbox.dart';
|
import '../support/mailbox.dart';
|
||||||
import '../support/password_device.dart';
|
import '../support/password_device.dart';
|
||||||
|
|
@ -1489,6 +1490,28 @@ void main() {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('gives addresses nobody has keys no test tells from an '
|
||||||
|
"account's: X25519 public keys are points of Curve25519's "
|
||||||
|
'prime-order subgroup, as keys made from scalars are', () async {
|
||||||
|
// Random bytes are such a point one time in sixteen: twenty
|
||||||
|
// addresses all passing by chance is 2^-80.
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
final nobody = await atNow(
|
||||||
|
() => endpoints.accountRecovery.beginKeyProof(
|
||||||
|
notSignedIn(),
|
||||||
|
address: 'nobody$i@example.org',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isX25519PublicKey(
|
||||||
|
Uint8List.sublistView(nobody.encryptionPublicKey),
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
reason: 'nobody$i@example.org',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('is refused for another key, an address nobody has, a missing '
|
test('is refused for another key, an address nobody has, a missing '
|
||||||
'code, a used challenge, or a new password out of range, the '
|
'code, a used challenge, or a new password out of range, the '
|
||||||
'password staying', () async {
|
'password staying', () async {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
/// Whether 32 bytes are an X25519 public key a device could have made: a
|
||||||
|
/// point of Curve25519 (not of its twist) in its prime-order subgroup, as
|
||||||
|
/// every key made from a scalar is. Random bytes are such a point one time
|
||||||
|
/// in sixteen, so an answer for an address nobody has that isn't one would
|
||||||
|
/// tell it from an account's (the second reading's M1).
|
||||||
|
library;
|
||||||
|
|
||||||
|
final _p = (BigInt.one << 255) - BigInt.from(19);
|
||||||
|
final _a24 = BigInt.from(121665);
|
||||||
|
|
||||||
|
/// The prime order of the subgroup the base point generates.
|
||||||
|
final _order =
|
||||||
|
(BigInt.one << 252) +
|
||||||
|
BigInt.parse('27742317777372353535851937790883648493');
|
||||||
|
|
||||||
|
/// Whether [publicKey] (X25519's u-coordinate, little-endian) is a point
|
||||||
|
/// of the prime-order subgroup.
|
||||||
|
bool isX25519PublicKey(List<int> publicKey) {
|
||||||
|
final u = _littleEndian(publicKey) % _p;
|
||||||
|
return _onCurve(u) && _ladderIsInfinity(u, _order);
|
||||||
|
}
|
||||||
|
|
||||||
|
BigInt _littleEndian(List<int> bytes) {
|
||||||
|
var value = BigInt.zero;
|
||||||
|
for (var i = bytes.length - 1; i >= 0; i--) {
|
||||||
|
value = (value << 8) | BigInt.from(bytes[i]);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether u³ + Au² + u is a square: the point is on the curve, not its
|
||||||
|
/// twist.
|
||||||
|
bool _onCurve(BigInt u) {
|
||||||
|
final rhs = (u * u * u + BigInt.from(486662) * u * u + u) % _p;
|
||||||
|
return rhs == BigInt.zero ||
|
||||||
|
rhs.modPow((_p - BigInt.one) >> 1, _p) == BigInt.one;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [k] times the point at [u] is the point at infinity (an x-only
|
||||||
|
/// Montgomery ladder, RFC 7748's).
|
||||||
|
bool _ladderIsInfinity(BigInt u, BigInt k) {
|
||||||
|
var x2 = BigInt.one;
|
||||||
|
var z2 = BigInt.zero;
|
||||||
|
var x3 = u;
|
||||||
|
var z3 = BigInt.one;
|
||||||
|
var swap = 0;
|
||||||
|
for (var t = k.bitLength - 1; t >= 0; t--) {
|
||||||
|
final bit = ((k >> t) & BigInt.one).toInt();
|
||||||
|
swap ^= bit;
|
||||||
|
if (swap == 1) {
|
||||||
|
(x2, x3) = (x3, x2);
|
||||||
|
(z2, z3) = (z3, z2);
|
||||||
|
}
|
||||||
|
swap = bit;
|
||||||
|
final a = (x2 + z2) % _p;
|
||||||
|
final aa = a * a % _p;
|
||||||
|
final b = (x2 - z2) % _p;
|
||||||
|
final bb = b * b % _p;
|
||||||
|
final e = (aa - bb) % _p;
|
||||||
|
final c = (x3 + z3) % _p;
|
||||||
|
final d = (x3 - z3) % _p;
|
||||||
|
final da = d * a % _p;
|
||||||
|
final cb = c * b % _p;
|
||||||
|
x3 = (da + cb) * (da + cb) % _p;
|
||||||
|
z3 = u * ((da - cb) * (da - cb) % _p) % _p;
|
||||||
|
x2 = aa * bb % _p;
|
||||||
|
z2 = e * ((aa + _a24 * e) % _p) % _p;
|
||||||
|
}
|
||||||
|
if (swap == 1) {
|
||||||
|
z2 = z3;
|
||||||
|
}
|
||||||
|
return z2 % _p == BigInt.zero;
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,8 @@ import 'package:halkyon_base_server/src/password/key_proof.dart';
|
||||||
import 'package:serverpod/serverpod.dart';
|
import 'package:serverpod/serverpod.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
import '../../integration/support/curve25519.dart';
|
||||||
|
|
||||||
Uint8List _hex(String hex) => Uint8List.fromList([
|
Uint8List _hex(String hex) => Uint8List.fromList([
|
||||||
for (var i = 0; i < hex.length; i += 2)
|
for (var i = 0; i < hex.length; i += 2)
|
||||||
int.parse(hex.substring(i, i + 2), radix: 16),
|
int.parse(hex.substring(i, i + 2), radix: 16),
|
||||||
|
|
@ -147,5 +149,29 @@ void main() {
|
||||||
)).extractPublicKey()).bytes,
|
)).extractPublicKey()).bytes,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'decoy X25519 keys from a seed are points of the prime-order '
|
||||||
|
"subgroup, as devices' keys are, and random bytes mostly aren't",
|
||||||
|
() async {
|
||||||
|
for (var i = 0; i < 20; i++) {
|
||||||
|
final seed = List.generate(32, (j) => (i * 31 + j * 17) % 256);
|
||||||
|
final decoy = await x25519PublicKeyFromSeed(seed);
|
||||||
|
expect(await x25519PublicKeyFromSeed(seed), decoy);
|
||||||
|
expect(isX25519PublicKey(decoy), isTrue);
|
||||||
|
final made = await (await X25519().newKeyPair()).extractPublicKey();
|
||||||
|
expect(isX25519PublicKey(made.bytes), isTrue);
|
||||||
|
}
|
||||||
|
// The check itself: of byte strings that aren't keys, few pass.
|
||||||
|
var passing = 0;
|
||||||
|
for (var i = 0; i < 64; i++) {
|
||||||
|
final bytes = List.generate(32, (j) => (i * 131 + j * 29 + 7) % 256);
|
||||||
|
if (isX25519PublicKey(bytes)) {
|
||||||
|
passing++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(passing, lessThan(16));
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue