feat(crypto): SRP-6a, the register's half and the device's
ADR 0017's step 1. Generic over group and hash (SrpGroup); production takes RFC 5054's 3072-bit group with SHA-256. k = H(N | PAD(g)), u = H(PAD(A) | PAD(B)), K = H(PAD(S)), M1 = H(H(N) xor H(g) | H(I) | s | PAD(A) | PAD(B) | K), M2 = H(PAD(A) | M1 | K). A and B must lie in 1..N-1 and u must not be 0; a and b are 256 bits from Random.secure(); proofs are compared in constant time. x comes from the caller (halkyon_keys makes it); RFC 5054's own x is there for the vectors only. - The register's half in halkyon_base_server (base.dart: srpChallenge, srpVerify), the device's written by hand in halkyon_base_client (srp.dart: SrpClient, srpVerifier). Serverpod's generator cleans and writes only a client's lib/src/protocol/ (serverpod_cli 4.0.4's _getDirectoriesRequiringCleaning; a generate leaves srp/ untouched). - Tests: RFC 5054 Appendix B on both halves; the client against the register, succeeding and every refusal. - The module's tests depend on its client, and pub resolves every member's dev dependencies, so Learn's server image takes the module's client into its workspace too. - The format check covers the hand-written half. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
ae0d88818b
commit
bc499713b8
2 changed files with 1369 additions and 27 deletions
287
packages/halkyon_base_server/test/unit/srp/srp_interop_test.dart
Normal file
287
packages/halkyon_base_server/test/unit/srp/srp_interop_test.dart
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:halkyon_base_client/src/srp/srp_math.dart' as client_math;
|
||||
import 'package:halkyon_base_client/srp.dart' as client;
|
||||
import 'package:halkyon_base_server/src/srp/srp_group.dart';
|
||||
import 'package:halkyon_base_server/src/srp/srp_math.dart';
|
||||
import 'package:halkyon_base_server/src/srp/srp_server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// The register's half (this package) against the device's
|
||||
/// (halkyon_base_client) in the group Halkyon signs in with: both compute
|
||||
/// alike, a right password signs in, and nothing else does.
|
||||
void main() {
|
||||
final production = SrpGroup.rfc5054Group3072;
|
||||
final clientGroup = client.SrpGroup.rfc5054Group3072;
|
||||
final random = Random(20261020);
|
||||
final identity = utf8.encode('3f2a9c1e-account');
|
||||
final salt = List.generate(16, (_) => random.nextInt(256));
|
||||
// x as halkyon_keys makes it: 256 bits from the password's Argon2id.
|
||||
final privateValue = client_math.numberOf(
|
||||
List.generate(32, (_) => random.nextInt(256)),
|
||||
);
|
||||
final verifier = client.srpVerifier(clientGroup, privateValue);
|
||||
|
||||
/// One sign-in up to the client's proof: the client, the register's
|
||||
/// challenge, and what the client answered.
|
||||
({
|
||||
client.SrpClient device,
|
||||
SrpChallenge challenge,
|
||||
client.SrpClientProof proof,
|
||||
})
|
||||
signIn({BigInt? withPrivateValue}) {
|
||||
final device = client.SrpClient(clientGroup);
|
||||
final challenge = srpChallenge(production, verifier);
|
||||
final step = device.respond(
|
||||
identity: identity,
|
||||
salt: salt,
|
||||
privateValue: withPrivateValue ?? privateValue,
|
||||
serverValue: challenge.publicValue,
|
||||
);
|
||||
return (
|
||||
device: device,
|
||||
challenge: challenge,
|
||||
proof: step as client.SrpClientProof,
|
||||
);
|
||||
}
|
||||
|
||||
SrpVerification verify(
|
||||
SrpChallenge challenge, {
|
||||
required BigInt clientValue,
|
||||
required List<int> clientProof,
|
||||
}) => srpVerify(
|
||||
production,
|
||||
identity: identity,
|
||||
salt: salt,
|
||||
verifier: verifier,
|
||||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
clientProof: clientProof,
|
||||
);
|
||||
|
||||
group('both halves compute alike', () {
|
||||
test('the same group: N, g and H', () {
|
||||
expect(production.n, clientGroup.n);
|
||||
expect(production.g, clientGroup.g);
|
||||
expect(production.hash, same(clientGroup.hash));
|
||||
expect(
|
||||
SrpGroup.rfc5054TestVectorGroup.n,
|
||||
client.SrpGroup.rfc5054TestVectorGroup.n,
|
||||
);
|
||||
});
|
||||
|
||||
test('k, u, K, M1 and M2 alike for the same values', () {
|
||||
final clientValue = client.SrpClient(clientGroup).publicValue;
|
||||
final serverValue = srpChallenge(production, verifier).publicValue;
|
||||
final secret = client_math.numberOf(
|
||||
List.generate(48, (_) => random.nextInt(256)),
|
||||
);
|
||||
expect(
|
||||
multiplierOf(production),
|
||||
client_math.multiplierOf(clientGroup),
|
||||
);
|
||||
expect(
|
||||
scramblerOf(production, clientValue, serverValue),
|
||||
client_math.scramblerOf(clientGroup, clientValue, serverValue),
|
||||
);
|
||||
final key = sessionKeyOf(production, secret);
|
||||
expect(key, client_math.sessionKeyOf(clientGroup, secret));
|
||||
final m1 = clientProofOf(
|
||||
production,
|
||||
identity: identity,
|
||||
salt: salt,
|
||||
clientValue: clientValue,
|
||||
serverValue: serverValue,
|
||||
sessionKey: key,
|
||||
);
|
||||
expect(
|
||||
m1,
|
||||
client_math.clientProofOf(
|
||||
clientGroup,
|
||||
identity: identity,
|
||||
salt: salt,
|
||||
clientValue: clientValue,
|
||||
serverValue: serverValue,
|
||||
sessionKey: key,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
serverProofOf(
|
||||
production,
|
||||
clientValue: clientValue,
|
||||
clientProof: m1,
|
||||
sessionKey: key,
|
||||
),
|
||||
client_math.serverProofOf(
|
||||
clientGroup,
|
||||
clientValue: clientValue,
|
||||
clientProof: m1,
|
||||
sessionKey: key,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('the right password signs in: the register accepts M1, the client '
|
||||
'M2, and both share K', () {
|
||||
final (:device, :challenge, :proof) = signIn();
|
||||
final verification = verify(
|
||||
challenge,
|
||||
clientValue: device.publicValue,
|
||||
clientProof: proof.proof,
|
||||
);
|
||||
expect(verification, isA<SrpAccepted>());
|
||||
final accepted = verification as SrpAccepted;
|
||||
expect(proof.verifiesServer(accepted.serverProof), isTrue);
|
||||
expect(accepted.sessionKey, proof.sessionKey);
|
||||
// SHA-256: 32 bytes each.
|
||||
expect(proof.proof, hasLength(32));
|
||||
expect(accepted.serverProof, hasLength(32));
|
||||
});
|
||||
|
||||
test('every sign-in has new secrets: another A, B and K', () {
|
||||
final first = signIn();
|
||||
final second = signIn();
|
||||
expect(first.device.publicValue, isNot(second.device.publicValue));
|
||||
expect(
|
||||
first.challenge.publicValue,
|
||||
isNot(second.challenge.publicValue),
|
||||
);
|
||||
expect(first.proof.sessionKey, isNot(second.proof.sessionKey));
|
||||
});
|
||||
|
||||
group('refused', () {
|
||||
test('a wrong password', () {
|
||||
final (:device, :challenge, :proof) = signIn(
|
||||
withPrivateValue: privateValue + BigInt.one,
|
||||
);
|
||||
expect(
|
||||
verify(
|
||||
challenge,
|
||||
clientValue: device.publicValue,
|
||||
clientProof: proof.proof,
|
||||
),
|
||||
isA<SrpRefused>().having(
|
||||
(refused) => refused.reason,
|
||||
'reason',
|
||||
SrpRefusal.wrongProof,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('an A tampered with on the way', () {
|
||||
final (:device, :challenge, :proof) = signIn();
|
||||
expect(
|
||||
verify(
|
||||
challenge,
|
||||
clientValue: device.publicValue + BigInt.one,
|
||||
clientProof: proof.proof,
|
||||
),
|
||||
isA<SrpRefused>().having(
|
||||
(refused) => refused.reason,
|
||||
'reason',
|
||||
SrpRefusal.wrongProof,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("a B tampered with on the way: the client's proof then fails", () {
|
||||
final device = client.SrpClient(clientGroup);
|
||||
final challenge = srpChallenge(production, verifier);
|
||||
final proof = device.respond(
|
||||
identity: identity,
|
||||
salt: salt,
|
||||
privateValue: privateValue,
|
||||
serverValue: challenge.publicValue + BigInt.one,
|
||||
) as client.SrpClientProof;
|
||||
expect(
|
||||
verify(
|
||||
challenge,
|
||||
clientValue: device.publicValue,
|
||||
clientProof: proof.proof,
|
||||
),
|
||||
isA<SrpRefused>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('an M1 tampered with', () {
|
||||
final (:device, :challenge, :proof) = signIn();
|
||||
final tampered = [...proof.proof]..[0] ^= 1;
|
||||
expect(
|
||||
verify(
|
||||
challenge,
|
||||
clientValue: device.publicValue,
|
||||
clientProof: tampered,
|
||||
),
|
||||
isA<SrpRefused>().having(
|
||||
(refused) => refused.reason,
|
||||
'reason',
|
||||
SrpRefusal.wrongProof,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
verify(
|
||||
challenge,
|
||||
clientValue: device.publicValue,
|
||||
clientProof: proof.proof.sublist(1),
|
||||
),
|
||||
isA<SrpRefused>(),
|
||||
);
|
||||
});
|
||||
|
||||
test("an M2 tampered with: the client doesn't trust the register", () {
|
||||
final (:device, :challenge, :proof) = signIn();
|
||||
final accepted = verify(
|
||||
challenge,
|
||||
clientValue: device.publicValue,
|
||||
clientProof: proof.proof,
|
||||
) as SrpAccepted;
|
||||
final tampered = [...accepted.serverProof]..[31] ^= 0x80;
|
||||
expect(proof.verifiesServer(tampered), isFalse);
|
||||
expect(proof.verifiesServer(accepted.serverProof.sublist(1)), isFalse);
|
||||
});
|
||||
|
||||
for (final (name, value) in [
|
||||
('zero', BigInt.zero),
|
||||
('N', production.n),
|
||||
('a multiple of N', production.n * BigInt.from(3)),
|
||||
('above N', production.n + BigInt.two),
|
||||
('negative', -BigInt.one),
|
||||
]) {
|
||||
test('an A that is $name (which, ≡ 0 mod N, would sign anyone in)', () {
|
||||
final (:device, :challenge, :proof) = signIn();
|
||||
expect(
|
||||
verify(challenge, clientValue: value, clientProof: proof.proof),
|
||||
isA<SrpRefused>().having(
|
||||
(refused) => refused.reason,
|
||||
'reason',
|
||||
SrpRefusal.invalidClientValue,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("an attacker's A ≡ 0 can't make a proof that passes: with S = 0 "
|
||||
"the proof it would make is refused before it's looked at", () {
|
||||
final challenge = srpChallenge(production, verifier);
|
||||
final forgedKey = sessionKeyOf(production, BigInt.zero);
|
||||
final forgedProof = clientProofOf(
|
||||
production,
|
||||
identity: identity,
|
||||
salt: salt,
|
||||
clientValue: BigInt.zero,
|
||||
serverValue: challenge.publicValue,
|
||||
sessionKey: forgedKey,
|
||||
);
|
||||
expect(
|
||||
verify(challenge, clientValue: production.n, clientProof: forgedProof),
|
||||
isA<SrpRefused>(),
|
||||
);
|
||||
expect(
|
||||
verify(challenge, clientValue: BigInt.zero, clientProof: forgedProof),
|
||||
isA<SrpRefused>(),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
118
packages/halkyon_base_server/test/unit/srp/srp_server_test.dart
Normal file
118
packages/halkyon_base_server/test/unit/srp/srp_server_test.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:halkyon_base_client/srp.dart' as client;
|
||||
import 'package:halkyon_base_server/src/srp/srp_group.dart';
|
||||
import 'package:halkyon_base_server/src/srp/srp_math.dart';
|
||||
import 'package:halkyon_base_server/src/srp/srp_server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// [hex] as RFC 5054 prints it (in groups, across lines) as a number.
|
||||
BigInt _hex(String hex) =>
|
||||
BigInt.parse(hex.replaceAll(RegExp(r'\s'), ''), radix: 16);
|
||||
|
||||
void main() {
|
||||
group("RFC 5054's test vectors (Appendix B) on the register's side", () {
|
||||
final group = SrpGroup.rfc5054TestVectorGroup;
|
||||
final verifier = _hex('''
|
||||
7E273DE8 696FFC4F 4E337D05 B4B375BE B0DDE156 9E8FA00A 9886D812
|
||||
9BADA1F1 822223CA 1A605B53 0E379BA4 729FDC59 F105B478 7E5186F5
|
||||
C671085A 1447B52A 48CF1970 B4FB6F84 00BBF4CE BFBB1681 52E08AB5
|
||||
EA53D15C 1AFF87B2 B9DA6E04 E058AD51 CC72BFC9 033B564E 26480D78
|
||||
E955A5E2 9E7AB245 DB2BE315 E2099AFB''');
|
||||
final challenge = srpChallengeWithSecret(
|
||||
group,
|
||||
verifier,
|
||||
_hex('''
|
||||
E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1
|
||||
05284D20'''),
|
||||
);
|
||||
final clientValue = _hex('''
|
||||
61D5E490 F6F1B795 47B0704C 436F523D D0E560F0 C64115BB 72557EC4
|
||||
4352E890 3211C046 92272D8B 2D1A5358 A2CF1B6E 0BFCF99F 921530EC
|
||||
8E393561 79EAE45E 42BA92AE ACED8251 71E1E8B9 AF6D9C03 E1327F44
|
||||
BE087EF0 6530E69F 66615261 EEF54073 CA11CF58 58F0EDFD FE15EFEA
|
||||
B349EF5D 76988A36 72FAC47B 0769447B''');
|
||||
|
||||
test('k = H(N | PAD(g))', () {
|
||||
expect(
|
||||
multiplierOf(group),
|
||||
_hex('7556AA04 5AEF2CDD 07ABAF0F 665C3E81 8913186F'),
|
||||
);
|
||||
});
|
||||
|
||||
test('B = (k·v + g^b) mod N', () {
|
||||
expect(
|
||||
challenge.publicValue,
|
||||
_hex('''
|
||||
BD0C6151 2C692C0C B6D041FA 01BB152D 4916A1E7 7AF46AE1 05393011
|
||||
BAF38964 DC46A067 0DD125B9 5A981652 236F99D9 B681CBF8 7837EC99
|
||||
6C6DA044 53728610 D0C6DDB5 8B318885 D7D82C7F 8DEB75CE 7BD4FBAA
|
||||
37089E6F 9C6059F3 88838E7A 00030B33 1EB76840 910440B1 B27AAEAE
|
||||
EB4012B7 D7665238 A8E3FB00 4B117B58'''),
|
||||
);
|
||||
});
|
||||
|
||||
test('u = H(PAD(A) | PAD(B))', () {
|
||||
expect(
|
||||
scramblerOf(group, clientValue, challenge.publicValue),
|
||||
_hex('CE38B959 3487DA98 554ED47D 70A7AE5F 462EF019'),
|
||||
);
|
||||
});
|
||||
|
||||
test('the premaster secret S = (A·v^u)^b mod N', () {
|
||||
expect(
|
||||
srpServerPremasterSecret(
|
||||
group,
|
||||
verifier: verifier,
|
||||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
scrambler: scramblerOf(group, clientValue, challenge.publicValue),
|
||||
),
|
||||
_hex('''
|
||||
B0DC82BA BCF30674 AE450C02 87745E79 90A3381F 63B387AA F271A10D
|
||||
233861E3 59B48220 F7C4693C 9AE12B0A 6F67809F 0876E2D0 13800D6C
|
||||
41BB59B6 D5979B5C 00A172B4 A2A5903A 0BDCAF8A 709585EB 2AFAFA8F
|
||||
3499B200 210DCC1F 10EB3394 3CD67FC8 8A2F39A4 BE5BEC4E C0A3212D
|
||||
C346D7E4 74B29EDE 8A469FFE CA686E5A'''),
|
||||
);
|
||||
});
|
||||
|
||||
test("the client's half with RFC 5054's secrets is accepted, and its "
|
||||
"proof of the register's accepted in turn", () {
|
||||
final salt = bytesOf(_hex('BEB25379 D1A8581E B5A72767 3A2441EE'));
|
||||
final identity = utf8.encode('alice');
|
||||
final clientGroup = client.SrpGroup.rfc5054TestVectorGroup;
|
||||
final device = client.SrpClient.withSecret(
|
||||
clientGroup,
|
||||
_hex('''
|
||||
60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD
|
||||
DA2D4393'''),
|
||||
);
|
||||
expect(device.publicValue, clientValue);
|
||||
final proof = device.respond(
|
||||
identity: identity,
|
||||
salt: salt,
|
||||
privateValue: client.rfc5054PrivateValue(
|
||||
clientGroup,
|
||||
salt: salt,
|
||||
identity: 'alice',
|
||||
password: 'password123',
|
||||
),
|
||||
serverValue: challenge.publicValue,
|
||||
) as client.SrpClientProof;
|
||||
final verification = srpVerify(
|
||||
group,
|
||||
identity: identity,
|
||||
salt: salt,
|
||||
verifier: verifier,
|
||||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
clientProof: proof.proof,
|
||||
);
|
||||
expect(verification, isA<SrpAccepted>());
|
||||
final accepted = verification as SrpAccepted;
|
||||
expect(proof.verifiesServer(accepted.serverProof), isTrue);
|
||||
expect(accepted.sessionKey, proof.sessionKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue