diff --git a/README.md b/README.md index d00340b71..5171b72ce 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -

Peerbit icon Icon @@ -26,38 +25,39 @@ Your database schema can remain very simple but still utilize P2P networks, auto ## Optimized for performance -Peerbit is performant, so performant in fact you can use it for [streaming video](https://stream.dao.xyz) by having peers subscribing to database updates. In a low latency setting, you can achieve around 1000 replications a second and have a thoughput of 100 MB/s. +Peerbit is performant, so performant in fact you can use it for [streaming video](https://stream.dao.xyz) by having peers subscribing to database updates. In a low latency setting, you can achieve around 1000 replications a second and have a thoughput of 100 MB/s. ![Dogestream](/docs/videostream.gif) - ## Other examples ### [Chat room](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/one-chat-room/) + [](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/one-chat-room/) ### [Lobby + chat rooms](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/many-chat-rooms/) + [](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/many-chat-rooms/) ### [Sync files](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/file-share/) + #### [React app](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/file-share/) + [](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/file-share/) #### [CLI](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/file-share/) -[](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/file-share/) - +[](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/file-share/) ### [Collaborative machine learning](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/collaborative-learning/) -[](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/collaborative-learning/) - +[](https://github.com/dao-xyz/peerbit-examples/tree/master/packages/collaborative-learning/) ## Get Started 1. Install Peerbit by following the simple setup instructions in our [Installation Guide](https://peerbit.org/#/getting-started). -2. Dive into our comprehensive [Documentation](https://peerbit.org/#/modules/client/) or checkout the [Example repository](https://github.com/dao-xyz/peerbit-examples) to explore the powerful features and learn how to leverage Peerbit to its fullest potential. +2. Dive into our comprehensive [Documentation](https://peerbit.org/#/modules/client/) or checkout the [Example repository](https://github.com/dao-xyz/peerbit-examples) to explore the powerful features and learn how to leverage Peerbit to its fullest potential. 3. Join us on [Matrix](https://matrix.to/#/#peerbit:matrix.org) to connect, share ideas, and collaborate with like-minded individuals. @@ -65,9 +65,27 @@ Peerbit is performant, so performant in fact you can use it for [streaming video Peerbit is an open-source project, and we welcome contributions from developers like you! Feel free to contribute code, report issues, and submit feature requests. Together, let's shape the future of Peerbit. +IMPORTANT: Peerbit uses yarn. + +1. Check yarn version: `yarn -v` should print 1.something +2. Install: `yarn` +3. Build: `yarn build` +4. Run tests: `yarn test` + +You might possibly need to CMD + Shift + P and then enter to restart the typescript server after the build step. + +To create a new package, follow the following steps: + +1. Clone the time folder within /packages/utils/time to the desired destination and rename it +2. Update the package.json `name`, `description`, `version` fields +3. Possibly add other depencencies to the package.json `dependencies` field (like `@peerbit/crypto`) +4. Delete contents in CHANGELOG.md +5. Update the root package.json `workspaces.packages` field +6. Update root lerna.json `workspaces.packages` field +7. run yarn once in root + +We recommend running tests with the VS Code integration though: https://marketplace.visualstudio.com/items?itemName=firsttris.vscode-jest-runner ## Let's Get Coding! [peerbit.org](https://peerbit.org) - - diff --git a/lerna.json b/lerna.json index 8516622f0..7f070f676 100644 --- a/lerna.json +++ b/lerna.json @@ -34,7 +34,8 @@ "packages/utils/uint8arrays", "packages/utils/cache", "packages/utils/time", - "packages/utils/logger" + "packages/utils/logger", + "packages/utils/keychain" ], "version": "independent", "npmClient": "yarn" diff --git a/package.json b/package.json index 1c501481e..10fcd3017 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,8 @@ "packages/utils/uint8arrays", "packages/utils/time", "packages/utils/cache", - "packages/utils/logger" + "packages/utils/logger", + "packages/utils/keychain" ] }, "engines": { diff --git a/packages/transport/stream/src/__tests__/stream.test.ts b/packages/transport/stream/src/__tests__/stream.test.ts index 844405988..cabf3aefb 100644 --- a/packages/transport/stream/src/__tests__/stream.test.ts +++ b/packages/transport/stream/src/__tests__/stream.test.ts @@ -814,6 +814,73 @@ describe("streams", function () { }); it("through relay if fails", async () => { + const dialFn = + streams[0].stream.components.connectionManager.openConnection.bind( + streams[0].stream.components.connectionManager + ); + + let directlyDialded = false; + const filteredDial = (address: PeerId | Multiaddr | Multiaddr[]) => { + if ( + isPeerId(address) && + address.toString() === streams[3].stream.peerIdStr + ) { + throw new Error("Mock fail"); // don't allow connect directly + } + + let addresses: Multiaddr[] = Array.isArray(address) + ? address + : [address as Multiaddr]; + for (const a of addresses) { + if ( + !a.protoNames().includes("p2p-circuit") && + a.toString().includes(streams[3].stream.peerIdStr) + ) { + throw new Error("Mock fail"); // don't allow connect directly + } + } + addresses = addresses.map((x) => + x.protoNames().includes("p2p-circuit") + ? multiaddr(x.toString().replace("/webrtc/", "/")) + : x + ); // TODO use webrtc in node + + directlyDialded = true; + return dialFn(addresses); + }; + + streams[0].stream.components.connectionManager.openConnection = + filteredDial; + expect(streams[0].stream.peers.size).toEqual(1); + await streams[0].stream.publish(data, { + to: [streams[3].stream.components.peerId] + }); + await waitFor(() => streams[3].received.length === 1); + await waitForResolved(() => expect(directlyDialded).toBeTrue()); + }); + + it("tries multiple relays", async () => { + await session.connect([[session.peers[1], session.peers[3]]]); + await waitForPeerStreams(streams[1].stream, streams[3].stream); + + /* + ┌───┐ + │ 0 │ + └┬─┬┘ + │┌▽┐ + ││1│ + │└┬┘ + ┌▽┐│ + │2││ + └┬┘│ + ┌▽─▽─┐ + │ 3 │ + └────┘ + + */ + + const dialedCircuitRelayAddresses: Set = new Set(); + const dialFn = streams[0].stream.components.connectionManager.openConnection.bind( streams[0].stream.components.connectionManager @@ -837,22 +904,32 @@ describe("streams", function () { throw new Error("Mock fail"); // don't allow connect directly } } - const q = 123; + addresses + .filter((x) => x.protoNames().includes("p2p-circuit")) + .forEach((x) => { + dialedCircuitRelayAddresses.add(x.toString()); + }); addresses = addresses.map((x) => - x.protoCodes().includes(281) + x.protoNames().includes("p2p-circuit") ? multiaddr(x.toString().replace("/webrtc/", "/")) : x ); // TODO use webrtc in node + + if (dialedCircuitRelayAddresses.size === 1) { + throw new Error("Mock fail"); // only succeed with the dial once we have tried two unique addresses (both neighbors) + } return dialFn(addresses); }; streams[0].stream.components.connectionManager.openConnection = filteredDial; + expect(streams[0].stream.peers.size).toEqual(1); await streams[0].stream.publish(data, { to: [streams[3].stream.components.peerId] }); await waitFor(() => streams[3].received.length === 1); + expect(dialedCircuitRelayAddresses.size).toEqual(2); }); }); diff --git a/packages/transport/stream/src/index.ts b/packages/transport/stream/src/index.ts index 9ea7bedb0..1c66889a7 100644 --- a/packages/transport/stream/src/index.ts +++ b/packages/transport/stream/src/index.ts @@ -1412,7 +1412,7 @@ export abstract class DirectStream< ) { // Dont await this even if it is async since this method can fail // and might take some time to run - this.maybeConnectDirectly(path).catch((e) => { + this.maybeConnectDirectly(to).catch((e) => { logger.error( "Failed to request direct connection: " + e.message ); @@ -1499,13 +1499,7 @@ export abstract class DirectStream< } } - async maybeConnectDirectly(path: string[]) { - if (path.length < 3) { - return; - } - - const toHash = path[path.length - 1]; - + async maybeConnectDirectly(toHash: string) { if (this.peers.has(toHash)) { return; // TODO, is this expected, or are we to dial more addresses? } @@ -1527,49 +1521,51 @@ export abstract class DirectStream< } // Connect through a closer relay that maybe does holepunch for us - const nextToHash = path[path.length - 2]; - const routeKey = nextToHash + toHash; - if (!this.recentDials.has(routeKey)) { - this.recentDials.add(routeKey); - const to = this.peerKeyHashToPublicKey.get(toHash)! as Ed25519PublicKey; - const toPeerId = await to.toPeerId(); - const addrs = this.multiaddrsMap.get(path[path.length - 2]); - if (addrs && addrs.length > 0) { - const addressesToDial = addrs.sort((a, b) => { - if (a.includes("/wss/")) { - if (b.includes("/wss/")) { - return 0; - } - return -1; - } - if (a.includes("/ws/")) { - if (b.includes("/ws/")) { - return 0; + const neighbours = this.routes.graph.neighbors(toHash); + outer: for (const neighbour of neighbours) { + const routeKey = neighbour + toHash; + if (!this.recentDials.has(routeKey)) { + this.recentDials.add(routeKey); + const to = this.peerKeyHashToPublicKey.get(toHash)! as Ed25519PublicKey; + const toPeerId = await to.toPeerId(); + const addrs = this.multiaddrsMap.get(neighbour); + if (addrs && addrs.length > 0) { + const addressesToDial = addrs.sort((a, b) => { + if (a.includes("/wss/")) { + if (b.includes("/wss/")) { + return 0; + } + return -1; } - if (b.includes("/wss/")) { - return 1; + if (a.includes("/ws/")) { + if (b.includes("/ws/")) { + return 0; + } + if (b.includes("/wss/")) { + return 1; + } + return -1; } - return -1; - } - return 0; - }); + return 0; + }); - for (const addr of addressesToDial) { - const circuitAddress = multiaddr( - addr + "/p2p-circuit/webrtc/p2p/" + toPeerId.toString() - ); - try { - await this.components.connectionManager.openConnection( - circuitAddress - ); - return; - } catch (error: any) { - logger.error( - "Failed to connect directly to: " + - circuitAddress.toString() + - ". " + - error?.message + for (const addr of addressesToDial) { + const circuitAddress = multiaddr( + addr + "/p2p-circuit/webrtc/p2p/" + toPeerId.toString() ); + try { + await this.components.connectionManager.openConnection( + circuitAddress + ); + break outer; // We succeeded! that means we dont have to try anymore + } catch (error: any) { + logger.warn( + "Failed to connect directly to: " + + circuitAddress.toString() + + ". " + + error?.message + ); + } } } } diff --git a/packages/utils/any-store/src/interface.ts b/packages/utils/any-store/src/interface.ts index f14efbe2f..0bc9754cd 100644 --- a/packages/utils/any-store/src/interface.ts +++ b/packages/utils/any-store/src/interface.ts @@ -5,7 +5,7 @@ export interface AnyStore { close(): MaybePromise; open(): MaybePromise; get(key: string): MaybePromise; - put(key: string, value: Uint8Array); + put(key: string, value: Uint8Array): MaybePromise; del(key): MaybePromise; sublevel(name: string): MaybePromise; iterator: () => { diff --git a/packages/utils/any-store/src/memory.ts b/packages/utils/any-store/src/memory.ts index ca96c1c08..a00f13f06 100644 --- a/packages/utils/any-store/src/memory.ts +++ b/packages/utils/any-store/src/memory.ts @@ -42,7 +42,7 @@ export class MemoryStore implements AnyStore { } put(key: string, value: Uint8Array) { - return this.store.set(key, value); + this.store.set(key, value); } // Remove a value and key from the cache diff --git a/packages/utils/crypto/src/__tests__/encryption.test.ts b/packages/utils/crypto/src/__tests__/encryption.test.ts index d510529e0..5cf574c75 100644 --- a/packages/utils/crypto/src/__tests__/encryption.test.ts +++ b/packages/utils/crypto/src/__tests__/encryption.test.ts @@ -1,21 +1,11 @@ import { DecryptedThing, X25519Keypair, - Keychain, - PublicSignKey + createDecrypterFromKeyResolver, + createLocalEncryptProvider } from "../index.js"; describe("encryption", function () { - const keychain = (keypair: X25519Keypair): Keychain => { - return { - exportById: async (id: Uint8Array) => undefined, - exportByKey: async (publicKey: T) => - publicKey.equals(keypair.publicKey) ? (keypair as Q) : undefined, - import: (keypair: any, id: Uint8Array) => { - throw new Error("No implemented+"); - } - }; - }; it("encrypt", async () => { const senderKey = await X25519Keypair.create(); const receiverKey1 = await X25519Keypair.create(); @@ -26,14 +16,27 @@ describe("encryption", function () { data }); - const receiver1Config = keychain(receiverKey1); - const receiver2Config = keychain(receiverKey2); + const receiver1Config = createDecrypterFromKeyResolver( + () => receiverKey1 as any + ); + const receiver2Config = createDecrypterFromKeyResolver( + () => receiverKey2 as any + ); const encrypted = await decrypted.encrypt( - senderKey, - receiverKey1.publicKey, - receiverKey2.publicKey + createLocalEncryptProvider(new Uint8Array([1, 2, 3])), + { + receiverPublicKeys: [receiverKey1.publicKey, receiverKey2.publicKey] + } ); + + /* const encrypted = await decrypted.encrypt( + createLocalEncryptProvider(new Uint8Array(32)), + { + type: 'symmetric' + }, + ); */ + encrypted._decrypted = undefined; const decryptedFromEncrypted1 = await encrypted.decrypt(receiver1Config); diff --git a/packages/utils/crypto/src/encryption.ts b/packages/utils/crypto/src/encryption.ts index e57507593..109502224 100644 --- a/packages/utils/crypto/src/encryption.ts +++ b/packages/utils/crypto/src/encryption.ts @@ -1,19 +1,218 @@ export * from "./errors.js"; + import { AbstractType, deserialize, field, serialize, variant, - vec + vec, + fixedArray } from "@dao-xyz/borsh"; import { equals } from "@peerbit/uint8arrays"; import { AccessError } from "./errors.js"; import sodium from "libsodium-wrappers"; import { X25519Keypair, X25519PublicKey, X25519SecretKey } from "./x25519.js"; -import { Ed25519Keypair, Ed25519PublicKey } from "./ed25519.js"; +import { Ed25519PublicKey } from "./ed25519.js"; import { randomBytes } from "./random.js"; -import { Keychain } from "./keychain.js"; +import { sha256 } from "./hash.js"; + +export type PublicKeyEncryptionParameters = { + type?: "publicKey"; + receiverPublicKeys: (X25519PublicKey | Ed25519PublicKey)[]; +}; + +export type SymmetricKeyEncryptionParameters = { + type?: "hash"; +}; +/* +export type NoExchange = { + type: 'none' +}; + */ + +export type KeyExchangeOptions = + | PublicKeyEncryptionParameters + | SymmetricKeyEncryptionParameters; + +type EncryptReturnValue< + T, + Parameters extends KeyExchangeOptions +> = EncryptedThing>; + +type CipherWithEnvelope = { + cipher: Uint8Array; + nonce: Uint8Array; + envelope: E; +}; + +type SymmetricKeys = Uint8Array; +type PublicKeyEncryptionKeys = X25519Keypair; + +function isAsymmetriEncryptionParameters( + parameters: KeyExchangeOptions +): parameters is PublicKeyEncryptionParameters { + return ( + (parameters as PublicKeyEncryptionParameters).receiverPublicKeys != null + ); +} +function isAsymmetricEncryptionKeys( + parameters: PublicKeyEncryptionKeys | SymmetricKeys +): parameters is PublicKeyEncryptionKeys { + return (parameters as PublicKeyEncryptionKeys) instanceof X25519Keypair; +} + +type EnvelopeFromParameter = + Parameters extends PublicKeyEncryptionParameters + ? PublicKeyEnvelope + : HashedKeyEnvelope; + +type EncryptProvide = ( + bytes: Uint8Array, + parameters: Parameters +) => Promise>>; + +export const createLocalEncryptProvider = < + K extends PublicKeyEncryptionKeys | SymmetricKeys, + Parameters extends KeyExchangeOptions = K extends PublicKeyEncryptionKeys + ? PublicKeyEncryptionParameters + : SymmetricKeyEncryptionParameters +>( + keys: K +) => { + return async ( + bytes: Uint8Array, + parameters: Parameters + ): Promise>> => { + const nonce = randomBytes(NONCE_LENGTH); // crypto random is faster than sodim random + if ( + isAsymmetriEncryptionParameters(parameters) && + isAsymmetricEncryptionKeys(keys) + ) { + const epheremalKey = sodium.crypto_secretbox_keygen(); + const cipher = sodium.crypto_secretbox_easy(bytes, nonce, epheremalKey); + const { receiverPublicKeys } = parameters; + const receiverX25519PublicKeys = await Promise.all( + receiverPublicKeys.map((key) => { + if (key instanceof Ed25519PublicKey) { + return X25519PublicKey.from(key); + } + return key; + }) + ); + + const ks = receiverX25519PublicKeys.map((receiverPublicKey) => { + const kNonce = randomBytes(NONCE_LENGTH); // crypto random is faster than sodium random + return new K({ + encryptedKey: new CipherWithNonce({ + cipher: sodium.crypto_box_easy( + epheremalKey, + kNonce, + receiverPublicKey.publicKey, + keys.secretKey.secretKey + ), + nonce: kNonce + }), + receiverPublicKey + }); + }); + + return { + cipher: new Uint8Array(cipher), // TODO do we need this clone? + nonce, + envelope: new PublicKeyEnvelope({ + senderPublicKey: keys.publicKey, + ks + }) as EnvelopeFromParameter + }; + } else if ( + !isAsymmetriEncryptionParameters(parameters) && + !isAsymmetricEncryptionKeys(keys) + ) { + const cipher = sodium.crypto_secretbox_easy(bytes, nonce, keys); + return { + cipher: new Uint8Array(cipher), // TODO do we need this clone? + nonce, + envelope: new HashedKeyEnvelope({ + hash: await sha256(keys) + }) as EnvelopeFromParameter + }; + } + + throw new Error("Unexpected encryption parameters"); + }; +}; + +type DecryptProvider = ( + encrypted: Uint8Array, + nonce: Uint8Array, + exchange: Envelope +) => Promise; + +type KeyResolver = ( + key: PublicKey +) => + | (PublicKey extends X25519PublicKey ? X25519Keypair : Uint8Array) + | undefined; + +export const createDecrypterFromKeyResolver = ( + keyResolver: KeyResolver +): DecryptProvider => { + return async ( + encrypted: Uint8Array, + nonce: Uint8Array, + exchange: Envelope + ): Promise => { + // We only need to open with one of the keys + + let epheremalKey: Uint8Array | undefined; + + if (exchange instanceof PublicKeyEnvelope) { + let key: { index: number; keypair: X25519Keypair } | undefined; + for (const [i, k] of exchange._ks.entries()) { + const exported = keyResolver(k._receiverPublicKey); + if (exported) { + key = { + index: i, + keypair: exported + }; + break; + } + } + + if (key) { + const k = exchange[key.index]; + let secretKey: X25519SecretKey = undefined as any; + if (key.keypair instanceof X25519Keypair) { + secretKey = key.keypair.secretKey; + } else { + secretKey = await X25519SecretKey.from(key.keypair); + } + let epheremalKey: Uint8Array; + try { + epheremalKey = sodium.crypto_box_open_easy( + k._encryptedKey.cipher, + k._encryptedKey.nonce, + exchange._senderPublicKey.publicKey, + secretKey.secretKey + ); + } catch (error) { + throw new AccessError("Failed to decrypt"); + } + } else { + throw new AccessError("Failed to resolve decryption key"); + } + } else if (exchange instanceof HashedKeyEnvelope) { + epheremalKey = keyResolver(exchange.hash); + } + + if (!epheremalKey) { + throw new Error("Failed to resolve ephemeral key"); + } + + return sodium.crypto_secretbox_open_easy(encrypted, nonce, epheremalKey); + }; +}; const NONCE_LENGTH = 24; @@ -27,7 +226,7 @@ export abstract class MaybeEncrypted { } decrypt( - keyOrKeychain?: Keychain | X25519Keypair + provider?: DecryptProvider ): Promise> | DecryptedThing { throw new Error("Not implemented"); } @@ -69,47 +268,16 @@ export class DecryptedThing extends MaybeEncrypted { return deserialize(this._data, clazz); } - async encrypt( - x25519Keypair: X25519Keypair, - ...receiverPublicKeys: (X25519PublicKey | Ed25519PublicKey)[] - ): Promise> { + async encrypt( + provider: EncryptProvide, + parameters: Parameters + ): Promise> { const bytes = serialize(this); - const epheremalKey = sodium.crypto_secretbox_keygen(); - const nonce = randomBytes(NONCE_LENGTH); // crypto random is faster than sodim random - const cipher = sodium.crypto_secretbox_easy(bytes, nonce, epheremalKey); - - const receiverX25519PublicKeys = await Promise.all( - receiverPublicKeys.map((key) => { - if (key instanceof Ed25519PublicKey) { - return X25519PublicKey.from(key); - } - return key; - }) - ); - - const ks = receiverX25519PublicKeys.map((receiverPublicKey) => { - const kNonce = randomBytes(NONCE_LENGTH); // crypto random is faster than sodium random - return new K({ - encryptedKey: new CipherWithNonce({ - cipher: sodium.crypto_box_easy( - epheremalKey, - kNonce, - receiverPublicKey.publicKey, - x25519Keypair.secretKey.secretKey - ), - nonce: kNonce - }), - receiverPublicKey - }); - }); - - const enc = new EncryptedThing({ - encrypted: new Uint8Array(cipher), - nonce, - envelope: new Envelope({ - senderPublicKey: x25519Keypair.publicKey, - ks - }) + const { cipher, envelope, nonce } = await provider(bytes, parameters); + const enc = new EncryptedThing>({ + encrypted: cipher, + envelope, + nonce }); enc._decrypted = this; return enc; @@ -196,8 +364,12 @@ export class K { } } +abstract class Envelope { + abstract equals(other: Envelope): boolean; +} + @variant(0) -export class Envelope { +class PublicKeyEnvelope extends Envelope { @field({ type: X25519PublicKey }) _senderPublicKey: X25519PublicKey; @@ -205,14 +377,16 @@ export class Envelope { _ks: K[]; constructor(props?: { senderPublicKey: X25519PublicKey; ks: K[] }) { + super(); if (props) { this._senderPublicKey = props.senderPublicKey; this._ks = props.ks; } } - equals(other: Envelope): boolean { - if (other instanceof Envelope) { + // TODO: should this be comparable to AbstractEnvelope? + equals(other: PublicKeyEnvelope): boolean { + if (other instanceof PublicKeyEnvelope) { if (!this._senderPublicKey.equals(other._senderPublicKey)) { return false; } @@ -233,7 +407,35 @@ export class Envelope { } @variant(1) -export class EncryptedThing extends MaybeEncrypted { +class HashedKeyEnvelope extends Envelope { + @field({ type: fixedArray("u8", 32) }) + hash: Uint8Array; + // TODO: Do we need a salt here? + constructor(props?: { hash: Uint8Array }) { + super(); + if (props) { + this.hash = props.hash; + } + } + + // TODO: should this be comparable to AbstractEnvelope? + equals(other: HashedKeyEnvelope): boolean { + if (other instanceof HashedKeyEnvelope) { + if (!equals(this.hash, other.hash)) { + return false; + } + return true; + } else { + return false; + } + } +} + +@variant(1) +export class EncryptedThing< + T, + E extends Envelope = PublicKeyEnvelope | HashedKeyEnvelope +> extends MaybeEncrypted { @field({ type: Uint8Array }) _encrypted: Uint8Array; @@ -241,18 +443,18 @@ export class EncryptedThing extends MaybeEncrypted { _nonce: Uint8Array; @field({ type: Envelope }) - _envelope: Envelope; + _keyexchange: E; constructor(props?: { encrypted: Uint8Array; nonce: Uint8Array; - envelope: Envelope; + envelope: E; }) { super(); if (props) { this._encrypted = props.encrypted; this._nonce = props.nonce; - this._envelope = props.envelope; + this._keyexchange = props.envelope; } } @@ -266,86 +468,28 @@ export class EncryptedThing extends MaybeEncrypted { return this._decrypted; } - async decrypt( - keyResolver?: Keychain | X25519Keypair - ): Promise> { + async decrypt(provider?: DecryptProvider): Promise> { if (this._decrypted) { return this._decrypted; } - if (!keyResolver) { - throw new AccessError("Expecting key resolver"); - } - - // We only need to open with one of the keys - let key: { index: number; keypair: X25519Keypair } | undefined; - if (keyResolver instanceof X25519Keypair) { - for (const [i, k] of this._envelope._ks.entries()) { - if (k._receiverPublicKey.equals(keyResolver.publicKey)) { - key = { - index: i, - keypair: keyResolver - }; - } - } - } else { - for (const [i, k] of this._envelope._ks.entries()) { - const exported = await keyResolver.exportByKey(k._receiverPublicKey); - if (exported) { - key = { - index: i, - keypair: exported - }; - break; - } - } + if (!provider) { + throw new AccessError("Expecting decryption provider"); } - if (key) { - const k = this._envelope._ks[key.index]; - let secretKey: X25519SecretKey = undefined as any; - if (key.keypair instanceof X25519Keypair) { - secretKey = key.keypair.secretKey; - } else { - secretKey = await X25519SecretKey.from(key.keypair); - } - let epheremalKey: Uint8Array; - try { - epheremalKey = sodium.crypto_box_open_easy( - k._encryptedKey.cipher, - k._encryptedKey.nonce, - this._envelope._senderPublicKey.publicKey, - secretKey.secretKey - ); - } catch (error) { - throw new AccessError("Failed to decrypt"); - } + const decrypted = await provider( + this._encrypted, + this._nonce, + this._keyexchange + ); + if (decrypted) { + const der = deserialize(decrypted, DecryptedThing); - // TODO: is nested decryption necessary? - /* let der: any = this; - let counter = 0; - while (der instanceof EncryptedThing) { - const decrypted = await sodium.crypto_secretbox_open_easy(this._encrypted, this._nonce, epheremalKey); - der = deserialize(decrypted, DecryptedThing) - counter += 1; - if (counter >= 10) { - throw new Error("Unexpected decryption behaviour, data seems to always be in encrypted state") - } - } */ - - const der = deserialize( - sodium.crypto_secretbox_open_easy( - this._encrypted, - this._nonce, - epheremalKey - ), - DecryptedThing - ); this._decrypted = der as DecryptedThing; - } else { - throw new AccessError("Failed to resolve decryption key"); + return this._decrypted; } - return this._decrypted; + + throw new AccessError("Failed to resolve decryption key"); } equals(other: MaybeEncrypted): boolean { @@ -357,7 +501,7 @@ export class EncryptedThing extends MaybeEncrypted { return false; } - if (!this._envelope.equals(other._envelope)) { + if (!this._keyexchange.equals(other._keyexchange)) { return false; } return true; diff --git a/packages/utils/crypto/src/index.ts b/packages/utils/crypto/src/index.ts index d81825a16..22389dee4 100644 --- a/packages/utils/crypto/src/index.ts +++ b/packages/utils/crypto/src/index.ts @@ -1,7 +1,6 @@ export * from "./key.js"; export * from "./ed25519.js"; export * from "./signature.js"; -export * from "./key.js"; export * from "./sepc256k1.js"; export * from "./x25519.js"; export * from "./encryption.js"; @@ -11,7 +10,7 @@ export * from "./hash.js"; export * from "./random.js"; export * from "./prehash.js"; export * from "./signer.js"; -export * from "./keychain.js"; +export * from "./xsalsa20poly1305.js"; import libsodium from "libsodium-wrappers"; const ready = libsodium.ready; // TODO can we export ready directly ? export { ready }; diff --git a/packages/utils/crypto/src/key.ts b/packages/utils/crypto/src/key.ts index 121f658e9..8384ff5a2 100644 --- a/packages/utils/crypto/src/key.ts +++ b/packages/utils/crypto/src/key.ts @@ -1,6 +1,8 @@ -import { serialize } from "@dao-xyz/borsh"; +import { field, serialize } from "@dao-xyz/borsh"; import { sha256Base64Sync } from "./hash.js"; import { PeerId } from "@libp2p/interface/peer-id"; +import { compare } from "@peerbit/uint8arrays"; +import { toHexString } from "./utils"; interface Key { equals(other: Key): boolean; @@ -20,6 +22,8 @@ export abstract class Keypair { toPeerId(): Promise { throw new Error("Not implemented"); } + + // TODO: Should we add not implemented errors for .create and and .from as well? } // ---- SIGNATURE KEYS ----- @@ -78,3 +82,22 @@ export abstract class PlainKey implements Key { return this._hashcode || (this._hashcode = sha256Base64Sync(this.bytes)); } } + +export class ByteKey extends PlainKey { + @field({ type: Uint8Array }) + key: Uint8Array; + + constructor(properties: { key: Uint8Array }) { + super(); + this.key = properties.key; + } + + equals(other: ByteKey) { + return compare(this.key, other.key) === 0; + } + + // TODO: What should be preprended to this string here? + toString(): string { + return "bytekey/" + toHexString(this.key); + } +} diff --git a/packages/utils/crypto/src/keychain.ts b/packages/utils/crypto/src/keychain.ts deleted file mode 100644 index c681a0840..000000000 --- a/packages/utils/crypto/src/keychain.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { KeyChain as InternalKeychain } from "@libp2p/interface/keychain"; -import { keysPBM } from "@libp2p/crypto/keys"; -import { identity } from "multiformats/hashes/identity"; -import { base58btc } from "multiformats/bases/base58"; -import { Cache } from "@peerbit/cache"; -import { Ed25519Keypair, Ed25519PublicKey } from "./ed25519.js"; -import { Keypair, PublicSignKey } from "./key.js"; - -import { KeyInfo } from "@libp2p/interface/keychain"; -import { AccessError, X25519Keypair, X25519PublicKey } from "./x25519.js"; - -export type KeypairFromPublicKey = T extends X25519PublicKey - ? X25519PublicKey extends T - ? X25519Keypair - : Ed25519Keypair - : Ed25519Keypair; - -export interface Keychain { - import(keypair: Ed25519Keypair, id: Uint8Array): Promise; - - exportByKey< - T extends Ed25519PublicKey | X25519PublicKey, - Q = KeypairFromPublicKey - >( - publicKey: T - ): Promise; - - exportById< - T = "ed25519" | "x25519", - Q = T extends "ed25519" ? Ed25519Keypair : X25519Keypair - >( - id: Uint8Array, - type: T - ): Promise; -} - -export class Libp2pKeychain implements Keychain { - constructor( - readonly keychain: InternalKeychain, - readonly options?: { cache?: Cache } - ) {} - - keychainKeyIdFromPublicKey(publicKey: X25519PublicKey) { - const bytes = keysPBM.PublicKey.encode({ - Type: keysPBM.KeyType.Ed25519, - Data: publicKey.publicKey - }).subarray(); - - const encoding = identity.digest(bytes); - return base58btc.encode(encoding.bytes).substring(1); - } - - private cacheKey(key: Ed25519Keypair | X25519Keypair, id?: Uint8Array) { - this.options?.cache?.add(base58btc.encode(key.publicKey.bytes), key); - id && this.options?.cache?.add(base58btc.encode(id), key); - } - - private getCachedById(id: Uint8Array): Ed25519Keypair | null | undefined { - const key = base58btc.encode(id instanceof PublicSignKey ? id.bytes : id); - const cached = this.options?.cache?.get(key); - if (cached === null) { - return null; - } else if (!cached) { - return undefined; - } else if (cached instanceof Ed25519Keypair) { - return cached; - } - throw new Error("Unexpected cached keypair type: " + key?.constructor.name); - } - - private getCachedByKey< - T extends X25519PublicKey | Ed25519PublicKey, - Q = KeypairFromPublicKey - >(publicKey: T): Q | null | undefined { - const key = base58btc.encode(publicKey.bytes); - const cached = this.options?.cache?.get(key); - if (cached === null) { - return null; - } else if (!cached) { - return undefined; - } else if (cached instanceof Keypair) { - return cached as Q; - } - throw new Error("Unexpected cached keypair type: " + key?.constructor.name); - } - - exportByKey = async < - T extends X25519PublicKey | Ed25519PublicKey, - Q = KeypairFromPublicKey - >( - publicKey: T - ): Promise => { - const cached = this.getCachedByKey(publicKey); - if (cached !== undefined) { - // if null, means key is deleted - return cached ? cached : undefined; - } - - let keyInfo: KeyInfo | undefined = undefined; - if (publicKey instanceof Ed25519PublicKey) { - try { - keyInfo = await this.keychain.findKeyById( - (await publicKey.toPeerId()).toString() - ); - } catch (e: any) { - if (e.code !== "ERR_KEY_NOT_FOUND") { - throw e; - } - } - } - - if (!keyInfo) { - try { - keyInfo = await this.keychain.findKeyByName( - base58btc.encode(publicKey.bytes) - ); - } catch (e: any) { - if (e.code !== "ERR_KEY_NOT_FOUND") { - throw e; - } - } - } - - if (!keyInfo) { - return undefined; - } - - const peerId = await this.keychain.exportPeerId(keyInfo.name); - - return ( - publicKey instanceof X25519PublicKey - ? X25519Keypair.fromPeerId(peerId) - : Ed25519Keypair.fromPeerId(peerId) - ) as Q; - }; - - async exportById< - T = "ed25519" | "x25519", - Q = T extends "ed25519" ? Ed25519Keypair : X25519Keypair - >(id: Uint8Array, type: T): Promise { - const cached = this.getCachedById(id) as Ed25519Keypair | undefined | null; - if (cached !== undefined) { - // if null, means key is deleted - if (type === "x25519" && cached instanceof Ed25519Keypair) { - return X25519Keypair.from(cached) as Q; // TODO perf, don't do this all the time - } - return cached ? (cached as Q) : undefined; - } - try { - const keyInfo = await this.keychain.findKeyByName(base58btc.encode(id)); - const peerId = await this.keychain.exportPeerId(keyInfo.name); - if (type === "x25519") { - return X25519Keypair.fromPeerId(peerId) as Q; - } - return Ed25519Keypair.fromPeerId(peerId) as Q; - } catch (e: any) { - if (e.code !== "ERR_KEY_NOT_FOUND") { - throw e; - } - } - } - - import = async (keypair: Ed25519Keypair, id: Uint8Array) => { - const receiverKeyPeerId = await keypair.toPeerId(); - this.cacheKey(keypair, id); - - // import as ed - await this.keychain.importPeer(base58btc.encode(id), receiverKeyPeerId); - - // import as x so we can decrypt messages with this public key (if received any) - const xKeypair = await X25519Keypair.from(keypair); - this.cacheKey(xKeypair); - await this.keychain.importPeer( - base58btc.encode(xKeypair.publicKey.bytes), - receiverKeyPeerId - ); - }; - - // Arrow function is used so we can reference this function and use 'this' without .bind(self) - getAnyKeypair = async (publicKeys) => { - for (let i = 0; i < publicKeys.length; i++) { - try { - const key = await this.exportByKey(publicKeys[i]); - if (key && key instanceof X25519Keypair) { - return { - index: i, - keypair: key as X25519Keypair - }; - } - } catch (error: any) { - // Key missing - if (error.code !== "ERR_NOT_FOUND") { - throw error; - } - } - } - throw new AccessError("Failed to access key"); - }; -} diff --git a/packages/utils/crypto/src/xsalsa20poly1305.ts b/packages/utils/crypto/src/xsalsa20poly1305.ts new file mode 100644 index 000000000..c5d061482 --- /dev/null +++ b/packages/utils/crypto/src/xsalsa20poly1305.ts @@ -0,0 +1,40 @@ +import { field, fixedArray, variant } from "@dao-xyz/borsh"; +import { PlainKey } from "./key"; +import { compare } from "@peerbit/uint8arrays"; +import { toHexString } from "./utils"; + +import sodium from "libsodium-wrappers"; + +@variant(0) +export class XSalsa20Poly1305 extends PlainKey { + @field({ type: fixedArray("u8", 32) }) + key: Uint8Array; + + constructor(properties: { key: Uint8Array }) { + super(); + if (properties.key.length !== 32) { + throw new Error("Expecting key to have length 32"); + } + this.key = properties.key; + } + + static async create(): Promise { + await sodium.ready; + const generated = sodium.crypto_secretbox_keygen(); + const kp = new XSalsa20Poly1305({ + key: generated + }); + + return kp; + } + + equals(other: PlainKey): boolean { + if (other instanceof XSalsa20Poly1305) { + return compare(this.key, other.key) === 0; + } + return false; + } + toString(): string { + return "xsalsa20poly1305/" + toHexString(this.key); + } +} diff --git a/packages/utils/keychain/.gitignore b/packages/utils/keychain/.gitignore new file mode 100644 index 000000000..9b26ed04f --- /dev/null +++ b/packages/utils/keychain/.gitignore @@ -0,0 +1,2 @@ +node_modules +lib \ No newline at end of file diff --git a/packages/utils/keychain/CHANGELOG.md b/packages/utils/keychain/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/utils/keychain/LICENSE b/packages/utils/keychain/LICENSE new file mode 100644 index 000000000..cdf9c0bfc --- /dev/null +++ b/packages/utils/keychain/LICENSE @@ -0,0 +1,24 @@ +The MIT License (MIT) + +Copyright (c) 2015-2018 shamb0t +Copyright (c) 2018 Haja Networks Oy +Copyright (c) 2020 dao.xyz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/utils/keychain/package.json b/packages/utils/keychain/package.json new file mode 100644 index 000000000..da3adb6d4 --- /dev/null +++ b/packages/utils/keychain/package.json @@ -0,0 +1,36 @@ +{ + "name": "@peerbit/keychain", + "version": "1.0.0", + "description": "Utility functions for keychain", + "type": "module", + "sideEffects": false, + "module": "lib/esm/index.js", + "types": "lib/esm/index.d.ts", + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib", + "src", + "!src/**/__tests__", + "!lib/**/__tests__", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "shx rm -rf lib/*", + "build": "yarn clean && tsc -p tsconfig.json", + "test": "node ../../../node_modules/.bin/jest test -c ../../../jest.config.ts --runInBand --forceExit", + "test:unit": "node ../../../node_modules/.bin/jest test -c ../../../jest.config.unit.ts --runInBand --forceExit", + "test:integration": "node ../node_modules/.bin/jest test -c ../../../jest.config.integration.ts --runInBand --forceExit" + }, + "author": "dao.xyz", + "license": "MIT", + "dependencies": { + "@peerbit/crypto": "^1.0.10", + "@peerbit/any-store": "^0.0.1" + } +} diff --git a/packages/utils/keychain/src/__tests__/index.test.ts b/packages/utils/keychain/src/__tests__/index.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/utils/crypto/src/__tests__/keychain.test.ts b/packages/utils/keychain/src/__tests__/keychain.test.ts similarity index 82% rename from packages/utils/crypto/src/__tests__/keychain.test.ts rename to packages/utils/keychain/src/__tests__/keychain.test.ts index 406b4b796..63557b977 100644 --- a/packages/utils/crypto/src/__tests__/keychain.test.ts +++ b/packages/utils/keychain/src/__tests__/keychain.test.ts @@ -1,9 +1,10 @@ -import { Keychain, Libp2pKeychain } from "../keychain"; import { MemoryDatastore } from "datastore-core"; import { DefaultKeyChain } from "@libp2p/keychain"; -import { Ed25519Keypair } from "../ed25519"; -import { X25519Keypair } from "../x25519"; +import { Ed25519Keypair, X25519Keypair, ByteKey } from "@peerbit/crypto"; import { Cache } from "@peerbit/cache"; +import { Keychain } from "../"; + +// TODO update tests describe("keychain", () => { let keychains: Keychain[]; @@ -31,7 +32,7 @@ describe("keychain", () => { )?.equals(kp) ).toBeTrue(); expect( - (await keychain.exportByKey(kp.publicKey))?.equals(kp) + (await keychain.exportByPublicKey(kp.publicKey))?.equals(kp) ).toBeTrue(); } }); @@ -49,7 +50,7 @@ describe("keychain", () => { )?.equals(xkp) ).toBeTrue(); expect( - (await keychain.exportByKey(xkp.publicKey))?.equals(xkp) + (await keychain.exportByPublicKey(xkp.publicKey))?.equals(xkp) ).toBeTrue(); } }); diff --git a/packages/utils/keychain/src/index.ts b/packages/utils/keychain/src/index.ts new file mode 100644 index 000000000..96d65789f --- /dev/null +++ b/packages/utils/keychain/src/index.ts @@ -0,0 +1 @@ +export * from "./interface.js"; diff --git a/packages/utils/keychain/src/interface.ts b/packages/utils/keychain/src/interface.ts new file mode 100644 index 000000000..5c8467fed --- /dev/null +++ b/packages/utils/keychain/src/interface.ts @@ -0,0 +1,88 @@ +import { + Ed25519Keypair, + X25519Keypair, + Keypair, + XSalsa20Poly1305, + Ed25519PublicKey, + X25519PublicKey, + ByteKey, + Secp256k1Keypair, + Secp256k1PublicKey, + PublicKeyEncryptionKey, + PublicSignKey +} from "@peerbit/crypto"; + +export type KeypairFromPublicKey = T extends X25519PublicKey + ? X25519Keypair + : T extends Ed25519PublicKey + ? Ed25519Keypair + : T extends Secp256k1PublicKey + ? Secp256k1Keypair + : T extends PublicSignKey | PublicKeyEncryptionKey + ? Keypair + : never; + +// Should perhaps be un crypto package +export type Keypairs = + | Ed25519Keypair + | Keypair + | X25519Keypair + | Secp256k1Keypair; + +// Should perhaps be un crypto package +export type PublicKeys = + | Ed25519PublicKey + | X25519PublicKey + | Secp256k1PublicKey + | PublicSignKey + | PublicKeyEncryptionKey; + +export interface Keychain { + // Add a key to the keychain. + import( + parameters: ( + | { keypair: Keypairs } + | { key: XSalsa20Poly1305 | ByteKey } + ) & { id: Uint8Array } + ): Promise; + + // This is only really relevant for asymmetric keys? -> No changes + exportByPublicKey>( + publicKey: T + ): Promise; + + // Export any key by their hashcode. + // If Key is PublicKey/PrivateKey keypair. The hashcode should be of the publickey + /* + key = new ByteKey({key: new Uint8Array(32)}) + keychain.exportByHash(key.hashcode()) // returns key + */ + exportByHash>( + hash: string + ): Promise; + + // ID's are the sha256base are user defined ids. Anyone can store any key with a specific id + exportById< + T = + | "ed25519" + | "x25519" + | "secp256k1" + | "xsalsa20poly1305" + | "bytekey" + | "keypair", + Q = T extends "ed25519" + ? Ed25519Keypair + : T extends "x25519" + ? X25519Keypair + : T extends "secp256k1" + ? Secp256k1Keypair + : T extends "keypair" + ? Keypair + : T extends "xsalsa20poly1305" + ? XSalsa20Poly1305 + : ByteKey + >( + id: string, + type: T + ): Promise; +} diff --git a/packages/utils/keychain/src/keychain.ts b/packages/utils/keychain/src/keychain.ts new file mode 100644 index 000000000..aa17a6434 --- /dev/null +++ b/packages/utils/keychain/src/keychain.ts @@ -0,0 +1,90 @@ +/** + * L0 Keychain implementation using AnyStore + */ + +import { createStore, AnyStore } from "@peerbit/any-store"; +import { + Keychain as IKeychain, + KeypairFromPublicKey, + Keypairs, + PublicKeys +} from "./interface.js"; +import { + Ed25519Keypair, + Keypair, + X25519Keypair, + Secp256k1Keypair, + XSalsa20Poly1305, + ByteKey, + sha256Base64Sync +} from "@peerbit/crypto"; +import { serialize } from "@dao-xyz/borsh"; + +class Keychain implements IKeychain { + store: AnyStore; + constructor(directory?: string | undefined) { + this.store = createStore(directory); + } + import( + parameters: ( + | { keypair: Keypairs } + | { key: XSalsa20Poly1305 | ByteKey } + ) & { id?: Uint8Array } + ): Promise { + let hashcode: string; + let bytes: Uint8Array; + if ((parameters as { keypair: Keypairs }).keypair) { + const kp = (parameters as { keypair: Keypairs }).keypair; + hashcode = kp.publicKey.hashcode(); + bytes = serialize(kp); + } else { + const key = (parameters as { key: XSalsa20Poly1305 | ByteKey }).key; + hashcode = key.hashcode(); + bytes = serialize(key); + } + this.store.put(hashcode, bytes); + + if ((parameters as { id: Uint8Array }).id) { + this.store.put( + sha256Base64Sync((parameters as { id: Uint8Array }).id), + bytes + ); + } + } + + exportByPublicKey>( + publicKey: T + ): Promise { + // anystore.get by publicKey.hashcode() -> deserialize(bytes, T) -> return + return this.exportByHash(publicKey.hashcode()); + } + exportByHash>( + hash: string + ): Promise { + // anystore.get by hash -> deserialize(bytes, T) -> return + throw new Error("Method not implemented."); + } + exportById< + T = + | "ed25519" + | "x25519" + | "secp256k1" + | "xsalsa20poly1305" + | "bytekey" + | "keypair", + Q = T extends "ed25519" + ? Ed25519Keypair + : T extends "x25519" + ? X25519Keypair + : T extends "secp256k1" + ? Secp256k1Keypair + : T extends "keypair" + ? Keypair + : T extends "xsalsa20poly1305" + ? XSalsa20Poly1305 + : ByteKey + >(id: string, type: T): Promise { + // anystore.get by sha256Base64Sync(id) -> deserialize(bytes, T) -> return + throw new Error("Method not implemented."); + } +} diff --git a/packages/utils/keychain/tsconfig.json b/packages/utils/keychain/tsconfig.json new file mode 100644 index 000000000..9c2db1ebd --- /dev/null +++ b/packages/utils/keychain/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src"], + "compilerOptions": { + "noEmit": false, + "outDir": "lib/esm" + } +}