// 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> 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 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 a, List 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 message) => Uint8List.fromList(hashes.sha256.convert(message).bytes); Uint8List sha512(List message) => Uint8List.fromList(hashes.sha512.convert(message).bytes); Uint8List hmacSha256(List key, List message) => Uint8List.fromList(hashes.Hmac(hashes.sha256, key).convert(message).bytes); Uint8List hkdfSha256(List ikm, List salt, List info, [int length = 32]) { final prk = hmacSha256(salt, ikm); var out = []; var block = []; 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 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 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 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));