Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scenario test for a multi-party encrypted chat app #556

Merged
merged 3 commits into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type { MessagesGetMessage, MessagesGetReply } from './types/messages-type
export type { PermissionConditions, PermissionScope, PermissionsGrantDescriptor, PermissionsGrantMessage, PermissionsRequestDescriptor, PermissionsRequestMessage, PermissionsRevokeDescriptor, PermissionsRevokeMessage } from './types/permissions-types.js';
export type { ProtocolsConfigureDescriptor, ProtocolDefinition, ProtocolTypes, ProtocolRuleSet, ProtocolsQueryFilter, ProtocolsConfigureMessage, ProtocolsQueryMessage } from './types/protocols-types.js';
export type { EncryptionProperty, RecordsDeleteMessage, RecordsQueryMessage, RecordsQueryReply, RecordsQueryReplyEntry, RecordsReadReply, RecordsWriteDescriptor, RecordsWriteMessage } from './types/records-types.js';
export { SortOrder } from './types/message-types.js';
export { authenticate } from './core/auth.js';
export { AllowAllTenantGate, TenantGate } from './core/tenant-gate.js';
export { Cid } from './utils/cid.js';
export { DateSort, RecordsQuery, RecordsQueryOptions } from './interfaces/records-query.js';
Expand Down Expand Up @@ -45,6 +45,9 @@ export { RecordsDelete, RecordsDeleteOptions } from './interfaces/records-delete
export { RecordsRead, RecordsReadOptions } from './interfaces/records-read.js';
export { Secp256k1 } from './utils/secp256k1.js';
export { Signer } from './types/signer.js';
export { SortOrder } from './types/message-types.js';

// store interfaces
export { DataStoreLevel } from './store/data-store-level.js';
export { EventLogLevel } from './event-log/event-log-level.js';
export { MessageStoreLevel } from './store/message-store-level.js';
4 changes: 2 additions & 2 deletions src/interfaces/records-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { DwnInterfaceName, DwnMethodName } from '../core/message.js';

export type RecordsReadOptions = {
filter: RecordsFilter;
date?: string;
messageTimestamp?: string;
authorizationSigner?: Signer;
permissionsGrantId?: string;
/**
Expand Down Expand Up @@ -50,7 +50,7 @@ export class RecordsRead extends Message<RecordsReadMessage> {
interface : DwnInterfaceName.Records,
method : DwnMethodName.Read,
filter : Records.normalizeFilter(filter),
messageTimestamp : options.date ?? currentTime,
messageTimestamp : options.messageTimestamp ?? currentTime,
};

removeUndefinedProperties(descriptor);
Expand Down
9 changes: 9 additions & 0 deletions src/utils/data-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ export class DataStream {
});
}

/**
* Reads the entire readable stream and JSON parses it into an object.
*/
public static async toObject(readableStream: Readable): Promise<object> {
const contentBytes = await DataStream.toBytes(readableStream);
const contentObject = Encoder.bytesToObject(contentBytes);
return contentObject;
}

/**
* Concatenates the array of bytes given into one Uint8Array.
*/
Expand Down
9 changes: 7 additions & 2 deletions src/utils/encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ export class Encoder {

public static base64UrlToObject(base64urlString: string): any {
const payloadBytes = base64url.baseDecode(base64urlString);
const payloadString = Encoder.bytesToString(payloadBytes);
const payloadObject = JSON.parse(payloadString);
const payloadObject = Encoder.bytesToObject(payloadBytes);
return payloadObject;
}

Expand All @@ -30,6 +29,12 @@ export class Encoder {
return bytes;
}

public static bytesToObject(content: Uint8Array): object {
const contentString = Encoder.bytesToString(content);
const contentObject = JSON.parse(contentString);
return contentObject;
}

public static objectToBytes(obj: Record<string, any>): Uint8Array {
const objectString = JSON.stringify(obj);
const objectBytes = textEncoder.encode(objectString);
Expand Down
267 changes: 267 additions & 0 deletions tests/end-to-end-tests.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import type { DerivedPrivateJwk } from '../src/utils/hd-key.js';
import type { DataStore, EventLog, MessageStore, ProtocolDefinition, ProtocolsConfigureMessage, RecordsReadReply } from '../src/index.js';

import chaiAsPromised from 'chai-as-promised';
import sinon from 'sinon';
import threadRoleProtocolDefinition from './vectors/protocol-definitions/thread-role.json' assert { type: 'json' };

import { authenticate } from '../src/core/auth.js';
import { DidKeyResolver } from '../src/did/did-key-resolver.js';
import { Encoder } from '../src/index.js';
import { HdKey } from '../src/utils/hd-key.js';
import { KeyDerivationScheme } from '../src/utils/hd-key.js';
import { TestDataGenerator } from './utils/test-data-generator.js';
import { TestStores } from './test-stores.js';
import { TestStubGenerator } from './utils/test-stub-generator.js';

import chai, { expect } from 'chai';
import { DataStream, DidResolver, Dwn, Jws, Protocols, ProtocolsConfigure, ProtocolsQuery, Records, RecordsRead } from '../src/index.js';

chai.use(chaiAsPromised);

export function testEndToEndScenarios(): void {
describe('End-to-end Scenarios Spanning Features', () => {
let didResolver: DidResolver;
let messageStore: MessageStore;
let dataStore: DataStore;
let eventLog: EventLog;
let dwn: Dwn;

// important to follow the `before` and `after` pattern to initialize and clean the stores in tests
// so that different test suites can reuse the same backend store for testing
before(async () => {
didResolver = new DidResolver([new DidKeyResolver()]);

const stores = TestStores.get();
messageStore = stores.messageStore;
dataStore = stores.dataStore;
eventLog = stores.eventLog;

dwn = await Dwn.create({ didResolver, messageStore, dataStore, eventLog });
});

beforeEach(async () => {
sinon.restore(); // wipe all previous stubs/spies/mocks/fakes

// clean up before each test rather than after so that a test does not depend on other tests to do the clean up
await messageStore.clear();
await dataStore.clear();
await eventLog.clear();
});

after(async () => {
await dwn.close();
});

it('should support a multi-participant encrypted chat protocol', async () => {
// Scenario:
// 1. Alice starts a chat thread
// 2. Alice adds Bob as a participant with [symmetric key] encrypted using [Bob's participant-level public key]
// 3. Alice writes a chat message(s) in the thread
// 4. Alice sends an invite to Bob's DWN with the [context/thread ID]
// 5. Bob fetches the invite from his DWN and obtains the [context/thread ID]
// 6. Bob fetches the entire thread using the [context/thread ID]
// 7. Bob is able to decrypt all thread content

// creating Alice and Bob persona and setting up a stub DID resolver
const alice = await TestDataGenerator.generatePersona();
const bob = await TestDataGenerator.generatePersona();
TestStubGenerator.stubDidResolver(didResolver, [alice, bob]);

const protocolDefinition: ProtocolDefinition = threadRoleProtocolDefinition as ProtocolDefinition;

// Alice configures chat protocol with encryption
const protocolDefinitionForAlice
= await Protocols.deriveAndInjectPublicEncryptionKeys(protocolDefinition, alice.keyId, alice.keyPair.privateJwk);
const protocolsConfigureForAlice = await TestDataGenerator.generateProtocolsConfigure({
author : alice,
protocolDefinition : protocolDefinitionForAlice
});

const protocolsConfigureForAliceReply = await dwn.processMessage(
alice.did,
protocolsConfigureForAlice.message
);
expect(protocolsConfigureForAliceReply.status.code).to.equal(202);

// Bob configures chat protocol with encryption
const protocolDefinitionForBob
= await Protocols.deriveAndInjectPublicEncryptionKeys(protocolDefinition, bob.keyId, bob.keyPair.privateJwk);
const protocolsConfigureForBob = await TestDataGenerator.generateProtocolsConfigure({
author : bob,
protocolDefinition : protocolDefinitionForBob
});

const protocolsConfigureReply = await dwn.processMessage(bob.did, protocolsConfigureForBob.message);
expect(protocolsConfigureReply.status.code).to.equal(202);

// 1. Alice starts a chat thread writing to her own DWN
const threadBytes = Encoder.objectToBytes({ title: 'Top Secret' });
const threadRecord = await TestDataGenerator.generateProtocolEncryptedRecordsWrite({
plaintextBytes : threadBytes,
author : alice,
protocolDefinition : protocolDefinition,
protocolPath : 'thread',
encryptSymmetricKeyWithProtocolPathDerivedKey : false,
encryptSymmetricKeyWithProtocolContextDerivedKey : true
});
const threadRecordReply1 = await dwn.processMessage(alice.did, threadRecord.message, threadRecord.dataStream);
expect(threadRecordReply1.status.code).to.equal(202);

// 2. Alice adds Bob as a participant giving him the [context-derived private key] encrypted using [Bob's participant-level public key]

// the context-derived key to be used for encrypting symmetric keys
const aliceRootKey = {
rootKeyId : alice.keyId,
derivationScheme : KeyDerivationScheme.ProtocolContext,
derivedPrivateKey : alice.keyPair.privateJwk
};
const contextDerivationPath = Records.constructKeyDerivationPathUsingProtocolContextScheme(threadRecord.message.contextId);
const contextDerivedPrivateKey: DerivedPrivateJwk = await HdKey.derivePrivateKey(aliceRootKey, contextDerivationPath);
const contextDerivedPublicKey = threadRecord.encryptionInput.keyEncryptionInputs[0].publicKey;

// Alice queries Bob's DWN for Bob's chat protocol definition containing public key declarations
const protocolsQuery = await ProtocolsQuery.create({
filter: { protocol: threadRoleProtocolDefinition.protocol }
});
const protocolsQueryReply = await dwn.processMessage(bob.did, protocolsQuery.message);
const protocolConfigureMessageOfBobFetched = protocolsQueryReply.entries![0] as ProtocolsConfigureMessage;

// Alice verifies that the chat protocol definition is authored by Bob
await authenticate(protocolConfigureMessageOfBobFetched.authorization, didResolver);
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
const protocolsConfigureOfBobFetched = await ProtocolsConfigure.parse(protocolConfigureMessageOfBobFetched);
expect(protocolsConfigureOfBobFetched.author).to.equal(bob.did);

// generate the encrypted participant record using Bob's protocol configuration fetched
const participantBobRecord = await TestDataGenerator.generateProtocolEncryptedRecordsWrite({
plaintextBytes : Encoder.objectToBytes(contextDerivedPrivateKey),
author : alice,
recipient : bob.did,
protocolDefinition : protocolsConfigureOfBobFetched.message.descriptor.definition,
protocolPath : 'thread/participant',
protocolContextId : threadRecord.message.contextId,
protocolParentId : threadRecord.message.recordId,
encryptSymmetricKeyWithProtocolPathDerivedKey : true,
encryptSymmetricKeyWithProtocolContextDerivedKey : false // this could be `true` also, but mostly orthogonal to the scenario
});
const participantRecordReply = await dwn.processMessage(alice.did, participantBobRecord.message, participantBobRecord.dataStream);
expect(participantRecordReply.status.code).to.equal(202);

// 3. Alice writes a chat message(s) in the thread
const messageByAlice = 'Message from Alice';
const chatMessageByAlice = await TestDataGenerator.generateProtocolEncryptedRecordsWrite({
plaintextBytes : Encoder.stringToBytes(messageByAlice),
author : alice,
recipient : alice.did, // this is arguably irrelevant in multi-party communication
protocolDefinition : protocolDefinition,
protocolPath : 'thread/chat',
protocolContextId : threadRecord.message.contextId,
protocolContextDerivingRootKeyId : aliceRootKey.rootKeyId,
protocolContextDerivedPublicJwk : contextDerivedPublicKey,
protocolParentId : threadRecord.message.recordId,
encryptSymmetricKeyWithProtocolPathDerivedKey : false,
encryptSymmetricKeyWithProtocolContextDerivedKey : true
});
const chatMessageReply = await dwn.processMessage(alice.did, chatMessageByAlice.message, chatMessageByAlice.dataStream);
expect(chatMessageReply.status.code).to.equal(202);

// Assume the below steps can be done since it is a common DWN usage pattern
// 4. Alice sends an invite to Bob's DWN with the [context/thread ID]
// 5. Bob fetches the invite from his DWN and obtains the [context/thread ID]

// 6. Bob fetches the entire thread using the [context/thread ID]
// Test that Bob is able to read his 'participant' role to obtain the context-derived private key for message decryption.
// He doesn't need to invoke the role because recipients of a record are always authorized to read it
const participantRead = await RecordsRead.create({
authorizationSigner : Jws.createSigner(bob),
filter : {
protocolPath : 'thread/participant',
recipient : bob.did,
contextId : threadRecord.message.contextId
},
});
const participantReadReply = await dwn.processMessage(alice.did, participantRead.message) as RecordsReadReply;
expect(participantReadReply.status.code).to.equal(200);

// Test that Bob is able to read the thread root record
const threadRead = await RecordsRead.create({
authorizationSigner : Jws.createSigner(bob),
filter : {
protocolPath : 'thread',
contextId : threadRecord.message.contextId
},
protocolRole: 'thread/participant'
});
const threadReadReply = await dwn.processMessage(alice.did, threadRead.message) as RecordsReadReply;
expect(threadReadReply.status.code).to.equal(200);
expect(threadReadReply.record).to.exist;

// Test Bob can invoke his 'participant' role to read the chat message
// TODO: #555 - We currently lack role-authorized RecordsQuery for a realistic scenario (https://github.com/TBD54566975/dwn-sdk-js/issues/555)
thehenrytsai marked this conversation as resolved.
Show resolved Hide resolved
const chatRead = await RecordsRead.create({
authorizationSigner : Jws.createSigner(bob),
filter : {
protocolPath : 'thread/chat',
contextId : threadRecord.message.contextId
},
protocolRole: 'thread/participant'
});
const chatReadReply = await dwn.processMessage(alice.did, chatRead.message) as RecordsReadReply;
expect(chatReadReply.status.code).to.equal(200);
expect(chatReadReply.record).to.exist;

// 7. Bob is able to decrypt all thread content
// Bob decrypts the participant message to obtain the context-derived private key
const bobRootKey = {
rootKeyId : bob.keyId,
derivationScheme : KeyDerivationScheme.ProtocolPath,
derivedPrivateKey : bob.keyPair.privateJwk
};
const participantRecordFetched = participantReadReply.record!;
const encryptedContextDerivedPrivateKeyBytes = await DataStream.toBytes(participantRecordFetched.data); // to create streams for testing
const derivationPathFromProtocolPath = Records.constructKeyDerivationPathUsingProtocolPathScheme(participantRecordFetched.descriptor);
const bobProtocolPathDerivedPrivateKey = await HdKey.derivePrivateKey(bobRootKey, derivationPathFromProtocolPath);
const decryptedContextDerivedKeyStream = await Records.decrypt(
participantRecordFetched,
bobProtocolPathDerivedPrivateKey,
DataStream.fromBytes(encryptedContextDerivedPrivateKeyBytes)
);
const decryptedContextDerivedPrivateKey = await DataStream.toObject(decryptedContextDerivedKeyStream) as DerivedPrivateJwk;
expect(decryptedContextDerivedPrivateKey).to.deep.equal(contextDerivedPrivateKey);

// Arguably unrelated to the scenario, but let's sanity check that Bob's root key can also decrypt the encrypted context-derived private key
const decryptedContextDerivedKeyStream2 = await Records.decrypt(
participantRecordFetched,
bobRootKey,
DataStream.fromBytes(encryptedContextDerivedPrivateKeyBytes)
);
const decryptedContextDerivedPrivateKey2 = await DataStream.toObject(decryptedContextDerivedKeyStream2) as DerivedPrivateJwk;
expect(decryptedContextDerivedPrivateKey2).to.deep.equal(contextDerivedPrivateKey);

// Verify that Bob can now decrypt Alice's chat thread record using the decrypted context-derived key
const decryptedChatThread = await Records.decrypt(
threadReadReply.record!,
decryptedContextDerivedPrivateKey,
threadReadReply.record!.data
);
expect(await DataStream.toBytes(decryptedChatThread)).to.deep.equal(threadBytes);

// Verify that Bob can now decrypt Alice's chat message using the decrypted context-derived key
const encryptedChatMessageBytes = await DataStream.toBytes(chatReadReply.record!.data); // to create streams for testing
const decryptedChatMessage = await Records.decrypt(
chatReadReply.record!,
decryptedContextDerivedPrivateKey,
DataStream.fromBytes(encryptedChatMessageBytes)
);
expect(await DataStream.toBytes(decryptedChatMessage)).to.deep.equal(Encoder.stringToBytes(messageByAlice));

// Arguably unrelated to the scenario, but let's also sanity check that Alice's root key can also decrypt the encrypted chat message
const decryptedChatMessageStream2 = await Records.decrypt(
chatReadReply.record!,
aliceRootKey,
DataStream.fromBytes(encryptedChatMessageBytes)
);
expect(await DataStream.toBytes(decryptedChatMessageStream2)).to.deep.equal(Encoder.stringToBytes(messageByAlice));
});
});
}
Loading