Skip to content

Commit 05a5353

Browse files
ehsan6shaclaude
andcommitted
Web: FxFiles branding, profile->cloud.fx.land, private share links
- Proper FxFiles icons for web (flutter_launcher_icons web target from assets/icons/icon.png; favicon + 192/512 + maskable), manifest.json and index.html branded (FxFiles name/description, #0A0A0A theme). Native launcher icons untouched (generator churn restored). - Home appbar: vault label is now a settings-style account button that opens https://cloud.fx.land (profile/billing management) in a new tab; sign-out unchanged. - Private (password-protected) share links on web, at full native wire parity: the PBKDF2-HMAC-SHA256(100k)/AES-GCM-256 envelope moved from SharingService into share_link_builder.dart (native delegates via thin wrappers; createPasswordProtectedLink now uses the shared builder). Share dialog gains a Public/Private segmented choice with password field. New wire-format test pins the outer {v,p,s,e,b,k} envelope, inner-payload decrypt round-trip, and wrong-password failure. Gates: dart analyze 176 (below the 177 baseline - two stale imports dropped), full flutter test green (466), web build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9f4af39 commit 05a5353

14 files changed

Lines changed: 358 additions & 123 deletions

‎lib/core/services/share_link_builder.dart‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import 'dart:convert';
2+
import 'dart:math';
23
import 'dart:typed_data';
34

5+
import 'package:cryptography/cryptography.dart';
6+
47
// Pure construction of public-share URLs. Platform-neutral and free of
58
// service dependencies so the native SharingService and the web shell
69
// build BYTE-IDENTICAL links — the v2 payload field set and the
@@ -46,3 +49,111 @@ String buildPublicShareUrl({
4649
final fragment = base64UrlEncode(utf8.encode(jsonEncode(payloadMap)));
4750
return '$baseUrl/view/$tokenId#$fragment';
4851
}
52+
53+
// ============================================================================
54+
// Password-protected links. The portal decrypts the inner v2 payload
55+
// with a key derived from the user-supplied password; these parameters
56+
// (PBKDF2-HMAC-SHA256 / 100k / 256-bit, AES-GCM-256, nonce||ct||mac
57+
// layout, outer {v,p,s,e,b,k} shape) are wire format — native, web and
58+
// the portal must agree byte-for-byte.
59+
// ============================================================================
60+
61+
final _shareAesGcm = AesGcm.with256bits();
62+
final _shareRandom = Random.secure();
63+
64+
/// PBKDF2-HMAC-SHA256, 100k iterations, 256-bit output.
65+
Future<Uint8List> sharePasswordDeriveKey(
66+
String password, Uint8List salt) async {
67+
final pbkdf2 = Pbkdf2(
68+
macAlgorithm: Hmac.sha256(),
69+
iterations: 100000,
70+
bits: 256,
71+
);
72+
final secretKey = await pbkdf2.deriveKey(
73+
secretKey: SecretKey(utf8.encode(password)),
74+
nonce: salt,
75+
);
76+
return Uint8List.fromList(await secretKey.extractBytes());
77+
}
78+
79+
/// AES-GCM-256; output layout: nonce || ciphertext || mac.
80+
Future<Uint8List> sharePasswordEncrypt(Uint8List data, Uint8List key) async {
81+
final secretKey = SecretKey(key);
82+
final nonce = _shareAesGcm.newNonce();
83+
final secretBox =
84+
await _shareAesGcm.encrypt(data, secretKey: secretKey, nonce: nonce);
85+
return Uint8List.fromList(
86+
[...nonce, ...secretBox.cipherText, ...secretBox.mac.bytes]);
87+
}
88+
89+
/// Inverse of [sharePasswordEncrypt].
90+
Future<Uint8List> sharePasswordDecrypt(
91+
Uint8List encryptedData, Uint8List key) async {
92+
final nonceLength = _shareAesGcm.nonceLength;
93+
final macLength = _shareAesGcm.macAlgorithm.macLength;
94+
final nonce = encryptedData.sublist(0, nonceLength);
95+
final cipherText =
96+
encryptedData.sublist(nonceLength, encryptedData.length - macLength);
97+
final mac = encryptedData.sublist(encryptedData.length - macLength);
98+
final secretBox = SecretBox(cipherText, nonce: nonce, mac: Mac(mac));
99+
final decrypted =
100+
await _shareAesGcm.decrypt(secretBox, secretKey: SecretKey(key));
101+
return Uint8List.fromList(decrypted);
102+
}
103+
104+
Uint8List generateShareSalt(int length) =>
105+
Uint8List.fromList(List.generate(length, (_) => _shareRandom.nextInt(256)));
106+
107+
/// Assemble a password-protected share URL: the standard v2 inner
108+
/// payload is AES-GCM-encrypted under the password-derived key and
109+
/// wrapped in the outer `{v:2, p:true, s, e, b, k}` envelope. Returns
110+
/// the URL plus the fragment and salt (native persists both for URL
111+
/// regeneration).
112+
Future<({String url, String fragment, Uint8List salt})>
113+
buildPasswordProtectedShareUrl({
114+
required String baseUrl,
115+
required String tokenId,
116+
required String fulaToken,
117+
required String bucket,
118+
required String pathScope,
119+
required String storageKey,
120+
required Uint8List linkSecretKey,
121+
required String password,
122+
String? label,
123+
String? fileName,
124+
bool folder = false,
125+
}) async {
126+
final innerPayloadMap = {
127+
'v': 2,
128+
't': fulaToken,
129+
'b': bucket,
130+
'k': pathScope,
131+
'cid': storageKey,
132+
'sk': base64Encode(linkSecretKey),
133+
if (label != null) 'l': label,
134+
if (fileName != null) 'f': fileName,
135+
if (folder) 'folder': true,
136+
};
137+
138+
final salt = generateShareSalt(16);
139+
final passwordKey = await sharePasswordDeriveKey(password, salt);
140+
final encryptedPayload = await sharePasswordEncrypt(
141+
Uint8List.fromList(utf8.encode(jsonEncode(innerPayloadMap))),
142+
passwordKey,
143+
);
144+
145+
final outerPayload = {
146+
'v': 2,
147+
'p': true, // password protected flag
148+
's': base64Encode(salt),
149+
'e': base64Encode(encryptedPayload),
150+
'b': bucket,
151+
'k': pathScope,
152+
};
153+
final fragment = base64UrlEncode(utf8.encode(jsonEncode(outerPayload)));
154+
return (
155+
url: '$baseUrl/view/$tokenId#$fragment',
156+
fragment: fragment,
157+
salt: salt,
158+
);
159+
}

‎lib/core/services/sharing_service.dart‎

Lines changed: 28 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import 'dart:convert';
33
import 'dart:io';
44
import 'dart:math';
55
import 'dart:typed_data';
6-
import 'package:cryptography/cryptography.dart';
76
import 'package:flutter/foundation.dart';
87
import 'package:http/http.dart' as http;
98
import 'package:uuid/uuid.dart';
@@ -71,63 +70,24 @@ class SharingService {
7170
final _uuid = const Uuid();
7271
final _random = Random.secure();
7372

74-
// Cryptographic algorithm for password encryption
75-
static final _aesGcm = AesGcm.with256bits();
7673

7774
// ============================================================================
7875
// CRYPTOGRAPHIC HELPERS (for password-protected links)
7976
// ============================================================================
8077

81-
/// Derive encryption key from password using PBKDF2
82-
Future<Uint8List> _deriveKeyFromPassword(String password, Uint8List salt) async {
83-
final pbkdf2 = Pbkdf2(
84-
macAlgorithm: Hmac.sha256(),
85-
iterations: 100000,
86-
bits: 256,
87-
);
88-
89-
final secretKey = await pbkdf2.deriveKey(
90-
secretKey: SecretKey(utf8.encode(password)),
91-
nonce: salt,
92-
);
78+
// Password-link crypto now lives in share_link_builder.dart (shared
79+
// wire format with the web shell). These wrappers keep the original
80+
// private call sites unchanged.
81+
Future<Uint8List> _deriveKeyFromPassword(String password, Uint8List salt) =>
82+
sharePasswordDeriveKey(password, salt);
9383

94-
return Uint8List.fromList(await secretKey.extractBytes());
95-
}
96-
97-
/// Encrypt data using AES-GCM
98-
Future<Uint8List> _encrypt(Uint8List data, Uint8List key) async {
99-
final secretKey = SecretKey(key);
100-
final nonce = _aesGcm.newNonce();
101-
final secretBox = await _aesGcm.encrypt(data, secretKey: secretKey, nonce: nonce);
102-
103-
// Return nonce + ciphertext + mac
104-
return Uint8List.fromList([
105-
...nonce,
106-
...secretBox.cipherText,
107-
...secretBox.mac.bytes,
108-
]);
109-
}
84+
Future<Uint8List> _encrypt(Uint8List data, Uint8List key) =>
85+
sharePasswordEncrypt(data, key);
11086

111-
/// Decrypt data using AES-GCM
112-
Future<Uint8List> _decrypt(Uint8List encryptedData, Uint8List key) async {
113-
final nonceLength = _aesGcm.nonceLength;
114-
final macLength = _aesGcm.macAlgorithm.macLength;
87+
Future<Uint8List> _decrypt(Uint8List encryptedData, Uint8List key) =>
88+
sharePasswordDecrypt(encryptedData, key);
11589

116-
final nonce = encryptedData.sublist(0, nonceLength);
117-
final cipherText = encryptedData.sublist(nonceLength, encryptedData.length - macLength);
118-
final mac = encryptedData.sublist(encryptedData.length - macLength);
119-
120-
final secretKey = SecretKey(key);
121-
final secretBox = SecretBox(cipherText, nonce: nonce, mac: Mac(mac));
122-
final decrypted = await _aesGcm.decrypt(secretBox, secretKey: secretKey);
123-
124-
return Uint8List.fromList(decrypted);
125-
}
126-
127-
/// Generate random salt
128-
Uint8List _generateSalt(int length) {
129-
return Uint8List.fromList(List.generate(length, (_) => _random.nextInt(256)));
130-
}
90+
Uint8List _generateSalt(int length) => generateShareSalt(length);
13191

13292
// ============================================================================
13393
// OWNER SIDE - Creating and managing shares
@@ -1172,44 +1132,25 @@ class SharingService {
11721132
);
11731133
}
11741134

1175-
// Build inner payload with fula token and secret key (v2 format)
1176-
// File manifest stored server-side only for folder shares.
1177-
final innerPayloadMap = {
1178-
'v': 2,
1179-
't': fulaToken,
1180-
'b': effBucket,
1181-
'k': pathScope, // Original path - used for DEK derivation
1182-
'cid': storageKey, // Storage key/CID - used for fetching file from IPFS
1183-
'sk': base64Encode(privateKeyBytes), // Secret key for decryption
1184-
if (label != null) 'l': label,
1185-
if (fileName != null) 'f': fileName,
1186-
if (folderFiles != null) 'folder': true,
1187-
};
1188-
1189-
// Encrypt the inner payload with password-derived key
1190-
final salt = _generateSalt(16);
1191-
final passwordKey = await _deriveKeyFromPassword(password, salt);
1192-
final encryptedPayload = await _encrypt(
1193-
Uint8List.fromList(utf8.encode(jsonEncode(innerPayloadMap))),
1194-
passwordKey,
1135+
// Inner v2 payload + password envelope + URL via the shared builder
1136+
// (single-sourced wire format with the web shell). File manifest
1137+
// stored server-side only for folder shares.
1138+
final built = await buildPasswordProtectedShareUrl(
1139+
baseUrl: gatewayBaseUrl ?? kShareGatewayBaseUrl,
1140+
tokenId: tokenId,
1141+
fulaToken: fulaToken,
1142+
bucket: effBucket,
1143+
pathScope: pathScope,
1144+
storageKey: storageKey,
1145+
linkSecretKey: privateKeyBytes,
1146+
password: password,
1147+
label: label,
1148+
fileName: fileName,
1149+
folder: folderFiles != null,
11951150
);
1196-
1197-
// Create outer wrapper with salt and encrypted inner payload
1198-
final outerPayload = {
1199-
'v': 2, // Version 2 = fula_client format
1200-
'p': true, // password protected flag
1201-
's': base64Encode(salt),
1202-
'e': base64Encode(encryptedPayload),
1203-
'b': effBucket,
1204-
'k': pathScope,
1205-
};
1206-
1207-
// Encode outer payload for URL
1208-
final fragment = base64UrlEncode(utf8.encode(jsonEncode(outerPayload)));
1209-
1210-
// Build the URL
1211-
final baseUrl = gatewayBaseUrl ?? kShareGatewayBaseUrl;
1212-
final url = '$baseUrl/view/$tokenId#$fragment';
1151+
final url = built.url;
1152+
final salt = built.salt;
1153+
final fragment = built.fragment;
12131154

12141155
// Post manifest to server for folder shares (enables temporal updates)
12151156
if (folderFiles != null) {

0 commit comments

Comments
 (0)