Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9d319eb
Begin working on App Platform
d2dyno1 May 31, 2026
2d21278
Added APP_PLATFORM_PRESENT compile constant
d2dyno1 May 31, 2026
2c88472
Added missing components
d2dyno1 May 31, 2026
e4254e8
Fixed build
d2dyno1 Jun 1, 2026
56415ff
Update Directory.Build.props
d2dyno1 Jun 1, 2026
385bc16
Added DeviceSetupDialog
d2dyno1 Jun 5, 2026
80154e9
Use AES-256
d2dyno1 Jun 5, 2026
81d58a3
Removed stale methods
d2dyno1 Jun 5, 2026
f79a156
Added the option to request App Platform account reset
d2dyno1 Jun 6, 2026
e88870e
Added equality and ComputeJwkThumbprint methods
d2dyno1 Jun 8, 2026
12ec907
Fixed build
d2dyno1 Jun 9, 2026
c9888c4
Apply code review
d2dyno1 Jun 9, 2026
728e058
Adjusted PBKDF2 iterations
d2dyno1 Jun 9, 2026
1116a28
Added IUriLauncher
d2dyno1 Jun 9, 2026
c975b4a
Encrypt pairing requests and mitigate MITM in Device Link
d2dyno1 Jun 10, 2026
3ac1f9e
Added complementation generation
d2dyno1 Jun 11, 2026
12fd891
Replaced Device Link's ExpectedHmac with BindingSecret
d2dyno1 Jun 12, 2026
71d93c2
Removed obsolete DeviceLinkVaultDataModel
d2dyno1 Jun 12, 2026
feaab08
Update AccountKeyHelper.cs
d2dyno1 Jun 13, 2026
57c73a9
Added Accounts settings page
d2dyno1 Jun 15, 2026
8c5ed8f
Refactored FileDeviceKeyStore
d2dyno1 Jun 15, 2026
9b21ed5
Removed MacCatalyst target
d2dyno1 Jun 15, 2026
551bd7f
Disable changing credentials for App Platform vaults
d2dyno1 Jun 15, 2026
c1a0ce9
Update FileDeviceKeyStore.cs
d2dyno1 Jun 15, 2026
f95db4a
Added cancellation when logging into App Platform
d2dyno1 Jun 17, 2026
34fb042
Force re-login on App Platform creation
d2dyno1 Jun 18, 2026
2a7dd34
Added localization strings for App Platform
d2dyno1 Jun 21, 2026
9e8b57d
Fixed build
d2dyno1 Jun 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SecureFolderFS.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
</Project>
</Folder>
<Folder Name="/src/Sdk/">
<Project Path="src/Sdk/SecureFolderFS.Sdk.AppPlatform/SecureFolderFS.Sdk.AppPlatform.csproj" />
<Project Path="src/Sdk/SecureFolderFS.Sdk.Accounts/SecureFolderFS.Sdk.Accounts.csproj" />
<Project Path="src/Sdk/SecureFolderFS.Sdk.DeviceLink/SecureFolderFS.Sdk.DeviceLink.csproj" />
<Project Path="src/Sdk/SecureFolderFS.Sdk.Dropbox/SecureFolderFS.Sdk.Dropbox.csproj" />
Expand Down
136 changes: 136 additions & 0 deletions src/Core/SecureFolderFS.Core.Cryptography/Jwe/AccountKeyHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography;
using Jose;

namespace SecureFolderFS.Core.Cryptography.Jwe
{
/// <summary>
/// Provides PBES2-based JWE operations for Account Key (passphrase) wrapping of EC private keys.
/// Used to bootstrap new devices when no device-specific JWE exists yet.
/// </summary>
public static class AccountKeyHelper
{
private const int AccountKeyPbes2Iterations = 120_000;
private const string AccountKeyAlgorithm = "PBES2-HS512+A256KW";
private const string AccountKeyEncryption = "A256GCM";

/// <summary>
/// Wraps an EC private key (in DER format) under a user-provided passphrase using PBES2-HS512+A256KW / A256GCM.
/// Uses 256-bit AES key wrapping for post-quantum security margin.
/// </summary>
/// <param name="privateKeyBytes">The EC private key bytes (DER-encoded) to wrap.</param>
/// <param name="passphrase">The user-provided Account Key passphrase.</param>
/// <returns>A JWE compact serialization string containing the encrypted private key.</returns>
public static string Wrap(byte[] privateKeyBytes, string passphrase)
{
var headers = new Dictionary<string, object>
{
["p2c"] = AccountKeyPbes2Iterations
};

return JWT.EncodeBytes(privateKeyBytes, passphrase, JweAlgorithm.PBES2_HS512_A256KW, JweEncryption.A256GCM, extraHeaders: headers);
}

/// <summary>
/// Unwraps an EC private key from a PBES2-protected JWE using the Account Key passphrase.
/// </summary>
/// <param name="jweCompact">The JWE compact serialization containing the wrapped private key.</param>
/// <param name="passphrase">The user-provided Account Key passphrase.</param>
/// <returns>The EC private key bytes (DER-encoded).</returns>
public static byte[] Unwrap(string jweCompact, string passphrase)
{
ValidateAccountKeyHeader(jweCompact);
return JWT.DecodeBytes(jweCompact, passphrase, JweAlgorithm.PBES2_HS512_A256KW, JweEncryption.A256GCM);
}

private static void ValidateAccountKeyHeader(string jweCompact)
{
IDictionary<string, object> headers;
try
{
headers = JWT.Headers(jweCompact);
}
catch (Exception ex) when (ex is JoseException or ArgumentException or FormatException)
{
throw new CryptographicException("Invalid Account Key JWE header.", ex);
}

if (!headers.TryGetValue("alg", out var alg) ||
!string.Equals(Convert.ToString(alg, CultureInfo.InvariantCulture), AccountKeyAlgorithm, StringComparison.Ordinal))
{
throw new CryptographicException("Unsupported Account Key JWE algorithm.");
}

if (!headers.TryGetValue("enc", out var enc) ||
!string.Equals(Convert.ToString(enc, CultureInfo.InvariantCulture), AccountKeyEncryption, StringComparison.Ordinal))
{
throw new CryptographicException("Unsupported Account Key JWE content encryption.");
}

if (!headers.TryGetValue("p2c", out var p2c) ||
!TryConvertToInt64(p2c, out var iterations) ||
iterations != AccountKeyPbes2Iterations)
{
throw new CryptographicException("Unexpected Account Key PBES2 iteration count.");
}
}

private static bool TryConvertToInt64(object value, out long result)
{
try
{
result = Convert.ToInt64(value, CultureInfo.InvariantCulture);
return true;
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
result = 0;
return false;
}
}

/// <summary>
/// Wraps a user's EC private key for Account Key bootstrap using PBES2-HS512+A256KW / A256GCM.
/// The private key is stored in JWK format inside the JWE for cross-platform compatibility.
/// </summary>
/// <param name="userPrivateKey">The user's EC private key to wrap.</param>
/// <param name="passphrase">The user-provided Account Key passphrase.</param>
/// <returns>A JWE compact serialization containing the encrypted user private key (as JWK).</returns>
public static string WrapUserKey(ECDiffieHellman userPrivateKey, string passphrase)
{
var privateKeyJwk = EcKeyHelper.ExportPrivateKeyJwk(userPrivateKey);
var privateKeyBytes = System.Text.Encoding.UTF8.GetBytes(privateKeyJwk);
try
{
return Wrap(privateKeyBytes, passphrase);
}
finally
{
CryptographicOperations.ZeroMemory(privateKeyBytes);
}
}

/// <summary>
/// Unwraps a user's EC private key from an Account Key-protected JWE.
/// Expects the JWE to contain the private key in JWK format.
/// </summary>
/// <param name="jweCompact">The JWE compact serialization containing the wrapped user private key.</param>
/// <param name="passphrase">The user-provided Account Key passphrase.</param>
/// <returns>An <see cref="ECDiffieHellman"/> instance with the decrypted user private key.</returns>
public static ECDiffieHellman UnwrapUserKey(string jweCompact, string passphrase)
{
var privateKeyBytes = Unwrap(jweCompact, passphrase);
try
{
var jwk = System.Text.Encoding.UTF8.GetString(privateKeyBytes);
return EcKeyHelper.ImportPrivateKeyJwk(jwk);
}
finally
{
CryptographicOperations.ZeroMemory(privateKeyBytes);
}
}
}
}
201 changes: 201 additions & 0 deletions src/Core/SecureFolderFS.Core.Cryptography/Jwe/EcKeyHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

namespace SecureFolderFS.Core.Cryptography.Jwe
{
/// <summary>
/// Provides EC P-256 key pair generation, JWK serialization, and import/export operations.
/// </summary>
public static class EcKeyHelper
{
/// <summary>
/// Generates a new EC P-256 key pair for ECDH key agreement.
/// </summary>
public static ECDiffieHellman GenerateKeyPair()
{
return ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
}

/// <summary>
/// Exports the public key of an <see cref="ECDiffieHellman"/> instance as a JWK JSON string.
/// </summary>
/// <param name="key">The key pair to export the public component from.</param>
/// <returns>A JSON string in JWK format containing the public key.</returns>
public static string ExportPublicKeyJwk(ECDiffieHellman key)
{
var parameters = key.ExportParameters(includePrivateParameters: false);
return SerializeJwk(parameters, includePrivate: false);
}

/// <summary>
/// Exports the full key pair (public + private) as a JWK JSON string.
/// </summary>
/// <param name="key">The key pair to export.</param>
/// <returns>A JSON string in JWK format containing both public and private key components.</returns>
public static string ExportPrivateKeyJwk(ECDiffieHellman key)
{
var parameters = key.ExportParameters(includePrivateParameters: true);
return SerializeJwk(parameters, includePrivate: true);
}

/// <summary>
/// Exports the private key as a DER-encoded byte array suitable for secure storage.
/// </summary>
/// <param name="key">The key pair to export the private key from.</param>
/// <returns>A byte array containing the private key in SEC1/ECPrivateKey format.</returns>
public static byte[] ExportPrivateKeyBytes(ECDiffieHellman key)
{
return key.ExportECPrivateKey();
}

/// <summary>
/// Imports an EC P-256 public key from a JWK JSON string.
/// </summary>
/// <param name="jwk">The JWK JSON string containing the public key.</param>
/// <returns>An <see cref="ECDiffieHellman"/> instance with only the public key component.</returns>
public static ECDiffieHellman ImportPublicKeyJwk(string jwk)
{
var parameters = DeserializeJwk(jwk);
parameters.D = null;
var ecdh = ECDiffieHellman.Create();
ecdh.ImportParameters(parameters);
return ecdh;
}

/// <summary>
/// Imports an EC P-256 key pair from a JWK JSON string that includes the private key.
/// </summary>
/// <param name="jwk">The JWK JSON string containing both public and private key components.</param>
/// <returns>An <see cref="ECDiffieHellman"/> instance with both public and private key components.</returns>
public static ECDiffieHellman ImportPrivateKeyJwk(string jwk)
{
var parameters = DeserializeJwk(jwk);
var ecdh = ECDiffieHellman.Create();
ecdh.ImportParameters(parameters);
return ecdh;
}

/// <summary>
/// Imports a private key from a DER-encoded byte array (SEC1/ECPrivateKey format).
/// </summary>
/// <param name="privateKeyBytes">The DER-encoded private key bytes.</param>
/// <returns>An <see cref="ECDiffieHellman"/> instance with the imported private key.</returns>
public static ECDiffieHellman ImportPrivateKeyBytes(byte[] privateKeyBytes)
{
var ecdh = ECDiffieHellman.Create();
ecdh.ImportECPrivateKey(privateKeyBytes, out _);
return ecdh;
}

/// <summary>
/// Compares the public EC coordinates in two P-256 JWKs.
/// </summary>
public static bool PublicJwksEqual(string leftJwk, string rightJwk)
{
var left = DeserializeJwk(leftJwk);
var right = DeserializeJwk(rightJwk);

return left.Q.X is not null &&
left.Q.Y is not null &&
right.Q.X is not null &&
right.Q.Y is not null &&
CryptographicOperations.FixedTimeEquals(left.Q.X, right.Q.X) &&
CryptographicOperations.FixedTimeEquals(left.Q.Y, right.Q.Y);
}

/// <summary>
/// Computes the JWK Thumbprint (RFC 7638) for an EC P-256 public key JWK.
/// Uses SHA-256 over the lexicographically-sorted required members: crv, kty, x, y.
/// </summary>
/// <param name="publicKeyJwk">The public key as a JWK JSON string.</param>
/// <returns>A base64url-encoded SHA-256 thumbprint.</returns>
public static string ComputeJwkThumbprint(string publicKeyJwk)
{
using var doc = JsonDocument.Parse(publicKeyJwk);
var root = doc.RootElement;

var crv = root.GetProperty("crv").GetString();
var kty = root.GetProperty("kty").GetString();
var x = root.GetProperty("x").GetString();
var y = root.GetProperty("y").GetString();

// RFC 7638: canonical JSON with required members in lexicographic order
// For EC keys the required members are: crv, kty, x, y
var canonical = $"{{\"crv\":\"{crv}\",\"kty\":\"{kty}\",\"x\":\"{x}\",\"y\":\"{y}\"}}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
return Base64UrlEncode(hash);
}

private static string SerializeJwk(ECParameters parameters, bool includePrivate)
{
using var stream = new System.IO.MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartObject();
writer.WriteString("kty", "EC");
writer.WriteString("crv", "P-256");
writer.WriteString("x", Base64UrlEncode(parameters.Q.X!));
writer.WriteString("y", Base64UrlEncode(parameters.Q.Y!));

if (includePrivate && parameters.D is not null)
writer.WriteString("d", Base64UrlEncode(parameters.D));

writer.WriteEndObject();
}

return Encoding.UTF8.GetString(stream.ToArray());
}

private static ECParameters DeserializeJwk(string jwk)
{
using var doc = JsonDocument.Parse(jwk);
var root = doc.RootElement;

var kty = root.GetProperty("kty").GetString();
var crv = root.GetProperty("crv").GetString();

if (kty != "EC" || crv != "P-256")
throw new CryptographicException($"Unsupported JWK key type or curve: kty={kty}, crv={crv}");

var parameters = new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint
{
X = Base64UrlDecode(root.GetProperty("x").GetString()!),
Y = Base64UrlDecode(root.GetProperty("y").GetString()!)
}
};

if (root.TryGetProperty("d", out var dElement) && dElement.GetString() is { } dValue)
parameters.D = Base64UrlDecode(dValue);

return parameters;
}

private static string Base64UrlEncode(byte[] data)
{
return Convert.ToBase64String(data)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}

private static byte[] Base64UrlDecode(string base64Url)
{
var s = base64Url.Replace('-', '+').Replace('_', '/');
if (s.Length % 4 == 1)
throw new FormatException("Invalid base64url length.");

switch (s.Length % 4)
{
case 2: s += "=="; break;
case 3: s += "="; break;
}

return Convert.FromBase64String(s);
}
}
}
Loading
Loading