Skip to content

Commit

Permalink
add basic encrypted drive class
Browse files Browse the repository at this point in the history
  • Loading branch information
hzrd149 committed Mar 14, 2024
1 parent 7696048 commit ac73296
Show file tree
Hide file tree
Showing 12 changed files with 295 additions and 104 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@
"format": "prettier -w ."
},
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/hashes": "^1.3.3",
"@nostr-dev-kit/ndk": "2.3.3",
"@nostr-dev-kit/ndk-cache-dexie": "2.2.4",
"@nostr-dev-kit/ndk-svelte": "2.2.4",
"@nostr-dev-kit/ndk-svelte-components": "2.2.4",
"@scure/base": "^1.1.5",
"blossom-client": "^0.2.1",
"dayjs": "^1.11.10",
"events": "^3.3.0",
Expand Down
148 changes: 91 additions & 57 deletions src/blossom-drive-client/Drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import type { SignedEvent, EventTemplate, Signer } from "blossom-client";
import EventEmitter from "events";
import { nip19 } from "nostr-tools";

import type TreeFolder from "./FileTree/TreeFolder";
import TreeFolder from "./FileTree/TreeFolder";
import { createTreeFromTags, updateTreeInTags } from "./FileTree/nostr";
import { getFile, getFolder, getPath, setFile, type Path, remove, move } from "./FileTree/methods";
import type { FileMetadata } from "./FileTree/TreeFile";
import TreeFile from "./FileTree/TreeFile";

function now() {
return Math.floor(Date.now() / 1000);
Expand All @@ -14,41 +15,57 @@ function now() {
export const DRIVE_KIND = 30563;

export type Publisher = (event: SignedEvent) => Promise<void>;
export type DriveMetadata = {
name: string;
identifier: string;
description: string;
servers: string[];
pubkey?: string;
treeTags: string[][];
};

export const emptyMetadata: DriveMetadata = { name: "", identifier: "", description: "", servers: [], treeTags: [] };

export default class Drive extends EventEmitter {
tree: TreeFolder;
event: EventTemplate;

identifier: string;
pubkey?: string;
event?: EventTemplate | SignedEvent;

/** whether the drive has been modified and needs to be saved */
modified = false;

protected _name: string = "";
protected _description: string = "";
protected _servers: string[] = [];
protected _metadata: DriveMetadata = emptyMetadata;
get pubkey() {
return this._metadata.pubkey;
}
get identifier() {
return this._metadata.identifier;
}
set identifier(v: string) {
this._metadata.identifier = v;
this.modified = true;
this.emit("change", this);
}
get name() {
return this._name;
return this._metadata.name;
}
set name(v: string) {
this._name = v;
this._metadata.name = v;
this.modified = true;
this.emit("change", this);
}
get description() {
return this._description;
return this._metadata.description;
}
set description(v: string) {
this._description = v;
this._metadata.description = v;
this.modified = true;
this.emit("change", this);
}
get servers() {
return this._servers;
return this._metadata.servers;
}
set servers(v: string[]) {
this._servers = v;
this._metadata.servers = v;
this.modified = true;
this.emit("change", this);
}
Expand All @@ -57,77 +74,87 @@ export default class Drive extends EventEmitter {
publisher: Publisher;

get address() {
if (!this.event) return "";
return this.pubkey
? nip19.naddrEncode({ identifier: this.identifier, pubkey: this.pubkey, kind: this.event.kind })
: "";
}

constructor(event: EventTemplate | SignedEvent, signer: Signer, publisher: Publisher) {
static fromEvent(event: SignedEvent, signer: Signer, publisher: Publisher) {
const drive = new Drive(signer, publisher);
drive.update(event);
return drive;
}

constructor(signer: Signer, publisher: Publisher) {
super();
this.event = event;
this.signer = signer;
this.publisher = publisher;
this.tree = new TreeFolder("");
}

// @ts-expect-error
if (Object.hasOwn(event, "pubkey")) this.pubkey = event.pubkey;
protected createEventTemplate() {
let newTags = updateTreeInTags(this.event?.tags || [], this.tree);

const replaceTags = ["name", "description", "d"];
newTags = newTags.filter((t) => !replaceTags.includes(t[0]));
newTags.unshift(["name", this.name], ["description", this.description], ["d", this.identifier]);

const template: EventTemplate = {
kind: DRIVE_KIND,
content: this.event?.content || "",
created_at: now(),
tags: newTags,
};

return template;
}
protected readEvent(event: EventTemplate | SignedEvent): DriveMetadata {
const name = this.event?.tags.find((t) => t[0] === "name")?.[1] ?? this.identifier ?? "";
const description = this.event?.tags.find((t) => t[0] === "description")?.[1] ?? "";
const servers =
this.event?.tags.filter((t) => t[0] === "r" && t[1]).map((t) => new URL("/", t[1]).toString()) || [];

const identifier = event.tags.find((t) => t[0] === "d")?.[1];
if (!identifier) throw new Error("Missing d tag");

const d = event.tags.find((t) => t[0] === "d")?.[1];
if (!d) throw new Error("Missing d tag");
this.identifier = d;
let pubkey: string | undefined = undefined;
// @ts-expect-error
if (Object.hasOwn(event, "pubkey")) pubkey = event.pubkey;

this.resetMetadata();
const treeTags = event.tags.filter((t) => t[0] === "x" || t[0] === "folder");

this.tree = createTreeFromTags(event.tags);
return { name, description, servers, identifier, pubkey, treeTags };
}

async save() {
if (!this.modified) return;
try {
let newTags = updateTreeInTags(this.event.tags, this.tree);

newTags = newTags.filter((t) => t[0] !== "name" && t[0] !== "description");
newTags.unshift(["name", this.name], ["description", this.description]);

const signed = await this.signer({
kind: DRIVE_KIND,
content: this.event.content || "",
created_at: now(),
tags: newTags,
});
await this.publisher(signed);
this.update(signed);
return signed;
} catch (e) {
this.reset();
throw e;
}
const signed = await this.signer(this.createEventTemplate());
await this.publisher(signed);
this.update(signed);
return signed;
}

update(event: EventTemplate | SignedEvent) {
if (event.kind !== DRIVE_KIND) return false;

if (event.created_at > this.event.created_at) {
async update(event: EventTemplate | SignedEvent) {
if (!this.event || event.created_at > this.event.created_at) {
this.event = event;

// @ts-expect-error
if (Object.hasOwn(event, "pubkey")) this.pubkey = event.pubkey;

this.reset();
this.resetFromEvent();
this.emit("update", this);
return true;
}
return false;
}

protected resetMetadata() {
this._name = this.event.tags.find((t) => t[0] === "name")?.[1] ?? this.identifier ?? "";
this._description = this.event.tags.find((t) => t[0] === "description")?.[1] ?? "";
this._servers = this.event.tags.filter((t) => t[0] === "r" && t[1]).map((t) => new URL("/", t[1]).toString());
protected resetFromEvent() {
if (!this.event) return;
this._metadata = this.readEvent(this.event);
this.tree = createTreeFromTags(this._metadata.treeTags);
this.modified = false;
}
reset() {
if (this.modified) {
this.tree = createTreeFromTags(this.event.tags);
this.resetMetadata();
this.resetFromEvent();
this.modified = false;
this.emit("change", this);
}
Expand Down Expand Up @@ -164,7 +191,14 @@ export default class Drive extends EventEmitter {
}

hasHash(sha256: string) {
return this.event.tags.some((t) => t[0] === "x" && t[1] === sha256);
const walk = (entry: TreeFolder) => {
for (const child of entry) {
if (child instanceof TreeFile && child.sha256 === sha256) return true;
if (child instanceof TreeFolder && walk(child)) return true;
}
return false;
};
return walk(this.tree);
}

[Symbol.iterator]() {
Expand Down
71 changes: 71 additions & 0 deletions src/blossom-drive-client/EncryptedDrive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { EventTemplate, SignedEvent } from "blossom-client";
import Drive, { emptyMetadata, type DriveMetadata } from "./Drive";
import { base64 } from "@scure/base";
import { decrypt, encrypt } from "./crypto";
import TreeFolder from "./FileTree/TreeFolder";

export const ENCRYPTED_DRIVE_KIND = 30564;

const drivePassword = new WeakMap<EncryptedDrive, string>();
export class EncryptedDrive extends Drive {
private encoder = new TextEncoder();
private decoder = new TextDecoder();

unlocked = false;

async unlock(password: string) {
if (!this.event) throw new Error("No Event");
if (this.unlocked) return;
try {
drivePassword.set(this, password);
this.unlocked = false;
await this.resetFromEvent();
} catch (e) {
drivePassword.delete(this);
this.unlocked = true;
throw e;
}
}
lock() {
if (this.unlocked) {
drivePassword.delete(this);
this.unlocked = true;
this._metadata = emptyMetadata;
this.tree = new TreeFolder("");
}
}

/** used to set the password on new drives */
setPassword(password: string) {
if (!this.unlocked && !drivePassword.has(this)) {
drivePassword.set(this, password);
}
}

protected readEvent(event: EventTemplate | SignedEvent): DriveMetadata {
const password = drivePassword.get(this);
if (!password) throw new Error("No password provided");

const data = decrypt(base64.decode(event.content), password);
const plaintext = this.decoder.decode(data);
const tags = JSON.parse(plaintext);

this.unlocked = true;

return super.readEvent({ ...event, content: "", tags, created_at: 0 });
}

protected createEventTemplate(): EventTemplate {
const password = drivePassword.get(this);
if (!password) throw new Error("No password set");

const template = super.createEventTemplate();
const plaintext = this.encoder.encode(JSON.stringify(template.tags));
const data = encrypt(plaintext, password);
const ciphertext = base64.encode(data);
template.kind = ENCRYPTED_DRIVE_KIND;
template.content = ciphertext;
template.tags = [];
return template;
}
}
37 changes: 37 additions & 0 deletions src/blossom-drive-client/crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { scrypt } from "@noble/hashes/scrypt";
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
import { concatBytes, randomBytes } from "@noble/hashes/utils";

export function encrypt(data: Uint8Array, password: string, logn: number = 16, ksb: 0x00 | 0x01 | 0x02 = 0x02) {
let salt = randomBytes(16);
let n = 2 ** logn;
let key = scrypt(password.normalize("NFKC"), salt, { N: n, r: 8, p: 1, dkLen: 32 });
let nonce = randomBytes(24);
let aad = Uint8Array.from([ksb]);
let xc2p1 = xchacha20poly1305(key, nonce, aad);
let ciphertext = xc2p1.encrypt(data);
let b = concatBytes(Uint8Array.from([0x02]), Uint8Array.from([logn]), salt, nonce, aad, ciphertext);
return b;
}

export function decrypt(b: Uint8Array, password: string) {
let version = b[0];
if (version !== 0x02) {
throw new Error(`invalid version ${version}, expected 0x02`);
}

let logn = b[1];
let n = 2 ** logn;

let salt = b.slice(2, 2 + 16);
let nonce = b.slice(2 + 16, 2 + 16 + 24);
let ksb = b[2 + 16 + 24];
let aad = Uint8Array.from([ksb]);
let ciphertext = b.slice(2 + 16 + 24 + 1);

let key = scrypt(password.normalize("NFKC"), salt, { N: n, r: 8, p: 1, dkLen: 32 });
let xc2p1 = xchacha20poly1305(key, nonce, aad);
let plaintext = xc2p1.decrypt(ciphertext);

return plaintext;
}
33 changes: 33 additions & 0 deletions src/components/DriveCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script lang="ts">
import { FolderOutline } from "flowbite-svelte-icons";
import { Badge } from "flowbite-svelte";
import type Drive from "../blossom-drive-client/Drive";
import { EncryptedDrive } from "../blossom-drive-client/EncryptedDrive";
export let drive: Drive;
$: isEncrypted = drive instanceof EncryptedDrive;
</script>

<a
href={`#/drive/${drive.address}`}
class="flex w-full max-w-sm flex-row divide-gray-200 rounded-lg border border-gray-200 bg-white text-gray-500 shadow-md hover:bg-gray-100 dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
>
<div class="flex aspect-square h-full w-32 items-center justify-center p-4">
<FolderOutline class="h-full w-full text-purple-500" />
</div>
<div class="relative flex-1 py-4 pb-4 pr-4">
<h5 class="mb-2 text-lg font-bold tracking-tight text-gray-900 dark:text-white">
{drive.name}
</h5>

<p class="font-normal leading-tight text-gray-700 dark:text-gray-400">
{drive.description}
</p>
{#if isEncrypted}
<Badge color="green" class="absolute bottom-2 right-2">Encrypted</Badge>
{:else}
<Badge color="purple" class="absolute bottom-2 right-2">Public</Badge>
{/if}
</div>
</a>
Loading

0 comments on commit ac73296

Please sign in to comment.