Initial commit
This commit is contained in:
182
packages/sync-engine/src/crypto.ts
Normal file
182
packages/sync-engine/src/crypto.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { EncryptionEnvelope } from "@obsidian-sync/sync-protocol";
|
||||
|
||||
export interface VaultKeyHandle {
|
||||
keyId: string;
|
||||
key: CryptoKey;
|
||||
}
|
||||
|
||||
export interface GeneratedVaultKey extends VaultKeyHandle {
|
||||
exportedKey: string;
|
||||
}
|
||||
|
||||
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
function getCryptoApi(): Crypto {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
throw new Error("Web Crypto API is unavailable in this environment.");
|
||||
}
|
||||
|
||||
return globalThis.crypto;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let output = "";
|
||||
|
||||
for (let index = 0; index < bytes.length; index += 3) {
|
||||
const first = bytes[index] ?? 0;
|
||||
const second = bytes[index + 1] ?? 0;
|
||||
const third = bytes[index + 2] ?? 0;
|
||||
const combined = (first << 16) | (second << 8) | third;
|
||||
|
||||
output += BASE64_ALPHABET[(combined >> 18) & 63];
|
||||
output += BASE64_ALPHABET[(combined >> 12) & 63];
|
||||
output += index + 1 < bytes.length ? BASE64_ALPHABET[(combined >> 6) & 63] : "=";
|
||||
output += index + 2 < bytes.length ? BASE64_ALPHABET[combined & 63] : "=";
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
const normalized = value.replace(/\s+/g, "");
|
||||
const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
|
||||
const outputLength = Math.floor((normalized.length * 3) / 4) - padding;
|
||||
const bytes = new Uint8Array(outputLength);
|
||||
|
||||
let byteIndex = 0;
|
||||
for (let index = 0; index < normalized.length; index += 4) {
|
||||
const first = BASE64_ALPHABET.indexOf(normalized[index] ?? "A");
|
||||
const second = BASE64_ALPHABET.indexOf(normalized[index + 1] ?? "A");
|
||||
const thirdChar = normalized[index + 2] ?? "=";
|
||||
const fourthChar = normalized[index + 3] ?? "=";
|
||||
const third = thirdChar === "=" ? 0 : BASE64_ALPHABET.indexOf(thirdChar);
|
||||
const fourth = fourthChar === "=" ? 0 : BASE64_ALPHABET.indexOf(fourthChar);
|
||||
const combined = (first << 18) | (second << 12) | (third << 6) | fourth;
|
||||
|
||||
bytes[byteIndex] = (combined >> 16) & 255;
|
||||
byteIndex += 1;
|
||||
|
||||
if (thirdChar !== "=" && byteIndex < outputLength + 1) {
|
||||
bytes[byteIndex] = (combined >> 8) & 255;
|
||||
byteIndex += 1;
|
||||
}
|
||||
|
||||
if (fourthChar !== "=" && byteIndex < outputLength + 1) {
|
||||
bytes[byteIndex] = combined & 255;
|
||||
byteIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function toOwnedBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
const view = new Uint8Array(buffer);
|
||||
view.set(bytes);
|
||||
return view;
|
||||
}
|
||||
|
||||
function normalizeBytes(value: ArrayBuffer | Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
return value instanceof Uint8Array ? toOwnedBytes(value) : toOwnedBytes(new Uint8Array(value));
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return toOwnedBytes(bytes).buffer;
|
||||
}
|
||||
|
||||
export async function generateVaultKey(keyId = globalThis.crypto.randomUUID()): Promise<GeneratedVaultKey> {
|
||||
const cryptoApi = getCryptoApi();
|
||||
const key = await cryptoApi.subtle.generateKey(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 256
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
const rawKey = await cryptoApi.subtle.exportKey("raw", key);
|
||||
|
||||
return {
|
||||
keyId,
|
||||
key,
|
||||
exportedKey: bytesToBase64(new Uint8Array(rawKey))
|
||||
};
|
||||
}
|
||||
|
||||
export async function importVaultKey(exportedKey: string, keyId: string): Promise<VaultKeyHandle> {
|
||||
const cryptoApi = getCryptoApi();
|
||||
const rawKey = base64ToBytes(exportedKey);
|
||||
const key = await cryptoApi.subtle.importKey(
|
||||
"raw",
|
||||
toArrayBuffer(rawKey),
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 256
|
||||
},
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
|
||||
return {
|
||||
keyId,
|
||||
key
|
||||
};
|
||||
}
|
||||
|
||||
export async function encryptBytes(plaintext: ArrayBuffer | Uint8Array, vaultKey: VaultKeyHandle): Promise<EncryptionEnvelope> {
|
||||
const cryptoApi = getCryptoApi();
|
||||
const iv = cryptoApi.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await cryptoApi.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv
|
||||
},
|
||||
vaultKey.key,
|
||||
normalizeBytes(plaintext)
|
||||
);
|
||||
|
||||
return {
|
||||
algorithm: "AES-GCM-256",
|
||||
keyId: vaultKey.keyId,
|
||||
iv: bytesToBase64(iv),
|
||||
ciphertext: bytesToBase64(new Uint8Array(ciphertext))
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptBytes(envelope: EncryptionEnvelope, vaultKey: VaultKeyHandle): Promise<ArrayBuffer> {
|
||||
const cryptoApi = getCryptoApi();
|
||||
const plaintext = await cryptoApi.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: toOwnedBytes(base64ToBytes(envelope.iv))
|
||||
},
|
||||
vaultKey.key,
|
||||
toOwnedBytes(base64ToBytes(envelope.ciphertext))
|
||||
);
|
||||
|
||||
return plaintext.slice(0);
|
||||
}
|
||||
|
||||
export async function encryptText(plaintext: string, vaultKey: VaultKeyHandle): Promise<EncryptionEnvelope> {
|
||||
return encryptBytes(new TextEncoder().encode(plaintext), vaultKey);
|
||||
}
|
||||
|
||||
export async function decryptText(envelope: EncryptionEnvelope, vaultKey: VaultKeyHandle): Promise<string> {
|
||||
return new TextDecoder().decode(await decryptBytes(envelope, vaultKey));
|
||||
}
|
||||
|
||||
async function computeHash(bytes: Uint8Array): Promise<string> {
|
||||
const cryptoApi = getCryptoApi();
|
||||
const digest = await cryptoApi.subtle.digest("SHA-256", toOwnedBytes(bytes));
|
||||
return bytesToBase64(new Uint8Array(digest));
|
||||
}
|
||||
|
||||
export async function computeTextHash(value: string): Promise<string> {
|
||||
return computeHash(new TextEncoder().encode(value));
|
||||
}
|
||||
|
||||
export async function computeBinaryHash(value: ArrayBuffer | Uint8Array): Promise<string> {
|
||||
return computeHash(normalizeBytes(value));
|
||||
}
|
||||
2
packages/sync-engine/src/index.ts
Normal file
2
packages/sync-engine/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./crypto";
|
||||
export * from "./merge";
|
||||
59
packages/sync-engine/src/merge.ts
Normal file
59
packages/sync-engine/src/merge.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export interface MergeTextInput {
|
||||
base?: string;
|
||||
local: string;
|
||||
remote: string;
|
||||
}
|
||||
|
||||
export interface MergeTextResult {
|
||||
status: "merged" | "conflict";
|
||||
content: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function mergeTextRevisions(input: MergeTextInput): MergeTextResult {
|
||||
const base = input.base ?? "";
|
||||
|
||||
if (input.local === input.remote) {
|
||||
return {
|
||||
status: "merged",
|
||||
content: input.local
|
||||
};
|
||||
}
|
||||
|
||||
if (base === input.local) {
|
||||
return {
|
||||
status: "merged",
|
||||
content: input.remote
|
||||
};
|
||||
}
|
||||
|
||||
if (base === input.remote) {
|
||||
return {
|
||||
status: "merged",
|
||||
content: input.local
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "conflict",
|
||||
reason: "Both local and remote content changed since the last shared base.",
|
||||
content: [
|
||||
"<<<<<<< LOCAL",
|
||||
input.local,
|
||||
"=======",
|
||||
input.remote,
|
||||
">>>>>>> REMOTE"
|
||||
].join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
export function createConflictCopyPath(path: string): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const extensionIndex = path.lastIndexOf(".");
|
||||
|
||||
if (extensionIndex <= 0) {
|
||||
return `${path}.remote-conflict.${timestamp}`;
|
||||
}
|
||||
|
||||
return `${path.slice(0, extensionIndex)}.remote-conflict.${timestamp}${path.slice(extensionIndex)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user