inbox: use pgp encryption (#70)

* inbox: use pgp encryption && other fixes
* fix inbox key last used at time
* remove inbox items if keys change or same item id syncs

* inbox:update inbox sync item
* rename item field to sync
* add alg field

* sync: delete inbox items after commit succeeds

* user: merge if conditions

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
This commit is contained in:
01zulfi
2026-03-27 14:06:29 +05:00
committed by GitHub
parent 4bc1469dfe
commit 8d92aff8cd
10 changed files with 87 additions and 124 deletions
@@ -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[]
+4 -20
View File
@@ -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();
+7 -1
View File
@@ -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
-1
View File
@@ -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";
}
}
+46 -28
View File
@@ -20,46 +20,64 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
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;
}
@@ -49,7 +49,7 @@ namespace Notesnook.API.Repositories
this.logger = logger;
}
private readonly List<string> ALGORITHMS = [Algorithms.Default, Algorithms.XSAL_X25519_7];
private readonly List<string> ALGORITHMS = [Algorithms.Default];
private bool IsValidAlgorithm(string algorithm)
{
return ALGORITHMS.Contains(algorithm);
+2
View File
@@ -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);
+3 -5
View File
@@ -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=="],
+1 -1
View File
@@ -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": {
+22 -66
View File
@@ -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<EncryptedInboxItem, "key" | "iv" | "v" | "salt">;
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<EncryptedInboxItem> {
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;