diff --git a/Notesnook.API/Authorization/InboxApiKeyAuthenticationHandler.cs b/Notesnook.API/Authorization/InboxApiKeyAuthenticationHandler.cs index 66c3899..6147871 100644 --- a/Notesnook.API/Authorization/InboxApiKeyAuthenticationHandler.cs +++ b/Notesnook.API/Authorization/InboxApiKeyAuthenticationHandler.cs @@ -78,7 +78,7 @@ namespace Notesnook.API.Authorization return AuthenticateResult.Fail("API key has expired"); } - inboxApiKey.LastUsedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + inboxApiKey.LastUsedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); await _inboxApiKeyRepository.UpsertAsync(inboxApiKey, k => k.Key == apiKey); var claims = new[] diff --git a/Notesnook.API/Controllers/InboxController.cs b/Notesnook.API/Controllers/InboxController.cs index b15c9bf..1d0053e 100644 --- a/Notesnook.API/Controllers/InboxController.cs +++ b/Notesnook.API/Controllers/InboxController.cs @@ -151,34 +151,18 @@ namespace Notesnook.API.Controllers var userId = User.GetUserId(); try { - if (request.Key.Algorithm != Algorithms.XSAL_X25519_7) + if (string.IsNullOrWhiteSpace(request.Cipher)) { - return BadRequest(new { error = $"Only {Algorithms.XSAL_X25519_7} is supported for inbox item password." }); + return BadRequest(new { error = "Inbox item is required." }); } - if (string.IsNullOrWhiteSpace(request.Key.Cipher)) + if (string.IsNullOrWhiteSpace(request.Algorithm)) { - return BadRequest(new { error = "Inbox item password cipher is required." }); - } - if (request.Key.Length <= 0) - { - return BadRequest(new { error = "Valid inbox item password length is required." }); - } - if (request.Algorithm != Algorithms.Default) - { - return BadRequest(new { error = $"Only {Algorithms.Default} is supported for inbox item." }); + return BadRequest(new { error = "Inbox item algorithm is required." }); } if (request.Version <= 0) { return BadRequest(new { error = "Valid inbox item version is required." }); } - if (string.IsNullOrWhiteSpace(request.Cipher) || string.IsNullOrWhiteSpace(request.IV)) - { - return BadRequest(new { error = "Inbox item cipher and iv is required." }); - } - if (request.Length <= 0) - { - return BadRequest(new { error = "Valid inbox item length is required." }); - } request.UserId = userId; request.ItemId = ObjectId.GenerateNewId().ToString(); diff --git a/Notesnook.API/Hubs/SyncV2Hub.cs b/Notesnook.API/Hubs/SyncV2Hub.cs index 3215718..f81e7cd 100644 --- a/Notesnook.API/Hubs/SyncV2Hub.cs +++ b/Notesnook.API/Hubs/SyncV2Hub.cs @@ -132,13 +132,19 @@ namespace Notesnook.API.Hubs var stopwatch = Stopwatch.StartNew(); try { - var UpsertItems = UpsertActionsMap[pushItem.Type] ?? throw new Exception($"Invalid item type: {pushItem.Type}."); UpsertItems(pushItem.Items, userId, 1); if (!await unit.Commit()) return 0; await SyncDeviceService.AddIdsToOtherDevicesAsync(userId, deviceId, pushItem.Items.Select((i) => new ItemKey(i.ItemId, pushItem.Type))); + + // we need to delete the inbox items from the inbox collection + // after syncing to prevent them from being sent again in the + // next fetch. + var itemIds = pushItem.Items.Select(i => i.ItemId).ToList(); + await Repositories.InboxItems.DeleteManyAsync(i => i.UserId == userId && itemIds.Contains(i.ItemId)); + return 1; } finally diff --git a/Notesnook.API/Models/Algorithms.cs b/Notesnook.API/Models/Algorithms.cs index 91d4e2a..cea365a 100644 --- a/Notesnook.API/Models/Algorithms.cs +++ b/Notesnook.API/Models/Algorithms.cs @@ -22,6 +22,5 @@ namespace Notesnook.API.Models public class Algorithms { public static string Default => "xcha-argon2i13-7"; - public static string XSAL_X25519_7 => "xsal-x25519-7"; } } \ No newline at end of file diff --git a/Notesnook.API/Models/InboxSyncItem.cs b/Notesnook.API/Models/InboxSyncItem.cs index 9948714..b4d9119 100644 --- a/Notesnook.API/Models/InboxSyncItem.cs +++ b/Notesnook.API/Models/InboxSyncItem.cs @@ -20,46 +20,64 @@ along with this program. If not, see . using System.ComponentModel.DataAnnotations; using System.Runtime.Serialization; using System.Text.Json.Serialization; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; namespace Notesnook.API.Models { [MessagePack.MessagePackObject] - public class InboxSyncItem : SyncItem + public class InboxSyncItem { - [DataMember(Name = "key")] - [JsonPropertyName("key")] - [MessagePack.Key("key")] - [Required] - public required EncryptedKey Key { get; set; } - - [DataMember(Name = "salt")] - [JsonPropertyName("salt")] - [MessagePack.Key("salt")] - [Required] - public required string Salt { get; set; } - } - - [MessagePack.MessagePackObject] - public class EncryptedKey - { - [DataMember(Name = "alg")] - [JsonPropertyName("alg")] - [MessagePack.Key("alg")] - [Required] - public required string Algorithm { get; set; } - [DataMember(Name = "cipher")] [JsonPropertyName("cipher")] [MessagePack.Key("cipher")] [Required] - public required string Cipher { get; set; } + public string Cipher + { + get; set; + } - [JsonPropertyName("length")] - [DataMember(Name = "length")] - [MessagePack.Key("length")] + [DataMember(Name = "userId")] + [JsonPropertyName("userId")] + [MessagePack.Key("userId")] + public string? UserId + { + get; set; + } + + [DataMember(Name = "id")] + [JsonPropertyName("id")] + [MessagePack.Key("id")] + public string? ItemId + { + get; set; + } + + [BsonId] + [BsonIgnoreIfDefault] + [BsonRepresentation(BsonType.ObjectId)] + [JsonIgnore] + [MessagePack.IgnoreMember] + public ObjectId Id + { + get; set; + } + + [JsonPropertyName("v")] + [DataMember(Name = "v")] + [MessagePack.Key("v")] [Required] - public long Length + public double Version + { + get; set; + } + + [JsonPropertyName("alg")] + [DataMember(Name = "alg")] + [MessagePack.Key("alg")] + [Required] + public string Algorithm { get; set; } diff --git a/Notesnook.API/Repositories/SyncItemsRepository.cs b/Notesnook.API/Repositories/SyncItemsRepository.cs index ba5e0fb..ed62fc6 100644 --- a/Notesnook.API/Repositories/SyncItemsRepository.cs +++ b/Notesnook.API/Repositories/SyncItemsRepository.cs @@ -49,7 +49,7 @@ namespace Notesnook.API.Repositories this.logger = logger; } - private readonly List ALGORITHMS = [Algorithms.Default, Algorithms.XSAL_X25519_7]; + private readonly List ALGORITHMS = [Algorithms.Default]; private bool IsValidAlgorithm(string algorithm) { return ALGORITHMS.Contains(algorithm); diff --git a/Notesnook.API/Services/UserService.cs b/Notesnook.API/Services/UserService.cs index da4b9b7..fdd1841 100644 --- a/Notesnook.API/Services/UserService.cs +++ b/Notesnook.API/Services/UserService.cs @@ -184,6 +184,8 @@ namespace Notesnook.API.Services }; await Repositories.InboxApiKey.InsertAsync(defaultInboxKey); } + + await Repositories.InboxItems.DeleteManyAsync(t => t.UserId == userId); } await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings); diff --git a/Notesnook.Inbox.API/bun.lock b/Notesnook.Inbox.API/bun.lock index 22dbdc6..7a9a2d0 100644 --- a/Notesnook.Inbox.API/bun.lock +++ b/Notesnook.Inbox.API/bun.lock @@ -6,7 +6,7 @@ "dependencies": { "express": "^5.1.0", "express-rate-limit": "^8.1.0", - "libsodium-wrappers-sumo": "^0.7.15", + "openpgp": "^6.2.2", "zod": "^4.1.9", }, "devDependencies": { @@ -116,10 +116,6 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "libsodium-sumo": ["libsodium-sumo@0.7.15", "", {}, "sha512-5tPmqPmq8T8Nikpm1Nqj0hBHvsLFCXvdhBFV7SGOitQPZAA6jso8XoL0r4L7vmfKXr486fiQInvErHtEvizFMw=="], - - "libsodium-wrappers-sumo": ["libsodium-wrappers-sumo@0.7.15", "", { "dependencies": { "libsodium-sumo": "^0.7.15" } }, "sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA=="], - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -140,6 +136,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "openpgp": ["openpgp@6.2.2", "", {}, "sha512-P/dyEqQ3gfwOCo+xsqffzXjmUhGn4AZTOJ1LCcN21S23vAk+EAvMJOQTsb/C8krL6GjOSBxqGYckhik7+hneNw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], diff --git a/Notesnook.Inbox.API/package.json b/Notesnook.Inbox.API/package.json index 2195ffa..ff77763 100644 --- a/Notesnook.Inbox.API/package.json +++ b/Notesnook.Inbox.API/package.json @@ -22,7 +22,7 @@ "dependencies": { "express": "^5.1.0", "express-rate-limit": "^8.1.0", - "libsodium-wrappers-sumo": "^0.7.15", + "openpgp": "^6.2.2", "zod": "^4.1.9" }, "devDependencies": { diff --git a/Notesnook.Inbox.API/src/index.ts b/Notesnook.Inbox.API/src/index.ts index bf3f406..c48affe 100644 --- a/Notesnook.Inbox.API/src/index.ts +++ b/Notesnook.Inbox.API/src/index.ts @@ -1,15 +1,13 @@ import express from "express"; -import _sodium, { base64_variants } from "libsodium-wrappers-sumo"; import { z } from "zod"; import { rateLimit } from "express-rate-limit"; +import * as openpgp from "openpgp"; const NOTESNOOK_API_SERVER_URL = process.env.NOTESNOOK_API_SERVER_URL; if (!NOTESNOOK_API_SERVER_URL) { throw new Error("NOTESNOOK_API_SERVER_URL is not defined"); } -let sodium: typeof _sodium; - const RawInboxItemSchema = z.object({ title: z.string().min(1, "Title is required"), pinned: z.boolean().optional(), @@ -31,62 +29,25 @@ const RawInboxItemSchema = z.object({ interface EncryptedInboxItem { v: 1; - key: Omit; - iv: string; - alg: string; cipher: string; - length: number; - salt: string; + alg: string; } -function encrypt(rawData: string, publicKey: string): EncryptedInboxItem { - try { - const password = sodium.crypto_aead_xchacha20poly1305_ietf_keygen(); - const saltBytes = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); - const key = sodium.crypto_pwhash( - sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES, - password, - saltBytes, - 3, // operations limit - 1024 * 1024 * 8, // memory limit (8MB) - sodium.crypto_pwhash_ALG_ARGON2I13 - ); - const nonce = sodium.randombytes_buf( - sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES - ); - const data = sodium.from_string(rawData); - const cipher = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( - data, - null, - null, - nonce, - key - ); - const inboxPublicKey = sodium.from_base64( - publicKey, - base64_variants.URLSAFE_NO_PADDING - ); - const encryptedKey = sodium.crypto_box_seal(key, inboxPublicKey); - - return { - v: 1, - key: { - cipher: sodium.to_base64( - encryptedKey, - base64_variants.URLSAFE_NO_PADDING - ), - alg: `xsal-x25519-${base64_variants.URLSAFE_NO_PADDING}`, - length: password.length, - }, - iv: sodium.to_base64(nonce, base64_variants.URLSAFE_NO_PADDING), - alg: `xcha-argon2i13-${base64_variants.URLSAFE_NO_PADDING}`, - cipher: sodium.to_base64(cipher, base64_variants.URLSAFE_NO_PADDING), - length: data.length, - salt: sodium.to_base64(saltBytes, base64_variants.URLSAFE_NO_PADDING), - }; - } catch (error) { - throw new Error(`encryption failed: ${error}`); - } +async function encrypt( + rawData: string, + rawPublicKey: string +): Promise { + const publicKey = await openpgp.readKey({ armoredKey: rawPublicKey }); + const message = await openpgp.createMessage({ text: rawData }); + const encrypted = await openpgp.encrypt({ + message, + encryptionKeys: publicKey, + }); + return { + v: 1, + cipher: encrypted, + alg: "pgp-aes256", + }; } async function getInboxPublicEncryptionKey(apiKey: string) { @@ -154,7 +115,7 @@ app.post("/inbox", async (req, res) => { }); } - const encryptedItem = encrypt( + const encryptedItem = await encrypt( JSON.stringify(validationResult.data), inboxPublicKey ); @@ -180,14 +141,9 @@ app.post("/inbox", async (req, res) => { } }); -(async () => { - await _sodium.ready; - sodium = _sodium; - - const PORT = Number(process.env.PORT || "5181"); - app.listen(PORT, () => { - console.log(`📫 notesnook inbox api server running on port ${PORT}`); - }); -})(); +const PORT = Number(process.env.PORT || "5181"); +app.listen(PORT, () => { + console.log(`📫 notesnook inbox api server running on port ${PORT}`); +}); export default app;