Initial commit
This commit is contained in:
14
packages/sync-engine/package.json
Normal file
14
packages/sync-engine/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@obsidian-sync/sync-engine",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@obsidian-sync/sync-protocol": "0.1.0"
|
||||
}
|
||||
}
|
||||
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)}`;
|
||||
}
|
||||
18
packages/sync-engine/tsconfig.json
Normal file
18
packages/sync-engine/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "CommonJS"
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../sync-protocol"
|
||||
}
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
14
packages/sync-protocol/package.json
Normal file
14
packages/sync-protocol/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@obsidian-sync/sync-protocol",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -b",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.2"
|
||||
}
|
||||
}
|
||||
194
packages/sync-protocol/src/index.ts
Normal file
194
packages/sync-protocol/src/index.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const SyncFileKindSchema = z.enum(["text", "binary"]);
|
||||
export type SyncFileKind = z.infer<typeof SyncFileKindSchema>;
|
||||
|
||||
export const EncryptionEnvelopeSchema = z.object({
|
||||
algorithm: z.literal("AES-GCM-256"),
|
||||
keyId: z.string().min(1),
|
||||
iv: z.string().min(1),
|
||||
ciphertext: z.string().min(1)
|
||||
});
|
||||
export type EncryptionEnvelope = z.infer<typeof EncryptionEnvelopeSchema>;
|
||||
|
||||
export const FileManifestSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
kind: SyncFileKindSchema,
|
||||
contentHash: z.string().min(1),
|
||||
revisionId: z.string().min(1),
|
||||
baseRevisionId: z.string().min(1).optional(),
|
||||
updatedAt: z.string().datetime(),
|
||||
sizeBytes: z.number().int().nonnegative(),
|
||||
deviceId: z.string().min(1)
|
||||
});
|
||||
export type FileManifest = z.infer<typeof FileManifestSchema>;
|
||||
|
||||
export const SyncFileRecordSchema = z.object({
|
||||
manifest: FileManifestSchema,
|
||||
envelope: EncryptionEnvelopeSchema
|
||||
});
|
||||
export type SyncFileRecord = z.infer<typeof SyncFileRecordSchema>;
|
||||
|
||||
export const TombstoneSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
revisionId: z.string().min(1),
|
||||
baseRevisionId: z.string().min(1).optional(),
|
||||
deletedAt: z.string().datetime(),
|
||||
deviceId: z.string().min(1)
|
||||
});
|
||||
export type Tombstone = z.infer<typeof TombstoneSchema>;
|
||||
|
||||
export const SyncConflictSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
serverRevisionId: z.string().min(1),
|
||||
clientRevisionId: z.string().min(1),
|
||||
reason: z.enum(["revision-mismatch"])
|
||||
});
|
||||
export type SyncConflict = z.infer<typeof SyncConflictSchema>;
|
||||
|
||||
export const SyncChangeSchema = z
|
||||
.object({
|
||||
serverRevision: z.number().int().nonnegative(),
|
||||
file: SyncFileRecordSchema.optional(),
|
||||
tombstone: TombstoneSchema.optional()
|
||||
})
|
||||
.refine((value) => Number(Boolean(value.file)) + Number(Boolean(value.tombstone)) === 1, {
|
||||
message: "A change must include either a file or a tombstone."
|
||||
});
|
||||
export type SyncChange = z.infer<typeof SyncChangeSchema>;
|
||||
|
||||
export const RegisterDeviceRequestSchema = z.object({
|
||||
vaultId: z.string().min(1),
|
||||
deviceName: z.string().min(1)
|
||||
});
|
||||
export type RegisterDeviceRequest = z.infer<typeof RegisterDeviceRequestSchema>;
|
||||
|
||||
export const RegisterDeviceResponseSchema = z.object({
|
||||
vaultId: z.string().min(1),
|
||||
deviceId: z.string().min(1),
|
||||
token: z.string().min(1),
|
||||
issuedAt: z.string().datetime()
|
||||
});
|
||||
export type RegisterDeviceResponse = z.infer<typeof RegisterDeviceResponseSchema>;
|
||||
|
||||
export const RecoveryBundleSchema = z.object({
|
||||
version: z.literal(1),
|
||||
serverUrl: z.string().min(1),
|
||||
vaultId: z.string().min(1),
|
||||
keyId: z.string().min(1),
|
||||
exportedVaultKey: z.string().min(1),
|
||||
generatedAt: z.string().datetime()
|
||||
});
|
||||
export type RecoveryBundle = z.infer<typeof RecoveryBundleSchema>;
|
||||
|
||||
export const DeviceRecordSchema = z.object({
|
||||
deviceId: z.string().min(1),
|
||||
deviceName: z.string().min(1),
|
||||
issuedAt: z.string().datetime(),
|
||||
revokedAt: z.string().datetime().optional()
|
||||
});
|
||||
export type DeviceRecord = z.infer<typeof DeviceRecordSchema>;
|
||||
|
||||
export const ListDevicesRequestSchema = z.object({
|
||||
vaultId: z.string().min(1),
|
||||
deviceId: z.string().min(1)
|
||||
});
|
||||
export type ListDevicesRequest = z.infer<typeof ListDevicesRequestSchema>;
|
||||
|
||||
export const ListDevicesResponseSchema = z.object({
|
||||
devices: z.array(DeviceRecordSchema),
|
||||
activeKeyId: z.string().min(1).optional(),
|
||||
keyRotatedAt: z.string().datetime().optional()
|
||||
});
|
||||
export type ListDevicesResponse = z.infer<typeof ListDevicesResponseSchema>;
|
||||
|
||||
export const RevokeDeviceRequestSchema = z.object({
|
||||
vaultId: z.string().min(1),
|
||||
deviceId: z.string().min(1),
|
||||
targetDeviceId: z.string().min(1)
|
||||
});
|
||||
export type RevokeDeviceRequest = z.infer<typeof RevokeDeviceRequestSchema>;
|
||||
|
||||
export const RevokeDeviceResponseSchema = z.object({
|
||||
targetDeviceId: z.string().min(1),
|
||||
revokedAt: z.string().datetime()
|
||||
});
|
||||
export type RevokeDeviceResponse = z.infer<typeof RevokeDeviceResponseSchema>;
|
||||
|
||||
export const RotateVaultKeyRequestSchema = z.object({
|
||||
vaultId: z.string().min(1),
|
||||
deviceId: z.string().min(1),
|
||||
nextKeyId: z.string().min(1),
|
||||
previousKeyId: z.string().min(1).optional()
|
||||
});
|
||||
export type RotateVaultKeyRequest = z.infer<typeof RotateVaultKeyRequestSchema>;
|
||||
|
||||
export const RotateVaultKeyResponseSchema = z.object({
|
||||
activeKeyId: z.string().min(1),
|
||||
rotatedAt: z.string().datetime()
|
||||
});
|
||||
export type RotateVaultKeyResponse = z.infer<typeof RotateVaultKeyResponseSchema>;
|
||||
|
||||
export const SyncPullRequestSchema = z.object({
|
||||
vaultId: z.string().min(1),
|
||||
deviceId: z.string().min(1),
|
||||
sinceServerRevision: z.number().int().nonnegative(),
|
||||
limit: z.number().int().positive().max(500).optional()
|
||||
});
|
||||
export type SyncPullRequest = z.infer<typeof SyncPullRequestSchema>;
|
||||
|
||||
export const SyncPullResponseSchema = z.object({
|
||||
serverRevision: z.number().int().nonnegative(),
|
||||
changes: z.array(SyncChangeSchema),
|
||||
hasMore: z.boolean(),
|
||||
nextSinceServerRevision: z.number().int().nonnegative(),
|
||||
activeKeyId: z.string().min(1).optional(),
|
||||
keyRotatedAt: z.string().datetime().optional()
|
||||
});
|
||||
export type SyncPullResponse = z.infer<typeof SyncPullResponseSchema>;
|
||||
|
||||
export const SyncPushRequestSchema = z.object({
|
||||
vaultId: z.string().min(1),
|
||||
deviceId: z.string().min(1),
|
||||
knownServerRevision: z.number().int().nonnegative(),
|
||||
files: z.array(SyncFileRecordSchema),
|
||||
tombstones: z.array(TombstoneSchema)
|
||||
});
|
||||
export type SyncPushRequest = z.infer<typeof SyncPushRequestSchema>;
|
||||
|
||||
export const SyncPushResponseSchema = z.object({
|
||||
acceptedServerRevision: z.number().int().nonnegative(),
|
||||
acceptedFilePaths: z.array(z.string()),
|
||||
acceptedTombstones: z.array(z.string()),
|
||||
conflicts: z.array(SyncConflictSchema),
|
||||
activeKeyId: z.string().min(1).optional(),
|
||||
keyRotatedAt: z.string().datetime().optional()
|
||||
});
|
||||
export type SyncPushResponse = z.infer<typeof SyncPushResponseSchema>;
|
||||
|
||||
export const ClientLogEntrySchema = z.object({
|
||||
level: z.enum(["debug", "info", "warn", "error"]),
|
||||
message: z.string().min(1),
|
||||
timestamp: z.string().datetime(),
|
||||
deviceId: z.string().min(1),
|
||||
context: z.record(z.string(), z.unknown()).optional()
|
||||
});
|
||||
export type ClientLogEntry = z.infer<typeof ClientLogEntrySchema>;
|
||||
|
||||
export const ClientLogUploadRequestSchema = z.object({
|
||||
vaultId: z.string().min(1),
|
||||
deviceId: z.string().min(1),
|
||||
runId: z.string().min(1),
|
||||
entries: z.array(ClientLogEntrySchema)
|
||||
});
|
||||
export type ClientLogUploadRequest = z.infer<typeof ClientLogUploadRequestSchema>;
|
||||
|
||||
export const ClientLogUploadResponseSchema = z.object({
|
||||
accepted: z.number().int().nonnegative(),
|
||||
requestId: z.string().min(1)
|
||||
});
|
||||
export type ClientLogUploadResponse = z.infer<typeof ClientLogUploadResponseSchema>;
|
||||
|
||||
export function parseWithSchema<T>(schema: z.ZodSchema<T>, value: unknown): T {
|
||||
return schema.parse(value);
|
||||
}
|
||||
13
packages/sync-protocol/tsconfig.json
Normal file
13
packages/sync-protocol/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "CommonJS"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user