435 lines
14 KiB
Dart
435 lines
14 KiB
Dart
// Smol Mail primitives (SPEC.md §1): SHA-2, HMAC/HKDF-SHA256, ChaCha20-Poly1305,
|
|
// X25519, Ed25519, and the §2 key conversions between the two curves. Pure
|
|
// Dart rather than PointyCastle so the byte-exact vectors from the reference
|
|
// client (test/vectors.json) can pin every operation.
|
|
|
|
import "dart:convert";
|
|
import "dart:math";
|
|
import "dart:typed_data";
|
|
|
|
import "package:crypto/crypto.dart" as hashes;
|
|
|
|
import "package:smol_mail/smol/errors.dart";
|
|
|
|
// --- bytes --------------------------------------------------------------------
|
|
|
|
Uint8List concat(List<List<int>> parts) {
|
|
final out = Uint8List(parts.fold(0, (n, p) => n + p.length));
|
|
var off = 0;
|
|
for (final p in parts) {
|
|
out.setRange(off, off + p.length, p);
|
|
off += p.length;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Uint8List utf8Bytes(String text) => Uint8List.fromList(utf8.encode(text));
|
|
|
|
String hex(List<int> bytes) =>
|
|
bytes.map((b) => b.toRadixString(16).padLeft(2, "0")).join();
|
|
|
|
Uint8List unhex(String text) {
|
|
if (text.length.isOdd) throw ArgumentError("odd-length hex string: $text");
|
|
final out = Uint8List(text.length ~/ 2);
|
|
for (var i = 0; i < out.length; i++) {
|
|
out[i] = int.parse(text.substring(i * 2, i * 2 + 2), radix: 16);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
BigInt leBytesToBigInt(Uint8List bytes) {
|
|
var n = BigInt.zero;
|
|
for (var i = bytes.length - 1; i >= 0; i--) {
|
|
n = (n << 8) | BigInt.from(bytes[i]);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
Uint8List bigIntToLeBytes(BigInt value, int length) {
|
|
final out = Uint8List(length);
|
|
var v = value;
|
|
for (var i = 0; i < length; i++) {
|
|
out[i] = (v & BigInt.from(0xff)).toInt();
|
|
v >>= 8;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Uint8List randomBytes(int n) {
|
|
final out = Uint8List(n);
|
|
final rng = Random.secure();
|
|
for (var i = 0; i < n; i++) {
|
|
out[i] = rng.nextInt(256);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
bool timingSafeEqual(List<int> a, List<int> b) {
|
|
if (a.length != b.length) return false;
|
|
var diff = 0;
|
|
for (var i = 0; i < a.length; i++) {
|
|
diff |= a[i] ^ b[i];
|
|
}
|
|
return diff == 0;
|
|
}
|
|
|
|
// --- SHA-256 / SHA-512 / HMAC-SHA256 / HKDF (RFC 2104, RFC 5869) ---------------
|
|
|
|
Uint8List sha256(List<int> message) =>
|
|
Uint8List.fromList(hashes.sha256.convert(message).bytes);
|
|
|
|
Uint8List sha512(List<int> message) =>
|
|
Uint8List.fromList(hashes.sha512.convert(message).bytes);
|
|
|
|
Uint8List hmacSha256(List<int> key, List<int> message) =>
|
|
Uint8List.fromList(hashes.Hmac(hashes.sha256, key).convert(message).bytes);
|
|
|
|
Uint8List hkdfSha256(List<int> ikm, List<int> salt, List<int> info,
|
|
[int length = 32]) {
|
|
final prk = hmacSha256(salt, ikm);
|
|
var out = <int>[];
|
|
var block = <int>[];
|
|
var counter = 1;
|
|
while (out.length < length) {
|
|
block = hmacSha256(prk, concat([block, info, [counter]]));
|
|
out.addAll(block);
|
|
counter++;
|
|
}
|
|
return Uint8List.fromList(out.sublist(0, length));
|
|
}
|
|
|
|
// --- ChaCha20-Poly1305 AEAD (RFC 8439) -----------------------------------------
|
|
|
|
const _mask32 = 0xFFFFFFFF;
|
|
|
|
int _rotl32(int x, int n) => ((x << n) | (x >>> (32 - n))) & _mask32;
|
|
|
|
Uint8List _chachaBlock(Uint8List key, int counter, Uint8List nonce) {
|
|
final state = Uint32List(16);
|
|
state.setAll(0, [0x61707865, 0x3320646e, 0x79622d32, 0x6b206574]);
|
|
final kview = ByteData.view(key.buffer, key.offsetInBytes, key.length);
|
|
for (var i = 0; i < 8; i++) {
|
|
state[4 + i] = kview.getUint32(i * 4, Endian.little);
|
|
}
|
|
state[12] = counter & _mask32;
|
|
final nview = ByteData.view(nonce.buffer, nonce.offsetInBytes, nonce.length);
|
|
for (var i = 0; i < 3; i++) {
|
|
state[13 + i] = nview.getUint32(i * 4, Endian.little);
|
|
}
|
|
final x = Uint32List.fromList(state);
|
|
void qr(int a, int b, int c, int d) {
|
|
x[a] = (x[a] + x[b]) & _mask32;
|
|
x[d] = _rotl32(x[d] ^ x[a], 16);
|
|
x[c] = (x[c] + x[d]) & _mask32;
|
|
x[b] = _rotl32(x[b] ^ x[c], 12);
|
|
x[a] = (x[a] + x[b]) & _mask32;
|
|
x[d] = _rotl32(x[d] ^ x[a], 8);
|
|
x[c] = (x[c] + x[d]) & _mask32;
|
|
x[b] = _rotl32(x[b] ^ x[c], 7);
|
|
}
|
|
|
|
for (var i = 0; i < 10; i++) {
|
|
qr(0, 4, 8, 12);
|
|
qr(1, 5, 9, 13);
|
|
qr(2, 6, 10, 14);
|
|
qr(3, 7, 11, 15);
|
|
qr(0, 5, 10, 15);
|
|
qr(1, 6, 11, 12);
|
|
qr(2, 7, 8, 13);
|
|
qr(3, 4, 9, 14);
|
|
}
|
|
final out = Uint8List(64);
|
|
final view = ByteData.view(out.buffer);
|
|
for (var i = 0; i < 16; i++) {
|
|
view.setUint32(i * 4, (x[i] + state[i]) & _mask32, Endian.little);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Uint8List _chacha20Xor(Uint8List key, int counter, Uint8List nonce, Uint8List data) {
|
|
final out = Uint8List(data.length);
|
|
for (var off = 0; off < data.length; off += 64) {
|
|
final stream = _chachaBlock(key, counter + off ~/ 64, nonce);
|
|
final n = min(64, data.length - off);
|
|
for (var i = 0; i < n; i++) {
|
|
out[off + i] = data[off + i] ^ stream[i];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Poly1305 over BigInt; correctness over speed, messages here stay small.
|
|
Uint8List _poly1305(Uint8List key, List<int> message) {
|
|
final p = (BigInt.one << 130) - BigInt.from(5);
|
|
final r = leBytesToBigInt(key.sublist(0, 16)) &
|
|
BigInt.parse("0x0ffffffc0ffffffc0ffffffc0fffffff");
|
|
final s = leBytesToBigInt(key.sublist(16, 32));
|
|
var acc = BigInt.zero;
|
|
for (var off = 0; off < message.length; off += 16) {
|
|
final block = message.sublist(off, min(off + 16, message.length));
|
|
acc = (acc + leBytesToBigInt(Uint8List.fromList(block)) +
|
|
(BigInt.one << (8 * block.length))) *
|
|
r %
|
|
p;
|
|
}
|
|
return bigIntToLeBytes((acc + s) & ((BigInt.one << 128) - BigInt.one), 16);
|
|
}
|
|
|
|
Uint8List _pad16(int n) => Uint8List((16 - (n % 16)) % 16);
|
|
|
|
Uint8List _le64(int n) => bigIntToLeBytes(BigInt.from(n), 8);
|
|
|
|
Uint8List aeadEncrypt(Uint8List key, Uint8List nonce, Uint8List plaintext,
|
|
Uint8List aad) {
|
|
final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32);
|
|
final ciphertext = _chacha20Xor(key, 1, nonce, plaintext);
|
|
final mac = _poly1305(polyKey,
|
|
concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)]));
|
|
return concat([ciphertext, mac]);
|
|
}
|
|
|
|
Uint8List aeadDecrypt(Uint8List key, Uint8List nonce, Uint8List sealed, Uint8List aad) {
|
|
if (sealed.length < 16) {
|
|
throw const SmolError("ciphertext shorter than the Poly1305 tag");
|
|
}
|
|
final ciphertext = sealed.sublist(0, sealed.length - 16);
|
|
final polyKey = _chachaBlock(key, 0, nonce).sublist(0, 32);
|
|
final expect = _poly1305(polyKey,
|
|
concat([aad, _pad16(aad.length), ciphertext, _pad16(ciphertext.length), _le64(aad.length), _le64(ciphertext.length)]));
|
|
if (!timingSafeEqual(expect, sealed.sublist(sealed.length - 16))) {
|
|
throw const SmolError("decryption failed: bad Poly1305 tag");
|
|
}
|
|
return _chacha20Xor(key, 1, nonce, ciphertext);
|
|
}
|
|
|
|
// --- X25519 (RFC 7748) ---------------------------------------------------------
|
|
|
|
final BigInt _p = (BigInt.one << 255) - BigInt.from(19);
|
|
final BigInt _mask255 = (BigInt.one << 255) - BigInt.one;
|
|
|
|
BigInt _mod(BigInt value, [BigInt? p]) {
|
|
final m = p ?? _p;
|
|
return ((value % m) + m) % m;
|
|
}
|
|
|
|
BigInt _powMod(BigInt base, BigInt exponent, [BigInt? p]) {
|
|
final m = p ?? _p;
|
|
var out = BigInt.one;
|
|
base = _mod(base, m);
|
|
while (exponent > BigInt.zero) {
|
|
if (exponent & BigInt.one == BigInt.one) out = out * base % m;
|
|
base = base * base % m;
|
|
exponent >>= 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
Uint8List clampScalar(Uint8List scalar) {
|
|
final k = Uint8List.fromList(scalar);
|
|
k[0] &= 248;
|
|
k[31] &= 127;
|
|
k[31] |= 64;
|
|
return k;
|
|
}
|
|
|
|
BigInt _x25519Raw(Uint8List scalar, Uint8List u) {
|
|
final k = leBytesToBigInt(clampScalar(scalar));
|
|
final x1 = leBytesToBigInt(u) & _mask255;
|
|
const a24 = 121665;
|
|
var x2 = BigInt.one, z2 = BigInt.zero, x3 = x1, z3 = BigInt.one;
|
|
var swap = BigInt.zero;
|
|
for (var t = 254; t >= 0; t--) {
|
|
final kt = (k >> t) & BigInt.one;
|
|
swap ^= kt;
|
|
if (swap == BigInt.one) {
|
|
var tmp = x2;
|
|
x2 = x3;
|
|
x3 = tmp;
|
|
tmp = z2;
|
|
z2 = z3;
|
|
z3 = tmp;
|
|
}
|
|
swap = kt;
|
|
final a = _mod(x2 + z2), aa = a * a % _p;
|
|
final b = _mod(x2 - z2), bb = b * b % _p;
|
|
final e = _mod(aa - bb);
|
|
final c = _mod(x3 + z3), d = _mod(x3 - z3);
|
|
final da = d * a % _p, cb = c * b % _p;
|
|
final sum = _mod(da + cb), diff = _mod(da - cb);
|
|
x3 = sum * sum % _p;
|
|
z3 = x1 * diff * diff % _p;
|
|
x2 = aa * bb % _p;
|
|
z2 = e * _mod(aa + BigInt.from(a24) * e) % _p;
|
|
}
|
|
if (swap == BigInt.one) {
|
|
var tmp = x2;
|
|
x2 = x3;
|
|
x3 = tmp;
|
|
tmp = z2;
|
|
z2 = z3;
|
|
z3 = tmp;
|
|
}
|
|
return x2 * _powMod(z2, _p - BigInt.two) % _p;
|
|
}
|
|
|
|
// §2's low-order rejection: a clamped scalar is a multiple of 8, so any
|
|
// low-order peer point yields an all-zero shared secret — rejecting the zero
|
|
// output rejects all of them.
|
|
Uint8List x25519(Uint8List scalar, Uint8List peerPublic) {
|
|
final shared = bigIntToLeBytes(_x25519Raw(scalar, peerPublic), 32);
|
|
if (shared.every((b) => b == 0)) {
|
|
throw const SmolError("rejected low-order key agreement point");
|
|
}
|
|
return shared;
|
|
}
|
|
|
|
Uint8List x25519Base(Uint8List scalar) => bigIntToLeBytes(
|
|
_x25519Raw(scalar, unhex("0900000000000000000000000000000000000000000000000000000000000000")),
|
|
32);
|
|
|
|
// --- Ed25519 (RFC 8032) --------------------------------------------------------
|
|
|
|
final BigInt _l = (BigInt.one << 252) +
|
|
BigInt.parse("27742317777372353535851937790883648493");
|
|
final BigInt _d = _mod(-BigInt.from(121665) * _powMod(BigInt.from(121666), _p - BigInt.two));
|
|
final _Point _b = _Point.fromAffine(
|
|
BigInt.parse(
|
|
"15112221349535400772501151409588531511454012693041857206046113283949847762202"),
|
|
_mod(BigInt.from(4) * _powMod(BigInt.from(5), _p - BigInt.two)));
|
|
|
|
class _Point {
|
|
final BigInt x, y, z, t;
|
|
|
|
const _Point(this.x, this.y, this.z, this.t);
|
|
|
|
_Point.fromAffine(BigInt x, BigInt y)
|
|
: this(x, y, BigInt.one, _mod(x * y));
|
|
}
|
|
|
|
final _Point _identity = _Point(
|
|
BigInt.zero, BigInt.one, BigInt.one, BigInt.zero);
|
|
|
|
_Point _pointAdd(_Point p, _Point q) {
|
|
final a = _mod(p.y - p.x) * _mod(q.y - q.x) % _p;
|
|
final b = _mod(p.y + p.x) * _mod(q.y + q.x) % _p;
|
|
final c = BigInt.two * p.t * q.t % _p * _d % _p;
|
|
final d = BigInt.two * p.z * q.z % _p;
|
|
final e = _mod(b - a), f = _mod(d - c), g = _mod(d + c);
|
|
final h = b + a;
|
|
return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p);
|
|
}
|
|
|
|
_Point _pointDouble(_Point p) {
|
|
final a = p.x * p.x % _p;
|
|
final b = p.y * p.y % _p;
|
|
final c = BigInt.two * p.z * p.z % _p;
|
|
final d = _p - a; // a = -1 on this curve, so d = -A
|
|
final e = _mod(_mod(p.x + p.y) * _mod(p.x + p.y) - a - b);
|
|
final g = _mod(d + b);
|
|
final f = _mod(g - c);
|
|
final h = _mod(d - b);
|
|
return _Point(e * f % _p, g * h % _p, f * g % _p, e * h % _p);
|
|
}
|
|
|
|
_Point _scalarMult(BigInt scalar, _Point point) {
|
|
var result = _identity;
|
|
for (var t = 254; t >= 0; t--) {
|
|
result = _pointDouble(result);
|
|
if ((scalar >> t) & BigInt.one == BigInt.one) result = _pointAdd(result, point);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
Uint8List _encodePoint(_Point p) {
|
|
final zInv = _powMod(p.z, _p - BigInt.two);
|
|
final x = p.x * zInv % _p, y = p.y * zInv % _p;
|
|
final out = bigIntToLeBytes(y, 32);
|
|
out[31] |= (x & BigInt.one).toInt() << 7;
|
|
return out;
|
|
}
|
|
|
|
_Point _decodePoint(Uint8List bytes) {
|
|
if (bytes.length != 32) {
|
|
throw const SmolError("Ed25519 public key must be 32 bytes");
|
|
}
|
|
final sign = bytes[31] >> 7;
|
|
final y = leBytesToBigInt(bytes) & _mask255;
|
|
if (y >= _p) {
|
|
throw const SmolError("non-canonical Ed25519 public key");
|
|
}
|
|
final u = _mod(y * y - BigInt.one), v = _mod(_d * y * y + BigInt.one);
|
|
final v2 = v * v % _p, v3 = v2 * v % _p, v4 = v2 * v2 % _p;
|
|
var x = u * v3 % _p * _powMod(u * v4 % _p * v3 % _p, (_p - BigInt.from(5)) ~/ BigInt.from(8)) % _p;
|
|
if (_mod(v * x % _p * x) != u) {
|
|
if (_mod(v * x % _p * x) == _mod(-u)) {
|
|
x = x * _powMod(BigInt.two, (_p - BigInt.one) ~/ BigInt.from(4)) % _p;
|
|
} else {
|
|
throw const SmolError("not a point on the Ed25519 curve");
|
|
}
|
|
}
|
|
if (x == BigInt.zero && sign == 1) {
|
|
throw const SmolError("invalid sign bit on x = 0");
|
|
}
|
|
if ((x & BigInt.one).toInt() != sign) x = _p - x;
|
|
return _Point.fromAffine(x, y);
|
|
}
|
|
|
|
BigInt _seedToScalar(Uint8List seed) {
|
|
final h = sha512(seed);
|
|
return leBytesToBigInt(clampScalar(h.sublist(0, 32)));
|
|
}
|
|
|
|
Uint8List ed25519PublicKey(Uint8List seed) {
|
|
if (seed.length != 32) {
|
|
throw const SmolError("identity seed must be 32 bytes");
|
|
}
|
|
return _encodePoint(_scalarMult(_seedToScalar(seed), _Point.fromAffine(_b.x, _b.y)));
|
|
}
|
|
|
|
Uint8List ed25519Sign(Uint8List seed, List<int> message) {
|
|
final h = sha512(seed);
|
|
final a = leBytesToBigInt(clampScalar(h.sublist(0, 32)));
|
|
final publicKey =
|
|
_encodePoint(_scalarMult(a, _Point.fromAffine(_b.x, _b.y)));
|
|
final r = leBytesToBigInt(sha512(concat([h.sublist(32), message]))) % _l;
|
|
final rEnc = _encodePoint(_scalarMult(r, _Point.fromAffine(_b.x, _b.y)));
|
|
final k = leBytesToBigInt(sha512(concat([rEnc, publicKey, message]))) % _l;
|
|
return concat([rEnc, bigIntToLeBytes((r + k * a) % _l, 32)]);
|
|
}
|
|
|
|
bool ed25519Verify(Uint8List publicKey, List<int> message, Uint8List signature) {
|
|
if (signature.length != 64) return false;
|
|
try {
|
|
final decodedPk = _decodePoint(publicKey);
|
|
final decodedR = _decodePoint(signature.sublist(0, 32));
|
|
final a = _Point.fromAffine(decodedPk.x, decodedPk.y);
|
|
final r = _Point.fromAffine(decodedR.x, decodedR.y);
|
|
final s = leBytesToBigInt(signature.sublist(32, 64));
|
|
if (s >= _l) return false;
|
|
final k = leBytesToBigInt(sha512(concat([signature.sublist(0, 32), publicKey, message]))) % _l;
|
|
final lhs = _scalarMult(s, _Point.fromAffine(_b.x, _b.y));
|
|
final rhs = _pointAdd(_scalarMult(k, a), r);
|
|
return lhs.x * rhs.z % _p == rhs.x * lhs.z % _p &&
|
|
lhs.y * rhs.z % _p == rhs.y * lhs.z % _p;
|
|
} on Exception {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// --- §2 conversions between the identity key and X25519 -------------------------
|
|
|
|
Uint8List ed25519ToX25519(Uint8List publicKey) {
|
|
final y = leBytesToBigInt(publicKey) & _mask255;
|
|
if (y >= _p) {
|
|
throw const SmolError("non-canonical Ed25519 public key");
|
|
}
|
|
if (_mod(BigInt.one - y) == BigInt.zero) {
|
|
throw const SmolError("identity element has no X25519 image");
|
|
}
|
|
return bigIntToLeBytes(
|
|
_mod(BigInt.one + y) * _powMod(BigInt.one - y, _p - BigInt.two) % _p, 32);
|
|
}
|
|
|
|
Uint8List ed25519SeedToX25519(Uint8List seed) =>
|
|
clampScalar(sha512(seed).sublist(0, 32));
|
|
|