using System; using System.IO; using System.Net; using System.Net.Security; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; namespace Clipman { internal sealed class ServerStorageClient { internal const long MaximumServerTransferBytes = 272L * 1024L * 1024L; private readonly string baseUrl; private readonly string token; private readonly string databaseId; private readonly bool hasDatabasePassword; private readonly X509Certificate2 privateAuthority; public ServerStorageClient( string serverUrl, string token, string databasePassword, string caCertPem, string caHost) : this(serverUrl, token, databasePassword, caCertPem, caHost, null) { } /// /// Addresses one bucket on the configured server. is /// blank for the history database and carries a derived sync-channel or sync-rules id /// otherwise (sync-rules-spec.md section 2). /// public ServerStorageClient( string serverUrl, string token, string databasePassword, string caCertPem, string caHost, string explicitDatabaseId) { baseUrl = NormalizeBaseUrl(serverUrl); this.token = ServerSettingsSanitizer.CleanToken(token); hasDatabasePassword = !string.IsNullOrEmpty(databasePassword); databaseId = string.IsNullOrEmpty(explicitDatabaseId) ? ServerDatabaseIdentity.FromTokenAndPassword(this.token, databasePassword) : explicitDatabaseId; privateAuthority = ParsePrivateAuthority(caCertPem, caHost, serverUrl); } public bool IsConfigured { get { return baseUrl.Length > 0 && token.Trim().Length > 0 && hasDatabasePassword && databaseId.Length > 0; } } public ServerDatabaseMetadata GetMetadata() { var request = CreateRequest(DatabasePath(), "HEAD"); using (var response = (HttpWebResponse)request.GetResponse()) { return MetadataFromResponse(response); } } public ServerDatabaseDownload Download() { var request = CreateRequest(DatabasePath(), "GET"); using (var response = (HttpWebResponse)request.GetResponse()) using (var memory = new MemoryStream()) { ValidateServerTransferLength(response.ContentLength); var stream = response.GetResponseStream(); if (stream == null) throw new InvalidDataException("Clipman Server returned an empty database response."); var buffer = new byte[64 * 1024]; int count; while ((count = stream.Read(buffer, 0, buffer.Length)) > 0) { ValidateServerTransferLength(memory.Length + count); memory.Write(buffer, 0, count); } return new ServerDatabaseDownload { Metadata = MetadataFromResponse(response), Data = memory.ToArray() }; } } public ServerDatabaseMetadata Upload(byte[] data, string expectedRevision) { return Upload(data, expectedRevision, false); } /// /// Uploads a bucket. sends If-None-Match: * so a bucket /// another device wrote in the meantime always wins (sync-rules-spec.md section 4, Caching). /// public ServerDatabaseMetadata Upload(byte[] data, string expectedRevision, bool createOnly) { ValidateServerTransferLength(data == null ? 0 : data.LongLength); var request = CreateRequest(DatabasePath(), "PUT"); if (createOnly) { request.Headers["If-None-Match"] = "*"; } else if (!string.IsNullOrWhiteSpace(expectedRevision)) { request.Headers["If-Match"] = "\"" + expectedRevision.Trim('"') + "\""; } request.ContentType = "application/octet-stream"; request.ContentLength = data == null ? 0 : data.Length; using (var output = request.GetRequestStream()) { if (data != null && data.Length > 0) { output.Write(data, 0, data.Length); } } using (var response = (HttpWebResponse)request.GetResponse()) { return MetadataFromResponse(response); } } internal static void ValidateServerTransferLength(long length) { if (length > MaximumServerTransferBytes) { throw new InvalidDataException("The Clipman Server database transfer exceeds the 272 MiB client safety limit."); } } public bool IsNotFound(WebException ex) { var response = ex == null ? null : ex.Response as HttpWebResponse; return response != null && response.StatusCode == HttpStatusCode.NotFound; } public bool IsConflict(WebException ex) { var response = ex == null ? null : ex.Response as HttpWebResponse; return response != null && response.StatusCode == HttpStatusCode.Conflict; } private HttpWebRequest CreateRequest(string relativePath, string method) { if (!IsConfigured) { throw new InvalidOperationException("Clipman server host, token, and history password are required."); } NetworkSecurity.EnableModernTls(); var request = (HttpWebRequest)WebRequest.Create(new Uri(new Uri(baseUrl), relativePath)); request.Method = method; request.Timeout = 8000; request.ReadWriteTimeout = 8000; request.AllowAutoRedirect = false; request.Headers["Authorization"] = "Bearer " + token.Trim(); request.UserAgent = "Clipman/" + VersionString(); if (privateAuthority != null) { request.ServerCertificateValidationCallback = ValidatePrivateAuthority; } return request; } private bool ValidatePrivateAuthority( object sender, X509Certificate certificate, X509Chain suppliedChain, SslPolicyErrors errors) { if (certificate == null || privateAuthority == null) return false; if ((errors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0 || (errors & SslPolicyErrors.RemoteCertificateNotAvailable) != 0) { return false; } using (var leaf = new X509Certificate2(certificate)) using (var chain = new X509Chain()) { chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; chain.ChainPolicy.ApplicationPolicy.Add(new Oid("1.3.6.1.5.5.7.3.1")); chain.ChainPolicy.ExtraStore.Add(privateAuthority); if (suppliedChain != null) { foreach (X509ChainElement element in suppliedChain.ChainElements) { if (!string.Equals(element.Certificate.Thumbprint, leaf.Thumbprint, StringComparison.OrdinalIgnoreCase)) { chain.ChainPolicy.ExtraStore.Add(element.Certificate); } } } chain.Build(leaf); foreach (X509ChainStatus status in chain.ChainStatus) { if (status.Status != X509ChainStatusFlags.NoError && status.Status != X509ChainStatusFlags.UntrustedRoot) { return false; } } if (chain.ChainElements.Count == 0) return false; var root = chain.ChainElements[chain.ChainElements.Count - 1].Certificate; return ByteArraysEqual(root.RawData, privateAuthority.RawData); } } private static X509Certificate2 ParsePrivateAuthority(string pem, string expectedHost, string serverUrl) { if (string.IsNullOrWhiteSpace(pem)) return null; ServerCertificateAuthority authority; string error; if (!ServerSettingsSanitizer.TryParseCertificateAuthority(pem, serverUrl, out authority, out error) || authority == null) { throw new InvalidDataException("Clipman could not use the configured private certificate authority: " + error); } if (!string.IsNullOrWhiteSpace(expectedHost) && !string.Equals(expectedHost.Trim(), authority.Host, StringComparison.OrdinalIgnoreCase)) { throw new InvalidDataException("The private certificate authority is configured for a different server host."); } return new X509Certificate2(authority.RawData); } private static bool ByteArraysEqual(byte[] first, byte[] second) { if (first == null || second == null || first.Length != second.Length) return false; var difference = 0; for (var index = 0; index < first.Length; index++) { difference |= first[index] ^ second[index]; } return difference == 0; } private static ServerDatabaseMetadata MetadataFromResponse(HttpWebResponse response) { return new ServerDatabaseMetadata { Revision = CleanRevision(response.Headers["X-Clipman-Revision"] ?? response.Headers["ETag"]), Length = response.ContentLength < 0 ? 0 : response.ContentLength }; } private static string NormalizeBaseUrl(string value) { var url = ServerSettingsSanitizer.CleanTransportUrl(value); if (url.Length == 0) return string.Empty; if (!url.EndsWith("/", StringComparison.Ordinal)) url += "/"; return url; } private string DatabasePath() { return "api/v1/database/" + Uri.EscapeDataString(databaseId); } private static string CleanRevision(string value) { return (value ?? string.Empty).Trim().Trim('"'); } private static string VersionString() { var version = typeof(ServerStorageClient).Assembly.GetName().Version; return version == null ? "unknown" : version.ToString(); } } internal sealed class ServerDatabaseMetadata { public string Revision { get; set; } public long Length { get; set; } public ServerDatabaseMetadata() { Revision = string.Empty; } } internal sealed class ServerDatabaseDownload { public ServerDatabaseMetadata Metadata { get; set; } public byte[] Data { get; set; } public ServerDatabaseDownload() { Metadata = new ServerDatabaseMetadata(); Data = new byte[0]; } } }