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:
Tizian 「ティツィアーン」 2026-10-20 12:01:52 +02:00
commit bc499713b8
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 1369 additions and 27 deletions

View file

@ -1,2 +1,3 @@
export 'src/protocol/protocol.dart';
export 'package:serverpod_client/serverpod_client.dart';
export 'src/protocol/protocol.dart';

View file

@ -0,0 +1,142 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:halkyon_base_client/src/srp/srp_group.dart';
import 'package:halkyon_base_client/src/srp/srp_math.dart';
import 'package:meta/meta.dart';
/// The verifier a register keeps for a password, v = g^x mod N, made on the
/// device at sign-up from [privateValue] (x), which only the device can make
/// (halkyon_keys derives it from the password with Argon2id). The verifier
/// can't sign in in the password's place; guessing the password from it
/// costs an Argon2id per guess.
BigInt srpVerifier(SrpGroup group, BigInt privateValue) =>
group.g.modPow(privateValue, group.n);
/// RFC 5054's x = H(s | H(I | ":" | P)), only to check this implementation
/// against RFC 5054's test vectors. Halkyon never hashes a password so: its
/// x comes from Argon2id (halkyon_keys), and no password reaches this
/// package.
@visibleForTesting
BigInt rfc5054PrivateValue(
SrpGroup group, {
required List<int> salt,
required String identity,
required String password,
}) => numberOf(
hashOf(group, [
salt,
hashOf(group, [utf8.encode('$identity:$password')]),
]),
);
/// SRP-6a's client side for one sign-in: it makes A ([publicValue]) from a
/// fresh secret a, then, given the register's salt and B, proves that it
/// knows the password (M1) without sending it, and checks the register's
/// proof (M2) in turn. A sign-in that fails starts anew with a new client.
final class SrpClient._(
/// The group it computes in.
final SrpGroup group,
final BigInt _secret,
) {
/// A client with a fresh secret a of 256 bits from [random] (the system's
/// secure one by default).
factory(SrpGroup group, {Random? random}) =>
SrpClient._(group, randomExponent(random ?? Random.secure()));
/// A client with the secret [a] given, only for RFC 5054's test vectors.
@visibleForTesting
factory withSecret(SrpGroup group, BigInt a) => SrpClient._(group, a);
/// A = g^a mod N, sent to the register first.
late final BigInt publicValue = group.g.modPow(_secret, group.n);
/// Answers the register's [serverValue] (B) for the account [identity]
/// with the [salt] it gave, from the [privateValue] x the device derived
/// from the password: the proof M1 to send, or the refusal of a B that
/// would give the password away.
SrpClientStep respond({
required List<int> identity,
required List<int> salt,
required BigInt privateValue,
required BigInt serverValue,
}) {
if (!isPublicValue(group, serverValue)) {
return const SrpClientRefused(SrpClientRefusal.invalidServerValue);
}
final scrambler = scramblerOf(group, publicValue, serverValue);
if (scrambler == BigInt.zero) {
return const SrpClientRefused(SrpClientRefusal.invalidServerValue);
}
final premasterSecret = premasterSecretOf(
privateValue: privateValue,
serverValue: serverValue,
scrambler: scrambler,
);
final sessionKey = sessionKeyOf(group, premasterSecret);
final proof = clientProofOf(
group,
identity: identity,
salt: salt,
clientValue: publicValue,
serverValue: serverValue,
sessionKey: sessionKey,
);
return SrpClientProof._(
proof,
sessionKey,
serverProofOf(
group,
clientValue: publicValue,
clientProof: proof,
sessionKey: sessionKey,
),
);
}
/// S = (B − k·g^x)^(a + u·x) mod N, exposed for RFC 5054's vectors.
@visibleForTesting
BigInt premasterSecretOf({
required BigInt privateValue,
required BigInt serverValue,
required BigInt scrambler,
}) {
final base =
(serverValue -
multiplierOf(group) * group.g.modPow(privateValue, group.n)) %
group.n;
return base.modPow(_secret + scrambler * privateValue, group.n);
}
}
/// What the client makes of the register's B.
sealed class const SrpClientStep();
/// The proof M1 to send, and what to expect back.
final class SrpClientProof._(
/// M1, sent to the register.
final Uint8List proof,
/// K, the key both sides now share; never sent.
final Uint8List sessionKey,
final Uint8List _expectedServerProof,
) extends SrpClientStep {
/// Whether the register's [serverProof] (M2) shows that it holds the
/// verifier: only then is the register the one the account was made with.
bool verifiesServer(List<int> serverProof) =>
constantTimeEquals(serverProof, _expectedServerProof);
}
/// A B the client refuses to answer.
final class const SrpClientRefused(
/// Why.
final SrpClientRefusal reason,
) extends SrpClientStep;
/// Why the client refused the register's B.
enum SrpClientRefusal() {
/// B is zero modulo N, or out of range, or makes u zero: answering would
/// let the proof be made without the password.
invalidServerValue,
}

View file

@ -0,0 +1,55 @@
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.
final class SrpGroup({
/// The safe prime N.
required final BigInt n,
/// The generator g.
required final BigInt g,
/// H, SHA-256 in production.
required final Hash 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,
);
/// 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';

View file

@ -0,0 +1,129 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:halkyon_base_client/src/srp/srp_group.dart';
/// The arithmetic both sides of SRP-6a share, as the server's half has it
/// too (halkyon_base_server's `srp_math.dart`, which must compute alike: an
/// interoperability test there 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;
}
}
}

View file

@ -0,0 +1,16 @@
/// SRP-6a's client side (ADR 0017): the device proves it knows the password
/// without sending it, and checks that the register holds the verifier made
/// at sign-up. Hand-written beside the generated client: Serverpod's
/// generator only ever writes and cleans `lib/src/protocol/`.
library;
export 'src/srp/srp_client.dart'
show
SrpClient,
SrpClientProof,
SrpClientRefusal,
SrpClientRefused,
SrpClientStep,
rfc5054PrivateValue,
srpVerifier;
export 'src/srp/srp_group.dart';