From 630546dac212d3070e6250f7a06e213260230068 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Wed, 11 Oct 2023 14:39:15 -0700 Subject: [PATCH 1/3] Wrote scenario test for a multi-party encrypted chat app --- src/utils/data-stream.ts | 9 + src/utils/encoder.ts | 9 +- tests/end-to-end-tests.spec.ts | 268 ++++++++++++++++++ tests/handlers/records-read.spec.ts | 36 +-- tests/handlers/records-write.spec.ts | 4 +- tests/test-suite.ts | 3 + tests/utils/test-data-generator.ts | 132 +++++---- .../protocol-definitions/thread-role.json | 6 +- 8 files changed, 391 insertions(+), 76 deletions(-) create mode 100644 tests/end-to-end-tests.spec.ts diff --git a/src/utils/data-stream.ts b/src/utils/data-stream.ts index f73eeee6b..40582412b 100644 --- a/src/utils/data-stream.ts +++ b/src/utils/data-stream.ts @@ -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 { + const contentBytes = await DataStream.toBytes(readableStream); + const contentObject = Encoder.bytesToObject(contentBytes); + return contentObject; + } + /** * Concatenates the array of bytes given into one Uint8Array. */ diff --git a/src/utils/encoder.ts b/src/utils/encoder.ts index 5140abe2d..3defd8be3 100644 --- a/src/utils/encoder.ts +++ b/src/utils/encoder.ts @@ -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; } @@ -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): Uint8Array { const objectString = JSON.stringify(obj); const objectBytes = textEncoder.encode(objectString); diff --git a/tests/end-to-end-tests.spec.ts b/tests/end-to-end-tests.spec.ts new file mode 100644 index 000000000..1e4931686 --- /dev/null +++ b/tests/end-to-end-tests.spec.ts @@ -0,0 +1,268 @@ +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); +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 thread-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 this 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 thread-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); + 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 this 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 + // NOTE: we are currently lacking role-authorized RecordsQuery for a more efficient and realistic fetch of messages + 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)); + }); + }); +} \ No newline at end of file diff --git a/tests/handlers/records-read.spec.ts b/tests/handlers/records-read.spec.ts index 619cecbae..674f4d83d 100644 --- a/tests/handlers/records-read.spec.ts +++ b/tests/handlers/records-read.spec.ts @@ -1715,11 +1715,11 @@ export function testRecordsReadHandler(): void { // Alice configures chat protocol with encryption const protocolDefinition: ProtocolDefinition = chatProtocolDefinition as ProtocolDefinition; - const encryptedProtocolDefinitionForAlice + const protocolDefinitionForAlice = await Protocols.deriveAndInjectPublicEncryptionKeys(protocolDefinition, alice.keyId, alice.keyPair.privateJwk); const protocolsConfigureForAlice = await TestDataGenerator.generateProtocolsConfigure({ author : alice, - protocolDefinition : encryptedProtocolDefinitionForAlice + protocolDefinition : protocolDefinitionForAlice }); const protocolsConfigureForAliceReply = await dwn.processMessage( @@ -1729,11 +1729,11 @@ export function testRecordsReadHandler(): void { expect(protocolsConfigureForAliceReply.status.code).to.equal(202); // Bob configures chat protocol with encryption - const encryptedProtocolDefinitionForBob + const protocolDefinitionForBob = await Protocols.deriveAndInjectPublicEncryptionKeys(protocolDefinition, bob.keyId, bob.keyPair.privateJwk); const protocolsConfigureForBob = await TestDataGenerator.generateProtocolsConfigure({ author : bob, - protocolDefinition : encryptedProtocolDefinitionForBob + protocolDefinition : protocolDefinitionForBob }); const protocolsConfigureReply = await dwn.processMessage(bob.did, protocolsConfigureForBob.message); @@ -1755,10 +1755,12 @@ export function testRecordsReadHandler(): void { const plaintextMessageToAlice = TestDataGenerator.randomBytes(100); const { message, dataStream, recordsWrite, encryptedDataBytes, encryptionInput } = await TestDataGenerator.generateProtocolEncryptedRecordsWrite({ - plaintextBytes : plaintextMessageToAlice, - author : bob, - targetProtocolDefinition : protocolsConfigureForAlice.message.descriptor.definition, - protocolPath : 'thread' + plaintextBytes : plaintextMessageToAlice, + author : bob, + protocolDefinition : protocolsConfigureForAlice.message.descriptor.definition, + protocolPath : 'thread', + encryptSymmetricKeyWithProtocolPathDerivedKey : true, + encryptSymmetricKeyWithProtocolContextDerivedKey : true }); // Bob writes the encrypted chat thread to Alice's DWN @@ -1828,14 +1830,16 @@ export function testRecordsReadHandler(): void { const plaintextMessageToBob = TestDataGenerator.randomBytes(100); const recordsWriteToBob = await TestDataGenerator.generateProtocolEncryptedRecordsWrite({ - plaintextBytes : plaintextMessageToBob, - author : alice, - targetProtocolDefinition : protocolsConfigureForBob.message.descriptor.definition, - protocolPath : 'thread/message', - protocolContextId : fetchedRecordsWrite.contextId, - protocolContextDerivingRootKeyId : protocolContextDerivingRootKeyIdReturned, - protocolContextDerivedPublicJwk : protocolContextDerivedPublicJwkReturned!, - protocolParentId : fetchedRecordsWrite.recordId + plaintextBytes : plaintextMessageToBob, + author : alice, + protocolDefinition : protocolsConfigureForBob.message.descriptor.definition, + protocolPath : 'thread/message', + protocolContextId : fetchedRecordsWrite.contextId, + protocolContextDerivingRootKeyId : protocolContextDerivingRootKeyIdReturned, + protocolContextDerivedPublicJwk : protocolContextDerivedPublicJwkReturned!, + protocolParentId : fetchedRecordsWrite.recordId, + encryptSymmetricKeyWithProtocolPathDerivedKey : true, + encryptSymmetricKeyWithProtocolContextDerivedKey : true }); // Alice sends the message to Bob diff --git a/tests/handlers/records-write.spec.ts b/tests/handlers/records-write.spec.ts index 349f50841..0c89a3bc3 100644 --- a/tests/handlers/records-write.spec.ts +++ b/tests/handlers/records-write.spec.ts @@ -367,7 +367,7 @@ export function testRecordsWriteHandler(): void { author: alice, protocolDefinition }); - const protocolWriteReply = await dwn.processMessage(alice.did, protocolsConfig.message, protocolsConfig.dataStream); + const protocolWriteReply = await dwn.processMessage(alice.did, protocolsConfig.message); expect(protocolWriteReply.status.code).to.equal(202); // Sanity test that Bob cannot write to a protocol record to Alice's DWN @@ -446,7 +446,7 @@ export function testRecordsWriteHandler(): void { author: carol, protocolDefinition }); - const protocolWriteReply = await dwn.processMessage(carol.did, protocolsConfig.message, protocolsConfig.dataStream); + const protocolWriteReply = await dwn.processMessage(carol.did, protocolsConfig.message); expect(protocolWriteReply.status.code).to.equal(202); // Test that Carol is not able to store the message Alice created diff --git a/tests/test-suite.ts b/tests/test-suite.ts index 83fd82a54..85d0fce04 100644 --- a/tests/test-suite.ts +++ b/tests/test-suite.ts @@ -1,6 +1,7 @@ import type { DataStore, EventLog, MessageStore } from '../src/index.js'; import { testDwnClass } from './dwn.spec.js'; +import { testEndToEndScenarios } from './end-to-end-tests.spec.js'; import { testEventsGetHandler } from './handlers/events-get.spec.js'; import { testMessagesGetHandler } from './handlers/messages-get.spec.js'; import { testMessageStore } from './store/message-store.spec.js'; @@ -42,5 +43,7 @@ export class TestSuite { testRecordsQueryHandler(); testRecordsReadHandler(); testRecordsWriteHandler(); + + testEndToEndScenarios(); } } \ No newline at end of file diff --git a/tests/utils/test-data-generator.ts b/tests/utils/test-data-generator.ts index 2e4935d17..d2331a5eb 100644 --- a/tests/utils/test-data-generator.ts +++ b/tests/utils/test-data-generator.ts @@ -74,6 +74,10 @@ export type GenerateProtocolsConfigureInput = { * Only takes effect if `protocolDefinition` is not explicitly set. Defaults to false if not specified. */ published?: boolean; + + /** + * Author who will be signing the protocol config created. + */ author?: Persona; messageTimestamp?: string; protocolDefinition?: ProtocolDefinition; @@ -83,7 +87,6 @@ export type GenerateProtocolsConfigureInput = { export type GenerateProtocolsConfigureOutput = { author: Persona; message: ProtocolsConfigureMessage; - dataStream?: Readable; protocolsConfigure: ProtocolsConfigure; }; @@ -308,9 +311,6 @@ export class TestDataGenerator { definition.structure[generatedLabel] = {}; } - // TODO: #451 - Remove reference and use of dataStream everywhere in tests - https://github.com/TBD54566975/dwn-sdk-js/issues/451 - const dataStream = undefined; - const authorizationSigner = Jws.createSigner(author); const options: ProtocolsConfigureOptions = { @@ -325,7 +325,6 @@ export class TestDataGenerator { return { author, message: protocolsConfigure.message, - dataStream, protocolsConfigure }; }; @@ -418,17 +417,30 @@ export class TestDataGenerator { }; /** - * Generates a RecordsWrite message for testing. + * Generates a encrypted RecordsWrite message for testing. + * + * @param input.protocolDefinition Protocol definition used to generate the RecordsWrite. + * Must be the RECIPIENT's protocol definition if `encryptSymmetricKeyWithProtocolPathDerivedKey` is true, + * because the recipient's public keys will be needed to encrypt the symmetric key. + * + * @param input.encryptSymmetricKeyWithProtocolPathDerivedKey + * Set to `true` to attach the symmetric key encrypted by the protocol path derived public key + * + * @param input.encryptSymmetricKeyWithProtocolContextDerivedKey + * Set to `true` to attach the symmetric key encrypted by the protocol context derived public key */ public static async generateProtocolEncryptedRecordsWrite(input: { plaintextBytes: Uint8Array, author: Persona, - targetProtocolDefinition: ProtocolDefinition, + recipient?: string, + protocolDefinition: ProtocolDefinition, protocolPath: string, protocolContextId?: string, protocolContextDerivingRootKeyId?: string, protocolContextDerivedPublicJwk?: PublicJwk, - protocolParentId?: string + protocolParentId?: string, + encryptSymmetricKeyWithProtocolPathDerivedKey: boolean, + encryptSymmetricKeyWithProtocolContextDerivedKey: boolean, }): Promise<{ message: RecordsWriteMessage; dataStream: Readable; @@ -439,7 +451,8 @@ export class TestDataGenerator { const { plaintextBytes, author, - targetProtocolDefinition, + recipient, + protocolDefinition, protocolPath, protocolContextId, protocolContextDerivingRootKeyId, @@ -462,69 +475,78 @@ export class TestDataGenerator { const { message, dataStream, recordsWrite } = await TestDataGenerator.generateRecordsWrite( { author, - protocol : targetProtocolDefinition.protocol, + recipient, + protocol : protocolDefinition.protocol, protocolPath, contextId : protocolContextId, parentId : protocolParentId, - schema : targetProtocolDefinition.types[recordType].schema, - dataFormat : targetProtocolDefinition.types[recordType].dataFormats![0], + schema : protocolDefinition.types[recordType].schema, + dataFormat : protocolDefinition.types[recordType].dataFormats?.[0], data : encryptedDataBytes } ); - // Bob prepares the symmetric key encryption input for protocol-path derived public key - let protocolSegment = targetProtocolDefinition.structure; - for (const pathSegment of protocolPathSegments) { - protocolSegment = protocolSegment[pathSegment]; - } - - const protocolPathDerivedPublicJwk = protocolSegment.$encryption?.publicKeyJwk; - const protocolPathDerivationRootKeyId = protocolSegment.$encryption?.rootKeyId; - const protocolPathDerivedKeyEncryptionInput: KeyEncryptionInput = { - publicKeyId : protocolPathDerivationRootKeyId, - publicKey : protocolPathDerivedPublicJwk!, - derivationScheme : KeyDerivationScheme.ProtocolPath + // final encryption input (`keyEncryptionInputs` to be populated below) + const encryptionInput: EncryptionInput = { + initializationVector : dataEncryptionInitializationVector, + key : dataEncryptionKey, + keyEncryptionInputs : [] }; - // generate key encryption input to that will encrypt the symmetric encryption key using protocol-context derived public key - let protocolContextDerivedKeyEncryptionInput: KeyEncryptionInput; - if (protocolContextId === undefined) { - // author generates protocol-context derived public key for encrypting symmetric key - const authorRootPrivateKey: DerivedPrivateJwk = { - rootKeyId : author.keyId, - derivationScheme : KeyDerivationScheme.ProtocolContext, - derivedPrivateKey : author.keyPair.privateJwk + if (input.encryptSymmetricKeyWithProtocolPathDerivedKey) { + // locate the rule set corresponding the protocol path of the message + let protocolRuleSetSegment = protocolDefinition.structure; + for (const pathSegment of protocolPathSegments) { + protocolRuleSetSegment = protocolRuleSetSegment[pathSegment]; + } + + const protocolPathDerivedPublicJwk = protocolRuleSetSegment.$encryption?.publicKeyJwk; + const protocolPathDerivationRootKeyId = protocolRuleSetSegment.$encryption?.rootKeyId; + const protocolPathDerivedKeyEncryptionInput: KeyEncryptionInput = { + publicKeyId : protocolPathDerivationRootKeyId, + publicKey : protocolPathDerivedPublicJwk!, + derivationScheme : KeyDerivationScheme.ProtocolPath }; - const contextId = await RecordsWrite.getEntryId(author.did, message.descriptor); - const contextDerivationPath = Records.constructKeyDerivationPathUsingProtocolContextScheme(contextId); - const authorGeneratedProtocolContextDerivedPublicJwk = await HdKey.derivePublicKey(authorRootPrivateKey, contextDerivationPath); + encryptionInput.keyEncryptionInputs.push(protocolPathDerivedKeyEncryptionInput); + } - protocolContextDerivedKeyEncryptionInput = { - publicKeyId : author.keyId, - publicKey : authorGeneratedProtocolContextDerivedPublicJwk, - derivationScheme : KeyDerivationScheme.ProtocolContext - }; - } else { - if (protocolContextDerivingRootKeyId === undefined || + if (input.encryptSymmetricKeyWithProtocolContextDerivedKey) { + // generate key encryption input to that will encrypt the symmetric encryption key using protocol-context derived public key + let protocolContextDerivedKeyEncryptionInput: KeyEncryptionInput; + if (protocolContextId === undefined) { + // author generates protocol-context derived public key for encrypting symmetric key + const authorRootPrivateKey: DerivedPrivateJwk = { + rootKeyId : author.keyId, + derivationScheme : KeyDerivationScheme.ProtocolContext, + derivedPrivateKey : author.keyPair.privateJwk + }; + + const contextId = await RecordsWrite.getEntryId(author.did, message.descriptor); + const contextDerivationPath = Records.constructKeyDerivationPathUsingProtocolContextScheme(contextId); + const authorGeneratedProtocolContextDerivedPublicJwk = await HdKey.derivePublicKey(authorRootPrivateKey, contextDerivationPath); + + protocolContextDerivedKeyEncryptionInput = { + publicKeyId : author.keyId, + publicKey : authorGeneratedProtocolContextDerivedPublicJwk, + derivationScheme : KeyDerivationScheme.ProtocolContext + }; + } else { + if (protocolContextDerivingRootKeyId === undefined || protocolContextDerivedPublicJwk === undefined) { - throw new Error ('`protocolContextDerivingRootKeyId` and `protocolContextDerivedPublicJwk` must both be defined if `protocolContextId` is given'); + throw new Error ('`protocolContextDerivingRootKeyId` and `protocolContextDerivedPublicJwk` must both be defined if `protocolContextId` is given'); + } + + protocolContextDerivedKeyEncryptionInput = { + publicKeyId : protocolContextDerivingRootKeyId!, + publicKey : protocolContextDerivedPublicJwk!, + derivationScheme : KeyDerivationScheme.ProtocolContext + }; } - protocolContextDerivedKeyEncryptionInput = { - publicKeyId : protocolContextDerivingRootKeyId!, - publicKey : protocolContextDerivedPublicJwk!, - derivationScheme : KeyDerivationScheme.ProtocolContext - }; + encryptionInput.keyEncryptionInputs.push(protocolContextDerivedKeyEncryptionInput); } - // final encryption input - const encryptionInput: EncryptionInput = { - initializationVector : dataEncryptionInitializationVector, - key : dataEncryptionKey, - keyEncryptionInputs : [protocolPathDerivedKeyEncryptionInput, protocolContextDerivedKeyEncryptionInput] - }; - await recordsWrite.encryptSymmetricEncryptionKey(encryptionInput); await recordsWrite.sign(Jws.createSigner(author)); diff --git a/tests/vectors/protocol-definitions/thread-role.json b/tests/vectors/protocol-definitions/thread-role.json index 49a0deeca..ae950c697 100644 --- a/tests/vectors/protocol-definitions/thread-role.json +++ b/tests/vectors/protocol-definitions/thread-role.json @@ -1,6 +1,6 @@ { "protocol": "http://thread-role.xyz", - "published": false, + "published": true, "types": { "thread": {}, "participant": {}, @@ -20,6 +20,10 @@ { "role": "thread/participant", "can": "read" + }, + { + "role": "thread/participant", + "can": "write" } ] }, From 2f05d74bda3a5074af5d6b2039787f59081fb9ed Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Wed, 11 Oct 2023 14:54:40 -0700 Subject: [PATCH 2/3] Minor fixes --- src/interfaces/records-read.ts | 4 ++-- tests/end-to-end-tests.spec.ts | 2 +- tests/interfaces/records-read.spec.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/interfaces/records-read.ts b/src/interfaces/records-read.ts index 052bd0fa1..79e883809 100644 --- a/src/interfaces/records-read.ts +++ b/src/interfaces/records-read.ts @@ -14,7 +14,7 @@ import { DwnInterfaceName, DwnMethodName } from '../core/message.js'; export type RecordsReadOptions = { filter: RecordsFilter; - date?: string; + messageTimestamp?: string; authorizationSigner?: Signer; permissionsGrantId?: string; /** @@ -50,7 +50,7 @@ export class RecordsRead extends Message { interface : DwnInterfaceName.Records, method : DwnMethodName.Read, filter : Records.normalizeFilter(filter), - messageTimestamp : options.date ?? currentTime, + messageTimestamp : options.messageTimestamp ?? currentTime, }; removeUndefinedProperties(descriptor); diff --git a/tests/end-to-end-tests.spec.ts b/tests/end-to-end-tests.spec.ts index 1e4931686..2c3afd30f 100644 --- a/tests/end-to-end-tests.spec.ts +++ b/tests/end-to-end-tests.spec.ts @@ -198,7 +198,7 @@ export function testEndToEndScenarios(): void { expect(threadReadReply.record).to.exist; // Test Bob can invoke his 'participant' role to read the chat message - // NOTE: we are currently lacking role-authorized RecordsQuery for a more efficient and realistic fetch of messages + // TODO: #555 - We currently lack role-authorized RecordsQuery for a realistic scenario (https://github.com/TBD54566975/dwn-sdk-js/issues/555) const chatRead = await RecordsRead.create({ authorizationSigner : Jws.createSigner(bob), filter : { diff --git a/tests/interfaces/records-read.spec.ts b/tests/interfaces/records-read.spec.ts index 100b4c8e7..b14143ecc 100644 --- a/tests/interfaces/records-read.spec.ts +++ b/tests/interfaces/records-read.spec.ts @@ -22,7 +22,7 @@ describe('RecordsRead', () => { recordId: 'anything', }, authorizationSigner : Jws.createSigner(alice), - date : currentTime + messageTimestamp : currentTime }); expect(recordsRead.message.descriptor.messageTimestamp).to.equal(currentTime); From a35603a8b81412f95b687bf4641178deef6995e6 Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Thu, 12 Oct 2023 13:52:05 -0700 Subject: [PATCH 3/3] Addressed review comments --- src/index.ts | 5 ++++- tests/end-to-end-tests.spec.ts | 9 ++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 285f104ed..7fc87e477 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; @@ -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'; diff --git a/tests/end-to-end-tests.spec.ts b/tests/end-to-end-tests.spec.ts index 2c3afd30f..4cf2c1699 100644 --- a/tests/end-to-end-tests.spec.ts +++ b/tests/end-to-end-tests.spec.ts @@ -17,7 +17,6 @@ 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); chai.use(chaiAsPromised); export function testEndToEndScenarios(): void { @@ -57,10 +56,10 @@ export function testEndToEndScenarios(): void { 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 thread-level public key] + // 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 this DWN and obtains 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 @@ -109,7 +108,7 @@ export function testEndToEndScenarios(): void { 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 thread-level public key] + // 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 = { @@ -168,7 +167,7 @@ export function testEndToEndScenarios(): void { // 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 this DWN and obtains 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.