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
4 changed files with 1369 additions and 27 deletions
|
|
@ -2,7 +2,7 @@
|
|||
/// (`HalkyonBase.configure`), how people authenticate, the account's
|
||||
/// deletion and the pruning, email sign-in's helpers, the instance's
|
||||
/// basics, people's public profiles and their pages, the instance's register
|
||||
/// (ADR 0016), and the module's models.
|
||||
/// (ADR 0016), SRP's register side (ADR 0017), and the module's models.
|
||||
library;
|
||||
|
||||
export 'src/auth/account.dart';
|
||||
|
|
@ -37,3 +37,13 @@ export 'src/register/deletion_notices.dart'
|
|||
export 'src/register/instance_routes.dart';
|
||||
export 'src/register/member_pruning.dart';
|
||||
export 'src/register/people.dart' show issueSession;
|
||||
export 'src/srp/srp_group.dart';
|
||||
export 'src/srp/srp_server.dart'
|
||||
show
|
||||
SrpAccepted,
|
||||
SrpChallenge,
|
||||
SrpRefusal,
|
||||
SrpRefused,
|
||||
SrpVerification,
|
||||
srpChallenge,
|
||||
srpVerify;
|
||||
|
|
|
|||
59
packages/halkyon_base_server/lib/src/srp/srp_group.dart
Normal file
59
packages/halkyon_base_server/lib/src/srp/srp_group.dart
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// A group SRP-6a computes in: a safe prime [n], a generator [g] of it, and
|
||||
/// the [hash] every step uses. The same groups as halkyon_base_client's
|
||||
/// (`lib/src/srp/srp_group.dart`), which the interoperability test holds
|
||||
/// them to.
|
||||
final class SrpGroup {
|
||||
SrpGroup({required this.n, required this.g, required this.hash});
|
||||
|
||||
/// RFC 5054's 3072-bit group (generator 5) with SHA-256: what Halkyon signs
|
||||
/// in with.
|
||||
static final rfc5054Group3072 = SrpGroup(
|
||||
n: BigInt.parse(_n3072, radix: 16),
|
||||
g: BigInt.from(5),
|
||||
hash: sha256,
|
||||
);
|
||||
|
||||
/// RFC 5054's 1024-bit group (generator 2) with SHA-1: only for the test
|
||||
/// vectors of RFC 5054's Appendix B, never to sign in with.
|
||||
static final rfc5054TestVectorGroup = SrpGroup(
|
||||
n: BigInt.parse(_n1024, radix: 16),
|
||||
g: BigInt.two,
|
||||
hash: sha1,
|
||||
);
|
||||
|
||||
/// The safe prime N.
|
||||
final BigInt n;
|
||||
|
||||
/// The generator g.
|
||||
final BigInt g;
|
||||
|
||||
/// H, SHA-256 in production.
|
||||
final Hash hash;
|
||||
|
||||
/// How many bytes N takes: what PAD() fills to.
|
||||
int get length => (n.bitLength + 7) ~/ 8;
|
||||
}
|
||||
|
||||
/// RFC 5054, Appendix A, the 3072-bit group's N.
|
||||
const _n3072 =
|
||||
'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74'
|
||||
'020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437'
|
||||
'4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED'
|
||||
'EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05'
|
||||
'98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB'
|
||||
'9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B'
|
||||
'E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718'
|
||||
'3995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33'
|
||||
'A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7'
|
||||
'ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864'
|
||||
'D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E2'
|
||||
'08E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF';
|
||||
|
||||
/// RFC 5054, Appendix A, the 1024-bit group's N.
|
||||
const _n1024 =
|
||||
'EEAF0AB9ADB38DD69C33F80AFA8FC5E86072618775FF3C0B9EA2314C9C256576'
|
||||
'D674DF7496EA81D3383B4813D692C6E0E0D5D8E250B98BE48E495C1D6089DAD1'
|
||||
'5DC7D7B46154D6B6CE8EF4AD69B15D4982559B297BCF1885C529F566660E57EC'
|
||||
'68EDBC3C05726CC02FD4CBF4976EAA9AFD5138FE8376435B9FC61D2FC0EB06E3';
|
||||
129
packages/halkyon_base_server/lib/src/srp/srp_math.dart
Normal file
129
packages/halkyon_base_server/lib/src/srp/srp_math.dart
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/src/srp/srp_group.dart';
|
||||
|
||||
/// The arithmetic both sides of SRP-6a share, as the client's half has it
|
||||
/// too (halkyon_base_client's `srp_math.dart`, which must compute alike: an
|
||||
/// interoperability test here holds them to each other).
|
||||
///
|
||||
/// Byte strings are unsigned and big-endian; PAD(x) is x filled with leading
|
||||
/// zeros to the length of N.
|
||||
|
||||
/// [value] as the fewest unsigned big-endian bytes (one zero byte for zero).
|
||||
Uint8List bytesOf(BigInt value) {
|
||||
if (value.isNegative) {
|
||||
throw ArgumentError.value(value, 'value', 'must not be negative');
|
||||
}
|
||||
var hex = value.toRadixString(16);
|
||||
if (hex.length.isOdd) {
|
||||
hex = '0$hex';
|
||||
}
|
||||
return Uint8List.fromList([
|
||||
for (var i = 0; i < hex.length; i += 2)
|
||||
int.parse(hex.substring(i, i + 2), radix: 16),
|
||||
]);
|
||||
}
|
||||
|
||||
/// PAD([value]): its bytes filled with leading zeros to [length].
|
||||
Uint8List padded(BigInt value, int length) {
|
||||
final bytes = bytesOf(value);
|
||||
if (bytes.length > length) {
|
||||
throw ArgumentError.value(value, 'value', 'is longer than $length bytes');
|
||||
}
|
||||
return Uint8List(length)..setRange(length - bytes.length, length, bytes);
|
||||
}
|
||||
|
||||
/// The unsigned big-endian number [bytes] stand for.
|
||||
BigInt numberOf(List<int> bytes) {
|
||||
var value = BigInt.zero;
|
||||
for (final byte in bytes) {
|
||||
value = (value << 8) | BigInt.from(byte);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// H over [parts], one after the other.
|
||||
Uint8List hashOf(SrpGroup group, Iterable<List<int>> parts) =>
|
||||
Uint8List.fromList(
|
||||
group.hash.convert([for (final part in parts) ...part]).bytes,
|
||||
);
|
||||
|
||||
/// k = H(N | PAD(g)).
|
||||
BigInt multiplierOf(SrpGroup group) =>
|
||||
numberOf(hashOf(group, [bytesOf(group.n), padded(group.g, group.length)]));
|
||||
|
||||
/// u = H(PAD(A) | PAD(B)).
|
||||
BigInt scramblerOf(SrpGroup group, BigInt clientValue, BigInt serverValue) =>
|
||||
numberOf(
|
||||
hashOf(group, [
|
||||
padded(clientValue, group.length),
|
||||
padded(serverValue, group.length),
|
||||
]),
|
||||
);
|
||||
|
||||
/// K = H(PAD(S)), the key both sides end up with.
|
||||
Uint8List sessionKeyOf(SrpGroup group, BigInt premasterSecret) =>
|
||||
hashOf(group, [padded(premasterSecret, group.length)]);
|
||||
|
||||
/// M1 = H(H(N) xor H(g) | H(I) | s | PAD(A) | PAD(B) | K): the client's
|
||||
/// proof that it knows the password.
|
||||
Uint8List clientProofOf(
|
||||
SrpGroup group, {
|
||||
required List<int> identity,
|
||||
required List<int> salt,
|
||||
required BigInt clientValue,
|
||||
required BigInt serverValue,
|
||||
required List<int> sessionKey,
|
||||
}) {
|
||||
final hashOfN = hashOf(group, [bytesOf(group.n)]);
|
||||
final hashOfG = hashOf(group, [bytesOf(group.g)]);
|
||||
return hashOf(group, [
|
||||
[for (var i = 0; i < hashOfN.length; i++) hashOfN[i] ^ hashOfG[i]],
|
||||
hashOf(group, [identity]),
|
||||
salt,
|
||||
padded(clientValue, group.length),
|
||||
padded(serverValue, group.length),
|
||||
sessionKey,
|
||||
]);
|
||||
}
|
||||
|
||||
/// M2 = H(PAD(A) | M1 | K): the server's proof that it holds the verifier.
|
||||
Uint8List serverProofOf(
|
||||
SrpGroup group, {
|
||||
required BigInt clientValue,
|
||||
required List<int> clientProof,
|
||||
required List<int> sessionKey,
|
||||
}) => hashOf(group, [
|
||||
padded(clientValue, group.length),
|
||||
clientProof,
|
||||
sessionKey,
|
||||
]);
|
||||
|
||||
/// Whether a public value lies in 1 … N − 1: zero (mod N) would let the
|
||||
/// other side's key be known without the password.
|
||||
bool isPublicValue(SrpGroup group, BigInt value) =>
|
||||
value > BigInt.zero && value < group.n;
|
||||
|
||||
/// Whether [a] and [b] are equal, in a time that depends only on their
|
||||
/// lengths, which are public.
|
||||
bool constantTimeEquals(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) {
|
||||
return false;
|
||||
}
|
||||
var difference = 0;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
difference |= a[i] ^ b[i];
|
||||
}
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
/// A fresh secret exponent of 256 bits from [random], never zero.
|
||||
BigInt randomExponent(Random random) {
|
||||
while (true) {
|
||||
final value = numberOf([for (var i = 0; i < 32; i++) random.nextInt(256)]);
|
||||
if (value != BigInt.zero) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
155
packages/halkyon_base_server/lib/src/srp/srp_server.dart
Normal file
155
packages/halkyon_base_server/lib/src/srp/srp_server.dart
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/src/srp/srp_group.dart';
|
||||
import 'package:halkyon_base_server/src/srp/srp_math.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
/// SRP-6a's register side (ADR 0017), in two steps a sign-in's two requests
|
||||
/// take: [srpChallenge] answers the client's A with B, keeping the secret b
|
||||
/// until [srpVerify] checks the client's proof M1 and answers with M2. The
|
||||
/// register keeps only the verifier v = g^x mod N the device made at sign-up:
|
||||
/// it never sees the password, nor anything that would sign in in its place.
|
||||
|
||||
/// B for the client, and the secret b that made it, which the register keeps
|
||||
/// (for this one sign-in, briefly) and never sends.
|
||||
final class SrpChallenge {
|
||||
SrpChallenge._(this.secret, this.publicValue);
|
||||
|
||||
/// b.
|
||||
final BigInt secret;
|
||||
|
||||
/// B = (k·v + g^b) mod N, sent to the client.
|
||||
final BigInt publicValue;
|
||||
}
|
||||
|
||||
/// The first step: B for the account whose [verifier] the register keeps,
|
||||
/// from a fresh secret b of 256 bits from [random] (the system's secure one
|
||||
/// by default).
|
||||
SrpChallenge srpChallenge(SrpGroup group, BigInt verifier, {Random? random}) {
|
||||
final source = random ?? Random.secure();
|
||||
while (true) {
|
||||
final challenge = srpChallengeWithSecret(
|
||||
group,
|
||||
verifier,
|
||||
randomExponent(source),
|
||||
);
|
||||
// B ≡ 0 (mod N) would make the key known; its chance is nil, its check
|
||||
// free.
|
||||
if (isPublicValue(group, challenge.publicValue)) {
|
||||
return challenge;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first step with the secret [secret] given, only for RFC 5054's test
|
||||
/// vectors.
|
||||
@visibleForTesting
|
||||
SrpChallenge srpChallengeWithSecret(
|
||||
SrpGroup group,
|
||||
BigInt verifier,
|
||||
BigInt secret,
|
||||
) => SrpChallenge._(
|
||||
secret,
|
||||
(multiplierOf(group) * verifier + group.g.modPow(secret, group.n)) % group.n,
|
||||
);
|
||||
|
||||
/// The second step: whether the client's [clientProof] M1, made with its A
|
||||
/// ([clientValue]) against this [challenge], shows it knows the password of
|
||||
/// the account [identity] (whose [salt] and [verifier] the register keeps).
|
||||
/// Accepted, it gives M2 for the client and the key K both now share.
|
||||
SrpVerification srpVerify(
|
||||
SrpGroup group, {
|
||||
required List<int> identity,
|
||||
required List<int> salt,
|
||||
required BigInt verifier,
|
||||
required SrpChallenge challenge,
|
||||
required BigInt clientValue,
|
||||
required List<int> clientProof,
|
||||
}) {
|
||||
if (!isPublicValue(group, clientValue)) {
|
||||
return const SrpRefused(SrpRefusal.invalidClientValue);
|
||||
}
|
||||
final scrambler = scramblerOf(group, clientValue, challenge.publicValue);
|
||||
if (scrambler == BigInt.zero) {
|
||||
return const SrpRefused(SrpRefusal.invalidClientValue);
|
||||
}
|
||||
final sessionKey = sessionKeyOf(
|
||||
group,
|
||||
srpServerPremasterSecret(
|
||||
group,
|
||||
verifier: verifier,
|
||||
challenge: challenge,
|
||||
clientValue: clientValue,
|
||||
scrambler: scrambler,
|
||||
),
|
||||
);
|
||||
final expected = clientProofOf(
|
||||
group,
|
||||
identity: identity,
|
||||
salt: salt,
|
||||
clientValue: clientValue,
|
||||
serverValue: challenge.publicValue,
|
||||
sessionKey: sessionKey,
|
||||
);
|
||||
if (!constantTimeEquals(clientProof, expected)) {
|
||||
return const SrpRefused(SrpRefusal.wrongProof);
|
||||
}
|
||||
return SrpAccepted._(
|
||||
serverProofOf(
|
||||
group,
|
||||
clientValue: clientValue,
|
||||
clientProof: expected,
|
||||
sessionKey: sessionKey,
|
||||
),
|
||||
sessionKey,
|
||||
);
|
||||
}
|
||||
|
||||
/// S = (A·v^u)^b mod N, exposed for RFC 5054's vectors.
|
||||
@visibleForTesting
|
||||
BigInt srpServerPremasterSecret(
|
||||
SrpGroup group, {
|
||||
required BigInt verifier,
|
||||
required SrpChallenge challenge,
|
||||
required BigInt clientValue,
|
||||
required BigInt scrambler,
|
||||
}) => (clientValue * verifier.modPow(scrambler, group.n) % group.n).modPow(
|
||||
challenge.secret,
|
||||
group.n,
|
||||
);
|
||||
|
||||
/// What the register makes of a client's proof.
|
||||
sealed class SrpVerification {
|
||||
const SrpVerification();
|
||||
}
|
||||
|
||||
/// The proof showed the password: M2 for the client, K for both.
|
||||
final class SrpAccepted extends SrpVerification {
|
||||
SrpAccepted._(this.serverProof, this.sessionKey);
|
||||
|
||||
/// M2, sent to the client, which checks it.
|
||||
final Uint8List serverProof;
|
||||
|
||||
/// K, the key both sides now share; never sent.
|
||||
final Uint8List sessionKey;
|
||||
}
|
||||
|
||||
/// The proof didn't show it.
|
||||
final class SrpRefused extends SrpVerification {
|
||||
const SrpRefused(this.reason);
|
||||
|
||||
/// Why.
|
||||
final SrpRefusal reason;
|
||||
}
|
||||
|
||||
/// Why the register refused a client's proof.
|
||||
enum SrpRefusal {
|
||||
/// A is zero modulo N, or out of range, or makes u zero: accepting it
|
||||
/// would let anyone sign in.
|
||||
invalidClientValue,
|
||||
|
||||
/// M1 isn't the one the password would make: a wrong password, or a value
|
||||
/// tampered with on the way.
|
||||
wrongProof,
|
||||
}
|
||||
Loading…
Reference in a new issue