414 lines
15 KiB
Dart
414 lines
15 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
import 'package:cryptography/cryptography.dart';
|
|
|
|
/// Low-level cryptographic primitives for the Sojorn Capsule system.
|
|
///
|
|
/// ## Zero-Knowledge Architecture
|
|
/// The server NEVER possesses unencrypted keys or plaintext.
|
|
/// All encryption/decryption happens exclusively on the client.
|
|
///
|
|
/// ## Algorithms
|
|
/// - **Symmetric**: AES-256-GCM (authenticated encryption with associated data)
|
|
/// - **Key Exchange**: X25519 (Elliptic-Curve Diffie-Hellman)
|
|
/// - **Key Derivation**: HKDF-SHA256 (for password-based escrow)
|
|
///
|
|
/// ## Data Flow
|
|
/// ```
|
|
/// generateSymmetricKey() → Capsule Key (AES-256)
|
|
/// │
|
|
/// ├─ encryptPayload(json, key) → { payload, iv }
|
|
/// ├─ decryptPayload(payload, iv, key) → json
|
|
/// │
|
|
/// └─ boxKey(groupKey, recipientPubKey) → sealed blob
|
|
/// └─ unboxKey(sealedBlob, myKeyPair) → Capsule Key
|
|
/// ```
|
|
class CapsuleCrypto {
|
|
CapsuleCrypto._();
|
|
|
|
static final _aes = AesGcm.with256bits();
|
|
static final _x25519 = X25519();
|
|
static final _hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// 1. SYMMETRIC KEY GENERATION
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/// Generate a cryptographically random AES-256-GCM symmetric key.
|
|
/// Used as the "Capsule Key" — the shared secret for a private group.
|
|
static Future<SecretKey> generateSymmetricKey() async {
|
|
return _aes.newSecretKey();
|
|
}
|
|
|
|
/// Import raw key bytes (e.g. from cache or recovery) into a SecretKey.
|
|
static SecretKey importSymmetricKey(Uint8List rawBytes) {
|
|
assert(rawBytes.length == 32, 'AES-256 key must be exactly 32 bytes');
|
|
return SecretKey(rawBytes);
|
|
}
|
|
|
|
/// Export a SecretKey to raw bytes for storage/transport.
|
|
static Future<Uint8List> exportSymmetricKey(SecretKey key) async {
|
|
final bytes = await key.extractBytes();
|
|
return Uint8List.fromList(bytes);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// 2. PAYLOAD ENCRYPTION / DECRYPTION (AES-256-GCM)
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/// Encrypt a JSON-serializable payload with the Capsule Key.
|
|
///
|
|
/// Returns a [SealedPayload] containing the IV (nonce) and the
|
|
/// ciphertext+MAC concatenated as a single base64 blob.
|
|
///
|
|
/// The MAC is appended to the ciphertext (standard GCM layout):
|
|
/// payload = base64(ciphertext || mac_16_bytes)
|
|
static Future<SealedPayload> encryptPayload(
|
|
String jsonString,
|
|
SecretKey key,
|
|
) async {
|
|
final plaintext = utf8.encode(jsonString);
|
|
final nonce = _aes.newNonce(); // 12 bytes for GCM
|
|
|
|
final box = await _aes.encrypt(
|
|
plaintext,
|
|
secretKey: key,
|
|
nonce: nonce,
|
|
);
|
|
|
|
// Concat ciphertext + MAC tag for single-blob storage
|
|
final combined = Uint8List.fromList([
|
|
...box.cipherText,
|
|
...box.mac.bytes,
|
|
]);
|
|
|
|
return SealedPayload(
|
|
iv: base64Encode(nonce),
|
|
payload: base64Encode(combined),
|
|
);
|
|
}
|
|
|
|
/// Decrypt a sealed payload back to its original JSON string.
|
|
///
|
|
/// Expects the same format produced by [encryptPayload]:
|
|
/// payload = base64(ciphertext || mac_16_bytes)
|
|
static Future<String> decryptPayload(
|
|
String payloadB64,
|
|
String ivB64,
|
|
SecretKey key,
|
|
) async {
|
|
final combined = base64Decode(payloadB64);
|
|
final ivBytes = base64Decode(ivB64);
|
|
|
|
// Split: everything except last 16 bytes is ciphertext, last 16 is MAC
|
|
if (combined.length < 16) {
|
|
throw CapsuleCryptoException('Payload too short — corrupted or tampered');
|
|
}
|
|
final cipherText = combined.sublist(0, combined.length - 16);
|
|
final macBytes = combined.sublist(combined.length - 16);
|
|
|
|
final box = SecretBox(
|
|
cipherText,
|
|
nonce: ivBytes,
|
|
mac: Mac(macBytes),
|
|
);
|
|
|
|
try {
|
|
final plaintext = await _aes.decrypt(box, secretKey: key);
|
|
return utf8.decode(plaintext);
|
|
} catch (e) {
|
|
throw CapsuleCryptoException(
|
|
'Decryption failed — wrong key or tampered payload',
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// 3. KEY BOXING / UNBOXING (X25519 ECDH + AES-GCM SEAL)
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/// "Box" (seal) a group symmetric key for a specific recipient.
|
|
///
|
|
/// Uses X25519 ECDH to derive a shared secret between sender and recipient,
|
|
/// then encrypts the group key with that shared secret.
|
|
///
|
|
/// Returns a [BoxedKey] that only the recipient can unbox.
|
|
static Future<BoxedKey> boxKey(
|
|
SecretKey groupKey,
|
|
SimpleKeyPair senderKeyPair,
|
|
SimplePublicKey recipientPublicKey,
|
|
) async {
|
|
// Derive shared secret via ECDH
|
|
final sharedSecret = await _x25519.sharedSecretKey(
|
|
keyPair: senderKeyPair,
|
|
remotePublicKey: recipientPublicKey,
|
|
);
|
|
|
|
// Derive a proper encryption key from the shared secret via HKDF
|
|
final derivedKey = await _hkdf.deriveKey(
|
|
secretKey: sharedSecret,
|
|
nonce: utf8.encode('sojorn-capsule-box-v1'),
|
|
info: utf8.encode('capsule-key-encryption'),
|
|
);
|
|
|
|
// Encrypt the group key
|
|
final groupKeyBytes = await groupKey.extractBytes();
|
|
final nonce = _aes.newNonce();
|
|
final box = await _aes.encrypt(
|
|
groupKeyBytes,
|
|
secretKey: derivedKey,
|
|
nonce: nonce,
|
|
);
|
|
|
|
final senderPub = await senderKeyPair.extractPublicKey();
|
|
return BoxedKey(
|
|
iv: base64Encode(nonce),
|
|
ciphertext: base64Encode(box.cipherText),
|
|
mac: base64Encode(box.mac.bytes),
|
|
senderPublicKey: base64Encode(senderPub.bytes),
|
|
);
|
|
}
|
|
|
|
/// "Unbox" (unseal) a group symmetric key addressed to us.
|
|
///
|
|
/// Uses our private key + the sender's public key to re-derive
|
|
/// the same shared secret, then decrypts the group key.
|
|
static Future<SecretKey> unboxKey(
|
|
BoxedKey boxedKey,
|
|
SimpleKeyPair recipientKeyPair,
|
|
) async {
|
|
final senderPub = SimplePublicKey(
|
|
base64Decode(boxedKey.senderPublicKey),
|
|
type: KeyPairType.x25519,
|
|
);
|
|
|
|
// Re-derive the same shared secret
|
|
final sharedSecret = await _x25519.sharedSecretKey(
|
|
keyPair: recipientKeyPair,
|
|
remotePublicKey: senderPub,
|
|
);
|
|
|
|
final derivedKey = await _hkdf.deriveKey(
|
|
secretKey: sharedSecret,
|
|
nonce: utf8.encode('sojorn-capsule-box-v1'),
|
|
info: utf8.encode('capsule-key-encryption'),
|
|
);
|
|
|
|
// Decrypt
|
|
final box = SecretBox(
|
|
base64Decode(boxedKey.ciphertext),
|
|
nonce: base64Decode(boxedKey.iv),
|
|
mac: Mac(base64Decode(boxedKey.mac)),
|
|
);
|
|
|
|
try {
|
|
final keyBytes = await _aes.decrypt(box, secretKey: derivedKey);
|
|
return SecretKey(keyBytes);
|
|
} catch (e) {
|
|
throw CapsuleCryptoException('Unbox failed — not intended for this key pair');
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// 4. X25519 KEY PAIR MANAGEMENT
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/// Generate a new X25519 key pair.
|
|
static Future<SimpleKeyPair> generateKeyPair() async {
|
|
return await _x25519.newKeyPair() as SimpleKeyPair;
|
|
}
|
|
|
|
/// Export a key pair to portable bytes for backup.
|
|
static Future<KeyPairExport> exportKeyPair(SimpleKeyPair keyPair) async {
|
|
final privateBytes = await keyPair.extractPrivateKeyBytes();
|
|
final publicKey = await keyPair.extractPublicKey();
|
|
return KeyPairExport(
|
|
privateKey: Uint8List.fromList(privateBytes),
|
|
publicKey: Uint8List.fromList(publicKey.bytes),
|
|
);
|
|
}
|
|
|
|
/// Import a key pair from raw bytes.
|
|
static SimpleKeyPair importKeyPair(Uint8List privateKey, Uint8List publicKey) {
|
|
return SimpleKeyPairData(
|
|
privateKey,
|
|
publicKey: SimplePublicKey(publicKey, type: KeyPairType.x25519),
|
|
type: KeyPairType.x25519,
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// 5. ESCROW KEY DERIVATION (Password/PIN → AES Key)
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/// Derive an AES-256 key from a user's password or PIN.
|
|
/// Used for the Escrow Recovery System — encrypts the user's private key
|
|
/// before uploading to the server.
|
|
///
|
|
/// Uses Argon2id for memory-hard key stretching (resistant to GPU attacks).
|
|
/// Falls back to PBKDF2 if Argon2id is not available.
|
|
static Future<SecretKey> deriveKeyFromPassword(
|
|
String password, {
|
|
required Uint8List salt,
|
|
int iterations = 100000,
|
|
}) async {
|
|
final pbkdf2 = Pbkdf2(
|
|
macAlgorithm: Hmac.sha256(),
|
|
iterations: iterations,
|
|
bits: 256,
|
|
);
|
|
return pbkdf2.deriveKey(
|
|
secretKey: SecretKey(utf8.encode(password)),
|
|
nonce: salt,
|
|
);
|
|
}
|
|
|
|
/// Generate a random 16-byte salt for password derivation.
|
|
static Uint8List generateSalt() {
|
|
// Use AES nonce generator as a source of 16 random bytes
|
|
final nonce = _aes.newNonce(); // 12 bytes
|
|
// Extend to 16 by generating another partial nonce
|
|
final extra = _aes.newNonce();
|
|
return Uint8List.fromList([...nonce, ...extra.sublist(0, 4)]);
|
|
}
|
|
|
|
/// Encrypt raw bytes with a password-derived key.
|
|
/// Used for escrow: encrypt(privateKeyBytes, deriveKey(pin))
|
|
static Future<SealedPayload> encryptWithDerivedKey(
|
|
Uint8List plaintext,
|
|
SecretKey derivedKey,
|
|
) async {
|
|
final nonce = _aes.newNonce();
|
|
final box = await _aes.encrypt(plaintext, secretKey: derivedKey, nonce: nonce);
|
|
final combined = Uint8List.fromList([...box.cipherText, ...box.mac.bytes]);
|
|
return SealedPayload(
|
|
iv: base64Encode(nonce),
|
|
payload: base64Encode(combined),
|
|
);
|
|
}
|
|
|
|
/// Decrypt raw bytes with a password-derived key.
|
|
static Future<Uint8List> decryptWithDerivedKey(
|
|
String payloadB64,
|
|
String ivB64,
|
|
SecretKey derivedKey,
|
|
) async {
|
|
final combined = base64Decode(payloadB64);
|
|
final cipherText = combined.sublist(0, combined.length - 16);
|
|
final mac = Mac(combined.sublist(combined.length - 16));
|
|
final box = SecretBox(cipherText, nonce: base64Decode(ivB64), mac: mac);
|
|
try {
|
|
final result = await _aes.decrypt(box, secretKey: derivedKey);
|
|
return Uint8List.fromList(result);
|
|
} catch (e) {
|
|
throw CapsuleCryptoException('Escrow decryption failed — wrong PIN/password');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// DATA CLASSES
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
/// Encrypted payload + its IV. Produced by [CapsuleCrypto.encryptPayload].
|
|
class SealedPayload {
|
|
final String iv; // base64-encoded 12-byte GCM nonce
|
|
final String payload; // base64-encoded (ciphertext || mac)
|
|
|
|
const SealedPayload({required this.iv, required this.payload});
|
|
|
|
Map<String, dynamic> toJson() => {'iv': iv, 'payload': payload};
|
|
|
|
factory SealedPayload.fromJson(Map<String, dynamic> json) => SealedPayload(
|
|
iv: json['iv'] as String,
|
|
payload: json['payload'] as String,
|
|
);
|
|
}
|
|
|
|
/// A group key encrypted for a specific recipient via X25519+AES-GCM.
|
|
class BoxedKey {
|
|
final String iv;
|
|
final String ciphertext;
|
|
final String mac;
|
|
final String senderPublicKey; // base64 X25519 public key of sender
|
|
|
|
const BoxedKey({
|
|
required this.iv,
|
|
required this.ciphertext,
|
|
required this.mac,
|
|
required this.senderPublicKey,
|
|
});
|
|
|
|
/// Serialize to JSON string for storage in `capsule_keys.encrypted_key_blob`
|
|
String serialize() => jsonEncode(toJson());
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'iv': iv,
|
|
'ct': ciphertext,
|
|
'mac': mac,
|
|
'spk': senderPublicKey,
|
|
};
|
|
|
|
factory BoxedKey.fromJson(Map<String, dynamic> json) => BoxedKey(
|
|
iv: json['iv'] as String,
|
|
ciphertext: json['ct'] as String,
|
|
mac: json['mac'] as String,
|
|
senderPublicKey: json['spk'] as String,
|
|
);
|
|
|
|
factory BoxedKey.deserialize(String jsonStr) =>
|
|
BoxedKey.fromJson(jsonDecode(jsonStr) as Map<String, dynamic>);
|
|
}
|
|
|
|
/// Portable representation of an X25519 key pair.
|
|
class KeyPairExport {
|
|
final Uint8List privateKey; // 32 bytes
|
|
final Uint8List publicKey; // 32 bytes
|
|
|
|
const KeyPairExport({required this.privateKey, required this.publicKey});
|
|
|
|
String get privateKeyB64 => base64Encode(privateKey);
|
|
String get publicKeyB64 => base64Encode(publicKey);
|
|
}
|
|
|
|
/// Escrow backup blob: the user's private key encrypted with their PIN.
|
|
class EscrowBackup {
|
|
final String salt; // base64-encoded salt used in PBKDF2
|
|
final String iv; // base64-encoded nonce
|
|
final String payload; // base64-encoded encrypted private key + MAC
|
|
final String publicKey; // base64-encoded public key (not secret, for matching)
|
|
|
|
const EscrowBackup({
|
|
required this.salt,
|
|
required this.iv,
|
|
required this.payload,
|
|
required this.publicKey,
|
|
});
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'salt': salt,
|
|
'iv': iv,
|
|
'payload': payload,
|
|
'pub': publicKey,
|
|
};
|
|
|
|
factory EscrowBackup.fromJson(Map<String, dynamic> json) => EscrowBackup(
|
|
salt: json['salt'] as String,
|
|
iv: json['iv'] as String,
|
|
payload: json['payload'] as String,
|
|
publicKey: json['pub'] as String,
|
|
);
|
|
|
|
String serialize() => jsonEncode(toJson());
|
|
factory EscrowBackup.deserialize(String jsonStr) =>
|
|
EscrowBackup.fromJson(jsonDecode(jsonStr) as Map<String, dynamic>);
|
|
}
|
|
|
|
/// Exception type for all capsule crypto failures.
|
|
class CapsuleCryptoException implements Exception {
|
|
final String message;
|
|
const CapsuleCryptoException(this.message);
|
|
@override
|
|
String toString() => 'CapsuleCryptoException: $message';
|
|
}
|