diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 5b0ac70f199..06e7cf989d4 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -659,6 +659,8 @@ Nothing else is verifiable on chain: not that the bytes decrypt, not that they d In Rust the declaration is `DocumentProperty::encrypted_for` (`Option`), listed per document type by `DocumentTypeV0Getters::encrypted_properties()`, and the shape check is `DocumentTypeBasicMethods::validate_encrypted_property_shapes()`, versioned on the `validate_encrypted_property_shapes` method slot (`None` before protocol version 14, which is what keeps the in-place replace call inert). In JavaScript, `contract.documentTypeEncryptedProperties(name)` and `contract.documentEncryptedProperties` expose the same declarations, and the shape error reaches an app as `DocumentEncryptionErrorCode.InvalidEncryptedPropertyShape`. +Clients encrypt and decrypt through the declaration rather than a per-contract recipe. The Rust SDK's `dash_sdk::platform::encrypted_for` module has `encrypt_property`, which writes the ciphertext and both key id properties, and `decrypt_property`. `EncryptedPropertyEnvelope::read` names the identities and key ids a reader needs. `select_encryption_keys` picks the keys the document type's `identityPublicKey` references demand through their `keyRequirements`. In JavaScript the same helpers are `sdk.encryptedFor.encrypt`, `decrypt` and `envelope` (`WasmSdk.encryptDocumentProperty`, `decryptDocumentProperty` and `encryptedPropertyEnvelope`). The layout has no authentication tag, so a wrong key fails the padding check except about once in 256 attempts, when it yields garbage. + ## Rules and Guidelines **Do:** diff --git a/docs/protocol/moderation-charters.md b/docs/protocol/moderation-charters.md index 7196c69bafd..b98e0da60b3 100644 --- a/docs/protocol/moderation-charters.md +++ b/docs/protocol/moderation-charters.md @@ -188,3 +188,29 @@ for the path that seats a team: | The description is over `SystemLimits::max_moderation_charter_description_length` (4096) bytes; the schema's `maxLength` counts characters | `ModerationCharterDescriptionTooLongError` | 11002 | `ElectedCharter` reads an elected charter's properties for the same path. + +## Reading and writing from a client + +Every read is an ordinary proved document query on the system contract, through +the indexes above; no endpoint is specific to charters. The Rust SDK +(`dash_sdk::platform::moderation_charters`) and the JavaScript SDK +(`sdk.moderationCharters` in `@dashevo/evo-sdk`) offer them by name: + +| Read | Query | Rust | JavaScript | +| --- | --- | --- | --- | +| A contract's seated charter | `electedCharter.byTargetContract`, at most one | `Sdk::fetch_seated_charter` | `seatedCharter` | +| A proposal | `submittedCharter` by id | `Sdk::fetch_submitted_charter` | `submittedCharter` | +| The team | the seated charter, then `addedModerator` and `removedModerator` by `byElectedCharterMember`, combined as `ElectedCharter::active_members` does | `Sdk::fetch_moderation_team` | `team` | +| The proposals for a contract | `submittedCharter.byTargetContract`, in filing order, paged | `Sdk::fetch_submitted_charters` | `submittedCharters` | +| The join requests for a proposal | `joinRequest.bySubmittedCharter`, paged | `Sdk::fetch_join_requests` | `joinRequests` | +| A charter's pending resignation requests | `resignationRequest.byElectedCharterOwner`, less the writers the charter has a `removedModerator` for | `Sdk::fetch_pending_resignation_requests` | `pendingResignationRequests` | + +`Sdk::build_join_request` and `Sdk::build_resignation_request` +(`buildJoinRequest` and `buildResignationRequest` in JavaScript) build the two +documents whose message only the leader reads. They pick the keys the schema's +`keyRequirements` demand, the leader's decryption key bound to +`submittedCharter` and the writer's encryption key bound to `joinRequest`, +encrypt the message and set `recipientId`, `recipientKeyId` and `senderKeyId`. +The encryption is the generic `encryptedFor` helper +(`dash_sdk::platform::encrypted_for`, `sdk.encryptedFor`), which reads the +declaration from any contract; the leader decrypts with it too. diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index e99ffc3a9ce..834ef65f25c 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -104,6 +104,8 @@ The SDK organises its API into domain-specific facades, each accessible as a pro | [`sdk.group`](src/group/facade.ts) | Group membership, actions, and contested resources | | [`sdk.voting`](src/voting/facade.ts) | Contested resource vote states and polls | | [`sdk.shielded`](src/shielded/facade.ts) | Query shielded pool state, encrypted notes, anchors, and nullifier status | +| [`sdk.encryptedFor`](src/encrypted-for/facade.ts) | Encrypt and decrypt the byte properties a document type declares `encryptedFor`, for any contract | +| [`sdk.moderationCharters`](src/moderation-charters/facade.ts) | Read a contract's seated charter, its team, proposals and join requests; build join and resignation requests | A `wallet` namespace is also exported with utilities for BIP39 mnemonic generation and validation, BIP44/DIP9/DIP13 key derivation (path helpers included), extended-key conversion (`xprvToXpub`, `deriveChildPublicKey`), key-pair generation and import (`generateKeyPair`, `keyPairFromWif`, `keyPairFromHex`), public-key-to-address conversion, address validation, message signing, and Dashpay contact-key derivation. See [`src/wallet/functions.ts`](src/wallet/functions.ts) for the full list. @@ -286,7 +288,81 @@ try { } ``` -Encrypt and decrypt helpers keyed off the declaration are not part of the SDK yet; the Rust `platform-encryption` crate has the primitives. +`sdk.encryptedFor` encrypts and decrypts such a property, reading the declaration from the contract, so the same calls work for every contract that declares one. They run locally and need no connection. + +```ts +import { PrivateKey } from '@dashevo/evo-sdk'; + +// The writer: the fields to set on the document, the ciphertext and both key ids +const fields = await sdk.encryptedFor.encrypt({ + dataContract: contract, + documentTypeName: 'joinRequest', + property: 'encryptedMessage', + plaintext: 'I would like to help moderate', + senderKey: writerIdentity.getPublicKeyById(4), // its id goes into senderKeyId + senderPrivateKey: PrivateKey.fromWIF(writerKeyWif), + recipientKey: leaderIdentity.getPublicKeyById(2), // its id goes into recipientKeyId +}); +// { encryptedMessage: Uint8Array(48), recipientKeyId: 2, senderKeyId: 4 } + +// The reader: whose keys the stored document names, then decrypt +const envelope = await sdk.encryptedFor.envelope({ dataContract: contract, document, property: 'encryptedMessage' }); +const sender = await sdk.identities.fetch(envelope.senderId); +const message = await sdk.encryptedFor.decrypt({ + dataContract: contract, + document, + property: 'encryptedMessage', + recipientPrivateKey: PrivateKey.fromWIF(leaderDecryptionKeyWif), // the key recipientKeyId names + senderKey: sender.getPublicKeyById(envelope.senderKeyId), +}); +``` + +The IV is fresh randomness on every call. The scheme carries no authentication tag: a wrong key is caught only by the padding check, which it passes about once in 256 attempts and then returns garbage, so an app that must tell the two apart has to recognise its plaintext. ECDH is symmetric, so the writer can read its own message back with its private key and the recipient's key. + +## Moderation charters + +A contract that declares elected moderation is moderated by the team of its seated charter in the moderation charters system contract (protocol version 14, `EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88`). `sdk.moderationCharters` reads it with ordinary proved document queries: + +```ts +// The seated charter: the one electedCharter for the contract, or undefined +const charter = await sdk.moderationCharters.seatedCharter(contractId); + +// Its proposal, the submittedCharter it runs on +const proposal = await sdk.moderationCharters.submittedCharter(charter.properties.submittedCharterId); + +// The team: the leader plus the elected members and the additions, less the removals +const team = await sdk.moderationCharters.team(contractId); +team.leaderId; team.members; team.contains(identityId); + +// Proposals for a contract in filing order, and the join requests for one, a page at a time +const proposals = await sdk.moderationCharters.submittedCharters({ targetContractId: contractId, limit: 20 }); +const requests = await sdk.moderationCharters.joinRequests({ submittedCharterId: proposalId }); + +// Resignation requests the leader has not acted on with a removal yet +const pending = await sdk.moderationCharters.pendingResignationRequests(charter.id); +``` + +A join request and a resignation request carry a message only the leader can read. The builders fetch the proposal (or the charter) and the leader, pick the leader's decryption key bound to `submittedCharter` and the writer's encryption key bound to `joinRequest`, the keys the schema's `keyRequirements` demand, encrypt the message and set `recipientId`, `recipientKeyId` and `senderKeyId`: + +```ts +const joinRequest = await sdk.moderationCharters.buildJoinRequest({ + submittedCharterId: proposalId, + message: 'Five years moderating a forum; happy to help', + writer: identity, // or its id + writerEncryptionKey: PrivateKey.fromWIF(encryptionKeyWif), +}); +await sdk.documents.create({ document: joinRequest, identityKey, signer }); + +const resignation = await sdk.moderationCharters.buildResignationRequest({ + electedCharterId: charter.id, + message: 'Stepping down at the end of the month', + writer: identity, + writerEncryptionKey: PrivateKey.fromWIF(encryptionKeyWif), +}); +await sdk.documents.create({ document: resignation, identityKey, signer }); +``` + +The leader reads either with `sdk.encryptedFor.decrypt`. ## Immutable properties (`immutable`) diff --git a/packages/js-evo-sdk/src/encrypted-for/facade.ts b/packages/js-evo-sdk/src/encrypted-for/facade.ts new file mode 100644 index 00000000000..039ea4a3705 --- /dev/null +++ b/packages/js-evo-sdk/src/encrypted-for/facade.ts @@ -0,0 +1,47 @@ +import * as wasm from '../wasm.js'; +import type { EvoSDK } from '../sdk.js'; + +/** + * Encrypting and decrypting the byte properties a document type declares `encryptedFor`. + * + * Every method reads the declaration from the contract given, so it works for any contract + * that declares one. None needs a connection: they run locally. + */ +export class EncryptedForFacade { + private sdk: EvoSDK; + + constructor(sdk: EvoSDK) { + this.sdk = sdk; + } + + /** + * Encrypts a message into a property declaring `encryptedFor`. + * + * @returns The properties to set on the document: the ciphertext at `property` and the two + * key ids at the declaration's `recipientKey` and `senderKey` paths. The recipient property + * is the caller's to set. + */ + async encrypt(options: wasm.EncryptDocumentPropertyOptions): Promise> { + await wasm.ensureInitialized(); + return wasm.WasmSdk.encryptDocumentProperty(options); + } + + /** + * Decrypts a property declaring `encryptedFor`. The scheme carries no authentication tag: + * a wrong key is caught only by the padding check, which it passes about once in 256 + * attempts, returning garbage. + */ + async decrypt(options: wasm.DecryptDocumentPropertyOptions): Promise { + await wasm.ensureInitialized(); + return wasm.WasmSdk.decryptDocumentProperty(options); + } + + /** + * Whose keys an encrypted property of a document is under: the recipient and sender + * identities and the ids of their keys, which a reader fetches to decrypt it. + */ + async envelope(options: wasm.EncryptedPropertyEnvelopeOptions): Promise { + await wasm.ensureInitialized(); + return wasm.WasmSdk.encryptedPropertyEnvelope(options); + } +} diff --git a/packages/js-evo-sdk/src/moderation-charters/facade.ts b/packages/js-evo-sdk/src/moderation-charters/facade.ts new file mode 100644 index 00000000000..1f28cea4ee6 --- /dev/null +++ b/packages/js-evo-sdk/src/moderation-charters/facade.ts @@ -0,0 +1,80 @@ +import * as wasm from '../wasm.js'; +import type { EvoSDK } from '../sdk.js'; + +/** + * The moderation charters system contract (protocol version 14): who moderates a contract + * that declares elected moderation, the proposals and join requests behind it, and the + * requests members send the leader. Every read is a proved document query. + */ +export class ModerationChartersFacade { + private sdk: EvoSDK; + + constructor(sdk: EvoSDK) { + this.sdk = sdk; + } + + /** + * The seated charter of a contract: its `electedCharter`, or undefined when it has none. + * Only a contest's winner is ever stored, so there is at most one. + */ + async seatedCharter(targetContractId: wasm.IdentifierLike): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.getModerationSeatedCharter(targetContractId); + } + + /** A proposal (`submittedCharter`) by id, such as a seated charter's `submittedCharterId`. */ + async submittedCharter(submittedCharterId: wasm.IdentifierLike): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.getModerationSubmittedCharter(submittedCharterId); + } + + /** + * The team that moderates a contract: the seated charter's leader plus its elected members + * and the members the leader added, less those the leader removed. Undefined when the + * contract has no seated charter. + */ + async team(targetContractId: wasm.IdentifierLike): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.getModerationTeam(targetContractId); + } + + /** One page of the proposals for a contract, in filing order. */ + async submittedCharters( + query: wasm.ModerationSubmittedChartersQuery, + ): Promise> { + const w = await this.sdk.getWasmSdkConnected(); + return w.getModerationSubmittedCharters(query); + } + + /** One page of the join requests for a proposal, in the order of their owners' ids. */ + async joinRequests( + query: wasm.ModerationJoinRequestsQuery, + ): Promise> { + const w = await this.sdk.getWasmSdkConnected(); + return w.getModerationJoinRequests(query); + } + + /** The resignation requests for a seated charter that the leader has not acted on. */ + async pendingResignationRequests(electedCharterId: wasm.IdentifierLike): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.getModerationPendingResignationRequests(electedCharterId); + } + + /** + * Builds a join request whose message only the proposal's leader can read. Pass the result + * to `documents.create`. + */ + async buildJoinRequest(options: wasm.ModerationJoinRequestOptions): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.buildModerationJoinRequest(options); + } + + /** + * Builds a resignation request whose message only the leader can read. Pass the result to + * `documents.create`; deleting it withdraws the request. + */ + async buildResignationRequest(options: wasm.ModerationResignationRequestOptions): Promise { + const w = await this.sdk.getWasmSdkConnected(); + return w.buildModerationResignationRequest(options); + } +} diff --git a/packages/js-evo-sdk/src/sdk.ts b/packages/js-evo-sdk/src/sdk.ts index 82b911ceeee..49268094f89 100644 --- a/packages/js-evo-sdk/src/sdk.ts +++ b/packages/js-evo-sdk/src/sdk.ts @@ -14,6 +14,8 @@ import { GroupFacade } from './group/facade.js'; import { ContractGroupsFacade } from './contract-groups/facade.js'; import { VotingFacade } from './voting/facade.js'; import { ShieldedFacade } from './shielded/facade.js'; +import { EncryptedForFacade } from './encrypted-for/facade.js'; +import { ModerationChartersFacade } from './moderation-charters/facade.js'; export interface ConnectionOptions { version?: number; @@ -73,6 +75,8 @@ export class EvoSDK { public contractGroups!: ContractGroupsFacade; public voting!: VotingFacade; public shielded!: ShieldedFacade; + public encryptedFor!: EncryptedForFacade; + public moderationCharters!: ModerationChartersFacade; constructor(options: EvoSDKOptions = {}) { // Apply defaults while preserving any future connection options const { network = 'testnet', trusted = false, addresses, devnetName, quorumUrl, ...connection } = options; @@ -111,6 +115,8 @@ export class EvoSDK { this.contractGroups = new ContractGroupsFacade(this); this.voting = new VotingFacade(this); this.shielded = new ShieldedFacade(this); + this.encryptedFor = new EncryptedForFacade(this); + this.moderationCharters = new ModerationChartersFacade(this); } get wasm(): wasm.WasmSdk { @@ -335,5 +341,7 @@ export { GroupFacade } from './group/facade.js'; export { ContractGroupsFacade } from './contract-groups/facade.js'; export { VotingFacade } from './voting/facade.js'; export { ShieldedFacade } from './shielded/facade.js'; +export { EncryptedForFacade } from './encrypted-for/facade.js'; +export { ModerationChartersFacade } from './moderation-charters/facade.js'; export { wallet } from './wallet/functions.js'; export * from './wasm.js'; diff --git a/packages/js-evo-sdk/tests/unit/facades/encrypted-for.spec.ts b/packages/js-evo-sdk/tests/unit/facades/encrypted-for.spec.ts new file mode 100644 index 00000000000..a90ee828dee --- /dev/null +++ b/packages/js-evo-sdk/tests/unit/facades/encrypted-for.spec.ts @@ -0,0 +1,125 @@ +import init, * as wasmSDKPackage from '@dashevo/wasm-sdk'; +import { + DataContract, + Document, + EvoSDK, + IdentityPublicKey, + ensureInitialized, + PlatformVersion, + PrivateKey, +} from '../../../dist/sdk.js'; + +/** + * The `encryptedFor` facade runs locally, so these run the real helpers. The objects come + * from the SDK's own exports: the facade calls the bundle the SDK wraps. + */ +describe('EncryptedForFacade', () => { + let client: EvoSDK; + let contract: DataContract; + let senderPrivateKey: PrivateKey; + let recipientPrivateKey: PrivateKey; + let senderKey: IdentityPublicKey; + let recipientKey: IdentityPublicKey; + + const ownerId = '11111111111111111111111111111111'; + const identifier = { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', + }; + const keyId = { type: 'integer', minimum: 0, maximum: 4294967295 }; + const schemas = { + secret: { + type: 'object', + properties: { + recipientId: { ...identifier, position: 0 }, + recipientKeyId: { ...keyId, position: 1 }, + senderKeyId: { ...keyId, position: 2 }, + encryptedMessage: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 1040, + position: 3, + encryptedFor: { + recipient: 'recipientId', + recipientKey: 'recipientKeyId', + senderKey: 'senderKeyId', + scheme: 'ecdh-secp256k1-aes256-cbc', + }, + }, + }, + required: ['recipientId', 'recipientKeyId', 'senderKeyId', 'encryptedMessage'], + additionalProperties: false, + }, + }; + + function identityKey(id: number, purpose: number, privateKey: PrivateKey): IdentityPublicKey { + return new IdentityPublicKey({ + keyId: id, + purpose, + securityLevel: 3, + keyType: 0, + isReadOnly: false, + data: privateKey.getPublicKey().toBytes(), + }); + } + + before(async () => { + await init(); + client = EvoSDK.fromWasm(await wasmSDKPackage.WasmSdkBuilder.testnet().build()); + // The bundle the SDK wraps, whose classes the facade takes + await ensureInitialized(); + contract = new DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + senderPrivateKey = PrivateKey.fromHex('21'.repeat(32), 'testnet'); + recipientPrivateKey = PrivateKey.fromHex('42'.repeat(32), 'testnet'); + senderKey = identityKey(4, 1, senderPrivateKey); + recipientKey = identityKey(2, 2, recipientPrivateKey); + }); + + it('should round trip a message through encrypt, envelope and decrypt', async () => { + const fields = await client.encryptedFor.encrypt({ + dataContract: contract, + documentTypeName: 'secret', + property: 'encryptedMessage', + plaintext: 'hello leader', + senderKey, + senderPrivateKey, + recipientKey, + }); + expect(fields.recipientKeyId).to.equal(2); + expect(fields.senderKeyId).to.equal(4); + + const document = new Document({ + properties: { recipientId: new Uint8Array(32).fill(7), ...fields }, + documentTypeName: 'secret', + dataContractId: contract.id, + ownerId, + }); + const envelope = await client.encryptedFor.envelope({ + dataContract: contract, + document, + property: 'encryptedMessage', + }); + expect(envelope.recipientKeyId).to.equal(2); + expect(envelope.senderKeyId).to.equal(4); + + const message = await client.encryptedFor.decrypt({ + dataContract: contract, + document, + property: 'encryptedMessage', + recipientPrivateKey, + senderKey, + }); + expect(new TextDecoder().decode(message)).to.equal('hello leader'); + }); +}); diff --git a/packages/js-evo-sdk/tests/unit/facades/moderation-charters.spec.ts b/packages/js-evo-sdk/tests/unit/facades/moderation-charters.spec.ts new file mode 100644 index 00000000000..d3906f0b920 --- /dev/null +++ b/packages/js-evo-sdk/tests/unit/facades/moderation-charters.spec.ts @@ -0,0 +1,109 @@ +import type { SinonStub } from 'sinon'; +import init, * as wasmSDKPackage from '@dashevo/wasm-sdk'; +import { EvoSDK } from '../../../dist/sdk.js'; + +describe('ModerationChartersFacade', () => { + let wasmSdk: wasmSDKPackage.WasmSdk; + let client: EvoSDK; + + let getModerationSeatedCharterStub: SinonStub; + let getModerationSubmittedCharterStub: SinonStub; + let getModerationTeamStub: SinonStub; + let getModerationSubmittedChartersStub: SinonStub; + let getModerationJoinRequestsStub: SinonStub; + let getModerationPendingResignationRequestsStub: SinonStub; + let buildModerationJoinRequestStub: SinonStub; + let buildModerationResignationRequestStub: SinonStub; + + const contractId = 'GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec'; + const charterId = '4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF'; + + beforeEach(async function setup() { + await init(); + const builder = wasmSDKPackage.WasmSdkBuilder.testnet(); + wasmSdk = await builder.build(); + client = EvoSDK.fromWasm(wasmSdk); + + getModerationSeatedCharterStub = this.sinon.stub(wasmSdk, 'getModerationSeatedCharter').resolves(undefined); + getModerationSubmittedCharterStub = this.sinon.stub(wasmSdk, 'getModerationSubmittedCharter').resolves(undefined); + getModerationTeamStub = this.sinon.stub(wasmSdk, 'getModerationTeam').resolves(undefined); + getModerationSubmittedChartersStub = this.sinon.stub(wasmSdk, 'getModerationSubmittedCharters').resolves(new Map()); + getModerationJoinRequestsStub = this.sinon.stub(wasmSdk, 'getModerationJoinRequests').resolves(new Map()); + getModerationPendingResignationRequestsStub = this.sinon.stub(wasmSdk, 'getModerationPendingResignationRequests').resolves([]); + buildModerationJoinRequestStub = this.sinon.stub(wasmSdk, 'buildModerationJoinRequest').resolves({}); + buildModerationResignationRequestStub = this.sinon.stub(wasmSdk, 'buildModerationResignationRequest').resolves({}); + }); + + describe('seatedCharter()', () => { + it('should forward the target contract id to getModerationSeatedCharter', async () => { + const result = await client.moderationCharters.seatedCharter(contractId); + expect(getModerationSeatedCharterStub).to.be.calledOnceWithExactly(contractId); + expect(result).to.equal(undefined); + }); + }); + + describe('submittedCharter()', () => { + it('should forward the proposal id to getModerationSubmittedCharter', async () => { + await client.moderationCharters.submittedCharter(charterId); + expect(getModerationSubmittedCharterStub).to.be.calledOnceWithExactly(charterId); + }); + }); + + describe('team()', () => { + it('should forward the target contract id to getModerationTeam', async () => { + await client.moderationCharters.team(contractId); + expect(getModerationTeamStub).to.be.calledOnceWithExactly(contractId); + }); + }); + + describe('submittedCharters()', () => { + it('should forward the page query to getModerationSubmittedCharters', async () => { + const query = { targetContractId: contractId, limit: 20, startAfter: charterId }; + const result = await client.moderationCharters.submittedCharters(query); + expect(getModerationSubmittedChartersStub).to.be.calledOnceWithExactly(query); + expect(result).to.be.instanceOf(Map); + }); + }); + + describe('joinRequests()', () => { + it('should forward the page query to getModerationJoinRequests', async () => { + const query = { submittedCharterId: charterId }; + await client.moderationCharters.joinRequests(query); + expect(getModerationJoinRequestsStub).to.be.calledOnceWithExactly(query); + }); + }); + + describe('pendingResignationRequests()', () => { + it('should forward the charter id to getModerationPendingResignationRequests', async () => { + const result = await client.moderationCharters.pendingResignationRequests(charterId); + expect(getModerationPendingResignationRequestsStub).to.be.calledOnceWithExactly(charterId); + expect(result).to.deep.equal([]); + }); + }); + + describe('buildJoinRequest()', () => { + it('should forward the options to buildModerationJoinRequest', async () => { + const options = { + submittedCharterId: charterId, + message: 'let me help', + writer: contractId, + writerEncryptionKey: {} as wasmSDKPackage.PrivateKey, + }; + await client.moderationCharters.buildJoinRequest(options); + expect(buildModerationJoinRequestStub).to.be.calledOnceWithExactly(options); + }); + }); + + describe('buildResignationRequest()', () => { + it('should forward the options to buildModerationResignationRequest', async () => { + const options = { + electedCharterId: charterId, + message: 'moving on', + writer: contractId, + writerEncryptionKey: {} as wasmSDKPackage.PrivateKey, + }; + await client.moderationCharters.buildResignationRequest(options); + expect(buildModerationResignationRequestStub).to.be.calledOnceWithExactly(options); + }); + }); +}); diff --git a/packages/moderation-charters-contract/src/lib.rs b/packages/moderation-charters-contract/src/lib.rs index e6d4c4be87a..2149d95819e 100644 --- a/packages/moderation-charters-contract/src/lib.rs +++ b/packages/moderation-charters-contract/src/lib.rs @@ -68,32 +68,117 @@ mod tests { #[test] fn should_load_the_schema_at_the_latest_platform_version() { + use v1::document_types::{ + added_moderator, elected_charter, join_request, reason, removed_moderator, + resignation_request, submitted_charter, + }; + let schema = load_documents_schemas(PlatformVersion::latest()).expect("schema loads"); - let charter = schema - .get(v1::document_types::charter::NAME) - .expect("the charter document type is declared"); - let properties = charter - .get("properties") - .and_then(Value::as_object) - .expect("the charter has properties"); - for property in [ - v1::document_types::charter::properties::TARGET_CONTRACT_ID, - v1::document_types::charter::properties::DESCRIPTION, - v1::document_types::charter::properties::ABILITIES, - v1::document_types::charter::properties::MEMBERS, - v1::document_types::charter::properties::REASON_CODES, - v1::document_types::charter::properties::MODERATORS_SHARE, - v1::document_types::charter::properties::SPLIT, - ] { - assert!( - properties.contains_key(property), - "the schema declares {property}" - ); - } - assert_eq!( - charter["indices"][0]["name"], - v1::document_types::charter::indexes::BY_TARGET_CONTRACT + let declared = |name: &str, properties: &[&str], indexes: &[&str]| { + let document_type = schema + .get(name) + .unwrap_or_else(|| panic!("the {name} document type is declared")); + let declared_properties = document_type + .get("properties") + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("{name} has properties")); + for property in properties { + assert!( + declared_properties.contains_key(*property), + "{name} declares {property}" + ); + } + let declared_indexes: Vec<&str> = document_type + .get("indices") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("{name} has indexes")) + .iter() + .filter_map(|index| index.get("name").and_then(Value::as_str)) + .collect(); + assert_eq!(declared_indexes, indexes, "the indexes of {name}"); + }; + + declared( + reason::NAME, + &[ + reason::properties::CODE, + reason::properties::LABEL, + reason::properties::DESCRIPTION, + ], + &[reason::indexes::BY_OWNER_CODE], + ); + declared( + submitted_charter::NAME, + &[ + submitted_charter::properties::TARGET_CONTRACT_ID, + submitted_charter::properties::DESCRIPTION, + submitted_charter::properties::REASONS, + submitted_charter::properties::MODERATORS_SHARE, + submitted_charter::properties::REWARD_SPLIT, + ], + &[ + submitted_charter::indexes::BY_TARGET_CONTRACT, + submitted_charter::indexes::BY_OWNER, + ], + ); + declared( + join_request::NAME, + &[ + join_request::properties::SUBMITTED_CHARTER_ID, + join_request::properties::RECIPIENT_ID, + join_request::properties::RECIPIENT_KEY_ID, + join_request::properties::SENDER_KEY_ID, + join_request::properties::ENCRYPTED_MESSAGE, + ], + &[ + join_request::indexes::BY_SUBMITTED_CHARTER, + join_request::indexes::BY_OWNER, + ], + ); + declared( + elected_charter::NAME, + &[ + elected_charter::properties::TARGET_CONTRACT_ID, + elected_charter::properties::SUBMITTED_CHARTER_ID, + elected_charter::properties::MEMBERS, + ], + &[ + elected_charter::indexes::BY_TARGET_CONTRACT, + elected_charter::indexes::BY_SUBMITTED_CHARTER, + ], + ); + declared( + added_moderator::NAME, + &[ + added_moderator::properties::ELECTED_CHARTER_ID, + added_moderator::properties::SUBMITTED_CHARTER_ID, + added_moderator::properties::MEMBER_ID, + ], + &[added_moderator::indexes::BY_ELECTED_CHARTER_MEMBER], + ); + declared( + removed_moderator::NAME, + &[ + removed_moderator::properties::ELECTED_CHARTER_ID, + removed_moderator::properties::MEMBER_ID, + ], + &[removed_moderator::indexes::BY_ELECTED_CHARTER_MEMBER], + ); + declared( + resignation_request::NAME, + &[ + resignation_request::properties::ELECTED_CHARTER_ID, + resignation_request::properties::RECIPIENT_ID, + resignation_request::properties::RECIPIENT_KEY_ID, + resignation_request::properties::SENDER_KEY_ID, + resignation_request::properties::ENCRYPTED_MESSAGE, + ], + &[resignation_request::indexes::BY_ELECTED_CHARTER_OWNER], ); + // Seven types and no others. + assert_eq!(schema.as_object().map(|types| types.len()), Some(7)); + // Only the elected charter is contested, on its target. + assert!(schema[elected_charter::NAME]["indices"][0]["contested"].is_object()); } #[test] diff --git a/packages/moderation-charters-contract/src/v1/mod.rs b/packages/moderation-charters-contract/src/v1/mod.rs index 392d3e18efd..42c01cbb04d 100644 --- a/packages/moderation-charters-contract/src/v1/mod.rs +++ b/packages/moderation-charters-contract/src/v1/mod.rs @@ -2,30 +2,36 @@ use crate::Error; use serde_json::Value; pub mod document_types { - /// A team's application to moderate a contract, and once elected the terms it moderates - /// under. Immutable and undeletable. - pub mod charter { - pub const NAME: &str = "charter"; + /// A ground for a moderation action, keyed by its owner and a three-letter code. Immutable + /// and undeletable. + pub mod reason { + pub const NAME: &str = "reason"; + + pub mod properties { + pub const CODE: &str = "code"; + pub const LABEL: &str = "label"; + pub const DESCRIPTION: &str = "description"; + } + + pub mod indexes { + /// Unique on the owner and the code. + pub const BY_OWNER_CODE: &str = "byOwnerCode"; + } + } + + /// A leader's proposal to moderate one contract. Immutable and undeletable. + pub mod submitted_charter { + pub const NAME: &str = "submittedCharter"; pub mod properties { pub const TARGET_CONTRACT_ID: &str = "targetContractId"; pub const DESCRIPTION: &str = "description"; - pub const ABILITIES: &str = "abilities"; - pub const MEMBERS: &str = "members"; - pub const REASON_CODES: &str = "reasonCodes"; + pub const REASONS: &str = "reasons"; pub const MODERATORS_SHARE: &str = "moderatorsShare"; - pub const SPLIT: &str = "split"; - - /// The keys of the `abilities` object, each holding the power the ability needs. - pub mod abilities { - pub const DELETE_DOCUMENTS: &str = "deleteDocuments"; - pub const BAN: &str = "ban"; - pub const SUSPEND: &str = "suspend"; - pub const WARN: &str = "warn"; - } + pub const REWARD_SPLIT: &str = "rewardSplit"; - /// The keys of the `split` object, three percentages summing to 100. - pub mod split { + /// The keys of the `rewardSplit` object, three percentages summing to 100. + pub mod reward_split { pub const LEADER: &str = "leader"; pub const EQUAL: &str = "equal"; pub const ACTIONS: &str = "actions"; @@ -33,9 +39,101 @@ pub mod document_types { } pub mod indexes { - /// The contested unique index keyed by the target contract: a create on it is - /// the team's application, and opens or joins the election. + /// The proposals for a contract in filing order. + pub const BY_TARGET_CONTRACT: &str = "byTargetContract"; + /// A leader's proposals. + pub const BY_OWNER: &str = "byOwner"; + } + } + + /// An identity's offer to serve on the team of a proposal, with a message only the + /// leader can read. Immutable and undeletable. + pub mod join_request { + pub const NAME: &str = "joinRequest"; + + pub mod properties { + pub const SUBMITTED_CHARTER_ID: &str = "submittedCharterId"; + pub const RECIPIENT_ID: &str = "recipientId"; + pub const RECIPIENT_KEY_ID: &str = "recipientKeyId"; + pub const SENDER_KEY_ID: &str = "senderKeyId"; + pub const ENCRYPTED_MESSAGE: &str = "encryptedMessage"; + } + + pub mod indexes { + /// Unique on the proposal and the owner: one offer per identity per proposal. + pub const BY_SUBMITTED_CHARTER: &str = "bySubmittedCharter"; + /// An identity's offers. + pub const BY_OWNER: &str = "byOwner"; + } + } + + /// A proposal put to the vote with its team. Immutable and undeletable. + pub mod elected_charter { + pub const NAME: &str = "electedCharter"; + + pub mod properties { + pub const TARGET_CONTRACT_ID: &str = "targetContractId"; + pub const SUBMITTED_CHARTER_ID: &str = "submittedCharterId"; + pub const MEMBERS: &str = "members"; + } + + pub mod indexes { + /// The contested unique index keyed by the target contract: a create on it opens + /// or joins the contest for the target's seat, and only the winner is stored. pub const BY_TARGET_CONTRACT: &str = "byTargetContract"; + /// The elected charters of a proposal. + pub const BY_SUBMITTED_CHARTER: &str = "bySubmittedCharter"; + } + } + + /// A member the leader of a seated charter adds after the election. Immutable and + /// undeletable. + pub mod added_moderator { + pub const NAME: &str = "addedModerator"; + + pub mod properties { + pub const ELECTED_CHARTER_ID: &str = "electedCharterId"; + pub const SUBMITTED_CHARTER_ID: &str = "submittedCharterId"; + pub const MEMBER_ID: &str = "memberId"; + } + + pub mod indexes { + /// Unique on the charter and the member. + pub const BY_ELECTED_CHARTER_MEMBER: &str = "byElectedCharterMember"; + } + } + + /// A member the leader of a seated charter removes; final. Immutable and undeletable. + pub mod removed_moderator { + pub const NAME: &str = "removedModerator"; + + pub mod properties { + pub const ELECTED_CHARTER_ID: &str = "electedCharterId"; + pub const MEMBER_ID: &str = "memberId"; + } + + pub mod indexes { + /// Unique on the charter and the member. + pub const BY_ELECTED_CHARTER_MEMBER: &str = "byElectedCharterMember"; + } + } + + /// A member asking to leave a seated team, with a message only the leader can read. + /// Immutable; deletable, which withdraws the request. + pub mod resignation_request { + pub const NAME: &str = "resignationRequest"; + + pub mod properties { + pub const ELECTED_CHARTER_ID: &str = "electedCharterId"; + pub const RECIPIENT_ID: &str = "recipientId"; + pub const RECIPIENT_KEY_ID: &str = "recipientKeyId"; + pub const SENDER_KEY_ID: &str = "senderKeyId"; + pub const ENCRYPTED_MESSAGE: &str = "encryptedMessage"; + } + + pub mod indexes { + /// Unique on the charter and the owner. + pub const BY_ELECTED_CHARTER_OWNER: &str = "byElectedCharterOwner"; } } } diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index e2753a62de2..fc50a245ae9 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -19,14 +19,17 @@ use dpp::data_contract::TokenConfiguration; feature = "keywords-contract", feature = "document-history-contract", feature = "app-connect-contract", + feature = "moderation-charters-contract", feature = "all-system-contracts" ))] use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; #[cfg(any(feature = "app-connect-contract", feature = "all-system-contracts"))] -use dpp::version::feature_initial_protocol_versions::{ - APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION, - MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION, -}; +use dpp::version::feature_initial_protocol_versions::APP_CONNECT_CONTRACT_INITIAL_PROTOCOL_VERSION; +#[cfg(any( + feature = "moderation-charters-contract", + feature = "all-system-contracts" +))] +use dpp::version::feature_initial_protocol_versions::MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; use lru::LruCache; @@ -788,6 +791,7 @@ impl ContextProvider for TrustedHttpContextProvider { feature = "keywords-contract", feature = "document-history-contract", feature = "app-connect-contract", + feature = "moderation-charters-contract", feature = "all-system-contracts" ))] { diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 2254324cc62..f921f93b0f7 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -1,4 +1,5 @@ //! Definitions of errors +use crate::platform::encrypted_for::EncryptedForError; use dapi_grpc::platform::v0::StateTransitionBroadcastError as StateTransitionBroadcastErrorProto; use dapi_grpc::tonic::Code; pub use dash_context_provider::ContextProviderError; @@ -131,6 +132,10 @@ pub enum Error { /// Contains the last meaningful error that caused addresses to be banned. #[error("no available addresses to retry, last error: {0}")] NoAvailableAddressesToRetry(Box), + + /// A property declared `encryptedFor` could not be encrypted or decrypted + #[error(transparent)] + EncryptedFor(#[from] EncryptedForError), } impl From for Error { diff --git a/packages/rs-sdk/src/platform.rs b/packages/rs-sdk/src/platform.rs index 48c234478ab..76b377aefbd 100644 --- a/packages/rs-sdk/src/platform.rs +++ b/packages/rs-sdk/src/platform.rs @@ -16,6 +16,7 @@ pub mod data_contracts_latest_versions; mod delegate; pub mod documents; pub mod dpns_usernames; +pub mod encrypted_for; mod fetch; pub mod fetch_current_no_parameters; mod fetch_many; @@ -23,10 +24,12 @@ mod fetch_unproved; pub mod group_actions; pub mod identities_contract_keys_query; pub mod identity_keys_remaining_budgets; +pub mod moderation_charters; pub mod query; pub mod query_settings; #[cfg(feature = "shielded")] pub mod shielded; +mod system_data_contract; pub mod tokens; pub mod transition; pub mod trunk_branch_sync; diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 182edd8854b..9ca2704be31 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -13,9 +13,7 @@ pub use contact_request::{ }; pub use contact_request_queries::ContactRequestDocuments; -use crate::platform::Fetch; use crate::{Error, Sdk}; -use dash_context_provider::ContextProvider; use dpp::prelude::Identifier; use std::sync::Arc; @@ -48,21 +46,8 @@ impl Sdk { /// Helper method to fetch the DashPay contract, checking context provider first async fn fetch_dashpay_contract(&self) -> Result, Error> { let dashpay_contract_id = self.get_dashpay_contract_id()?; - - // First check if the contract is available in the context provider - let context_provider = self - .context_provider() - .ok_or_else(|| Error::Generic("Context provider not set".to_string()))?; - - match context_provider.get_data_contract(&dashpay_contract_id, self.version())? { - Some(contract) => Ok(contract), - None => { - // If not in context, fetch from platform - let contract = crate::platform::DataContract::fetch(self, dashpay_contract_id) - .await? - .ok_or_else(|| Error::Generic("DashPay contract not found".to_string()))?; - Ok(Arc::new(contract)) - } - } + self.fetch_system_data_contract(dashpay_contract_id) + .await? + .ok_or_else(|| Error::Generic("DashPay contract not found".to_string())) } } diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index a8e02b29175..00fc293b348 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -8,9 +8,8 @@ pub use dash_platform_queries::dpns_usernames::{ pub use queries::DpnsUsername; use crate::platform::transition::put_document::PutDocument; -use crate::platform::{Document, Fetch, FetchMany}; +use crate::platform::{Document, FetchMany}; use crate::{Error, Sdk}; -use dash_context_provider::ContextProvider; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -108,22 +107,9 @@ impl Sdk { /// Helper method to fetch the DPNS contract, checking context provider first async fn fetch_dpns_contract(&self) -> Result, Error> { let dpns_contract_id = self.get_dpns_contract_id()?; - - // First check if the contract is available in the context provider - let context_provider = self - .context_provider() - .ok_or_else(|| Error::Generic("Context provider not set".to_string()))?; - - match context_provider.get_data_contract(&dpns_contract_id, self.version())? { - Some(contract) => Ok(contract), - None => { - // If not in context, fetch from platform - let contract = crate::platform::DataContract::fetch(self, dpns_contract_id) - .await? - .ok_or_else(|| Error::Generic("DPNS contract not found".to_string()))?; - Ok(Arc::new(contract)) - } - } + self.fetch_system_data_contract(dpns_contract_id) + .await? + .ok_or_else(|| Error::Generic("DPNS contract not found".to_string())) } /// Register a DPNS username in a single operation diff --git a/packages/rs-sdk/src/platform/encrypted_for.rs b/packages/rs-sdk/src/platform/encrypted_for.rs new file mode 100644 index 00000000000..6c778b8885b --- /dev/null +++ b/packages/rs-sdk/src/platform/encrypted_for.rs @@ -0,0 +1,739 @@ +//! Encrypting and decrypting the byte properties a document type declares `encryptedFor`. +//! +//! A property declared +//! `"encryptedFor": { "recipient": ..., "recipientKey": ..., "senderKey": ..., "scheme": ... }` +//! (protocol version 14) says how its bytes were produced: for which identity, under which two +//! identity keys, named by two key id properties of the same document, and under which +//! [`EncryptionScheme`]. Every helper here reads that declaration from the document type it is +//! given, so they work for any contract that declares one: +//! +//! * [`encrypt_property`] encrypts a plaintext into the property with the keys it is given and +//! writes the two key id properties; +//! * [`decrypt_property`] reads the property back; +//! * [`select_encryption_keys`] picks the two keys the document type's `identityPublicKey` +//! references demand through their `keyRequirements`, so a write does not name a key +//! consensus refuses; +//! * [`encrypt_property_for`] does both and also writes the recipient property; +//! * [`EncryptedPropertyEnvelope::read`] says, for a stored document, whose keys a reader needs. +//! +//! The one scheme, `ecdh-secp256k1-aes256-cbc`, is the one dashpay contact requests use +//! (DIP-15): the shared key is the libsecp256k1 ECDH of one side's private key and the other +//! side's public key, `SHA256((y & 1 | 2) || x)` of the product point, and the bytes are a random +//! 16-byte IV followed by the plaintext under AES-256-CBC with PKCS7 padding. ECDH is symmetric, +//! so the sender reads its own message back with its private key and the recipient's public key. +//! +//! The scheme carries no authentication tag. A wrong key is caught only by the padding check, +//! which a wrong key passes about once in 256 attempts and then returns garbage; a caller that +//! must tell the two apart has to recognise its plaintext. + +use dpp::dashcore::secp256k1::rand::rngs::StdRng; +use dpp::dashcore::secp256k1::rand::{RngCore, SeedableRng}; +use dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRef, EncryptedFor, + EncryptedForRecipient, EncryptionScheme, IdentityKeyReferenceRequirements, +}; +use dpp::document::{property_names, Document, DocumentV0Getters}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::contract_bounds::ContractBounds; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose}; +use dpp::platform_value::btreemap_extensions::{ + BTreeValueMapInsertionPathHelper, BTreeValueMapPathHelper, +}; +use dpp::platform_value::{Identifier, Value}; +use platform_encryption::{decrypt_aes_256_cbc, derive_shared_key_ecdh, encrypt_aes_256_cbc}; +use std::collections::BTreeMap; +use std::fmt; + +/// The length of the IV that prefixes an `ecdh-secp256k1-aes256-cbc` ciphertext. +const AES_CBC_IV_LENGTH: usize = 16; + +/// Why an `encryptedFor` helper failed. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum EncryptedForError { + /// The document type has no such property, or it declares no `encryptedFor`. + #[error("property {property} of document type {document_type} declares no encryptedFor")] + NotDeclared { + /// The document type. + document_type: String, + /// The property path asked for. + property: String, + }, + /// The document does not carry a property the declaration names. + #[error("the document has no {path} property")] + MissingProperty { + /// The property path. + path: String, + }, + /// A property the declaration names holds a value of the wrong kind. + #[error("property {path} is malformed: {reason}")] + InvalidProperty { + /// The property path. + path: String, + /// What is wrong with it. + reason: String, + }, + /// The bytes do not have the shape the scheme produces, the one consensus checks. + #[error( + "property {path} holds {length} bytes, not a {scheme} ciphertext (at least {} bytes, a \ + multiple of {})", + scheme.minimum_ciphertext_length(), + scheme.block_length() + )] + InvalidCiphertextLength { + /// The property path. + path: String, + /// The declared scheme. + scheme: EncryptionScheme, + /// The length found. + length: usize, + }, + /// The padding did not check out: the keys are not the ones the bytes were encrypted + /// with, or the bytes are corrupt. + #[error("decryption failed: the keys are not the ones the property was encrypted with")] + DecryptionFailed, + /// A key is not a valid secp256k1 key. + #[error("invalid key: {0}")] + InvalidKey(String), + /// No key of an identity can play a role the declaration needs. + #[error("no key of identity {identity_id} can be the {role} key: {reason}")] + NoSuitableKey { + /// The identity. + identity_id: Identifier, + /// The role the key was wanted for. + role: EncryptionKeyRole, + /// Why none fits. + reason: String, + }, + /// The declaration names one property for both key ids, so the message must be encrypted + /// under one key of one identity, and the keys given differ. + #[error( + "property {path} keeps both key ids in {key_path}, so the recipient key {recipient_key_id} \ + and the sender key {sender_key_id} must be the same key" + )] + SharedKeyIdProperty { + /// The property path. + path: String, + /// The one key id property the declaration names for both keys. + key_path: String, + /// The recipient key id given. + recipient_key_id: KeyID, + /// The sender key id given. + sender_key_id: KeyID, + }, + /// The document's owner may have changed since the bytes were written, so the owner is not + /// known to be the sender whose key the sender key id names. + #[error( + "the sender of property {path} is not known: the document was transferred or sold, or \ + its type lets it be and records no transfer, so its sender key id may name a previous \ + owner's key" + )] + SenderUnknownAfterTransfer { + /// The property path. + path: String, + }, + /// The declaration's recipient is the document owner, the writer, but another identity was + /// named as the recipient. + #[error( + "property {path} is encrypted for the document owner, so the recipient must be the \ + sender {sender_id}, not {recipient_id}" + )] + RecipientMustBeOwner { + /// The property path. + path: String, + /// The sender, the writer and owner. + sender_id: Identifier, + /// The recipient named. + recipient_id: Identifier, + }, +} + +/// Which side of an encrypted property a key belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncryptionKeyRole { + /// The writer's key, named by the declaration's `senderKey` property. + Sender, + /// The reader's key, named by the declaration's `recipientKey` property. + Recipient, +} + +impl fmt::Display for EncryptionKeyRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + EncryptionKeyRole::Sender => "sender", + EncryptionKeyRole::Recipient => "recipient", + }) + } +} + +/// The two keys a message is encrypted under: the ids written into the document's key id +/// properties, the sender's private key and the recipient's public key. The private key is +/// borrowed, so copying the struct never copies secret material. +#[derive(Clone, Copy)] +pub struct EncryptionKeys<'a> { + /// The id of the sender's key, written into the declaration's `senderKey` property. + pub sender_key_id: KeyID, + /// The private half of that key. + pub sender_private_key: &'a SecretKey, + /// The id of the recipient's key, written into the declaration's `recipientKey` property. + pub recipient_key_id: KeyID, + /// The public half of that key. + pub recipient_public_key: PublicKey, +} + +impl fmt::Debug for EncryptionKeys<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EncryptionKeys") + .field("sender_key_id", &self.sender_key_id) + .field("recipient_key_id", &self.recipient_key_id) + .field("recipient_public_key", &self.recipient_public_key) + .finish_non_exhaustive() + } +} + +/// The `encryptedFor` declaration of `property_path` on `document_type`. +pub fn encrypted_for_declaration( + document_type: DocumentTypeRef<'_>, + property_path: &str, +) -> Result { + document_type + .flattened_properties() + .get(property_path) + .and_then(|property| property.encrypted_for.clone()) + .ok_or_else(|| EncryptedForError::NotDeclared { + document_type: document_type.name().clone(), + property: property_path.to_string(), + }) +} + +/// Encrypts `plaintext` into the `property_path` property of `properties` under `keys`, the +/// way the property's `encryptedFor` declaration says, and writes the declaration's +/// `recipientKey` and `senderKey` properties with the two key ids. The recipient property is the +/// caller's to write; [`encrypt_property_for`] writes it too. +/// +/// The IV is fresh randomness on every call. The plaintext's length is not checked here: the +/// property's own byte bounds are, when the document is validated. +pub fn encrypt_property( + document_type: DocumentTypeRef<'_>, + property_path: &str, + plaintext: &[u8], + keys: &EncryptionKeys<'_>, + properties: &mut BTreeMap, +) -> Result<(), EncryptedForError> { + let declaration = encrypted_for_declaration(document_type, property_path)?; + encrypt_declared( + &declaration, + property_path, + plaintext, + keys, + &random_iv(), + properties, + ) +} + +fn random_iv() -> [u8; AES_CBC_IV_LENGTH] { + let mut iv = [0u8; AES_CBC_IV_LENGTH]; + StdRng::from_entropy().fill_bytes(&mut iv); + iv +} + +/// Encrypts under `declaration`, the one of `property_path`, with the IV given. Private: an IV +/// must never be reused under one shared key, so only tests pin it. +fn encrypt_declared( + declaration: &EncryptedFor, + property_path: &str, + plaintext: &[u8], + keys: &EncryptionKeys<'_>, + iv: &[u8; AES_CBC_IV_LENGTH], + properties: &mut BTreeMap, +) -> Result<(), EncryptedForError> { + // One property holding both key ids names a single key, so the message must be under it: + // writing two different ids there would keep only the second + if declaration.recipient_key == declaration.sender_key + && keys.recipient_key_id != keys.sender_key_id + { + return Err(EncryptedForError::SharedKeyIdProperty { + path: property_path.to_string(), + key_path: declaration.sender_key.clone(), + recipient_key_id: keys.recipient_key_id, + sender_key_id: keys.sender_key_id, + }); + } + let ciphertext = match declaration.scheme { + EncryptionScheme::EcdhSecp256k1Aes256Cbc => { + let shared_key = + derive_shared_key_ecdh(keys.sender_private_key, &keys.recipient_public_key); + let mut bytes = iv.to_vec(); + bytes.extend(encrypt_aes_256_cbc(&shared_key, iv, plaintext)); + bytes + } + }; + insert(properties, property_path, Value::Bytes(ciphertext))?; + insert( + properties, + &declaration.recipient_key, + Value::U32(keys.recipient_key_id), + )?; + insert( + properties, + &declaration.sender_key, + Value::U32(keys.sender_key_id), + ) +} + +/// Decrypts the `property_path` property of `properties`, the way its `encryptedFor` +/// declaration says, with the recipient's private key and the sender's public key: the keys +/// the document's `recipientKey` and `senderKey` properties name (see +/// [`EncryptedPropertyEnvelope::read`]). ECDH is symmetric, so the sender may pass its own +/// private key and the recipient's public key instead. +/// +/// A ciphertext of the wrong shape is refused before any decryption. See the module docs for +/// what a wrong key does. +pub fn decrypt_property( + document_type: DocumentTypeRef<'_>, + property_path: &str, + properties: &BTreeMap, + recipient_private_key: &SecretKey, + sender_public_key: &PublicKey, +) -> Result, EncryptedForError> { + let declaration = encrypted_for_declaration(document_type, property_path)?; + let bytes = required_at_path(properties, property_path)? + .to_binary_bytes() + .map_err(|e| EncryptedForError::InvalidProperty { + path: property_path.to_string(), + reason: e.to_string(), + })?; + let scheme = declaration.scheme; + if !scheme.is_valid_ciphertext_length(bytes.len()) { + return Err(EncryptedForError::InvalidCiphertextLength { + path: property_path.to_string(), + scheme, + length: bytes.len(), + }); + } + match scheme { + EncryptionScheme::EcdhSecp256k1Aes256Cbc => { + let Some((iv, blocks)) = bytes.split_first_chunk::() else { + return Err(EncryptedForError::InvalidCiphertextLength { + path: property_path.to_string(), + scheme, + length: bytes.len(), + }); + }; + let shared_key = derive_shared_key_ecdh(recipient_private_key, sender_public_key); + decrypt_aes_256_cbc(&shared_key, iv, blocks) + .map_err(|_| EncryptedForError::DecryptionFailed) + } + } +} + +/// Whose keys an encrypted property of a stored document is under: what a reader fetches to +/// decrypt it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EncryptedPropertyEnvelope { + /// The identity the bytes are encrypted for: the declaration's recipient property, or the + /// document owner when the declaration names `$ownerId`. + pub recipient_id: Identifier, + /// The id of the recipient's key, from the declaration's `recipientKey` property. + pub recipient_key_id: KeyID, + /// The identity whose key the `senderKey` property names: the document owner, the writer + /// that encrypted the bytes ([`encrypt_property_for`] encrypts with the writer's key). + /// Another `identityPublicKey` reference on the same key id only makes consensus check that + /// key exists; it does not change whose key encrypted the bytes. + pub sender_id: Identifier, + /// The id of the sender's key, from the declaration's `senderKey` property. + pub sender_key_id: KeyID, +} + +impl EncryptedPropertyEnvelope { + /// Reads the envelope of the `property_path` property of `document`, a document of + /// `document_type`. + /// + /// A document whose owner may have changed since it was written is refused + /// ([`EncryptedForError::SenderUnknownAfterTransfer`]): one that carries a transfer time, + /// and one of a transferable or purchasable type that records none, where a transfer + /// cannot be ruled out. Its sender key id may name a previous owner's key, and the document + /// does not say who that owner was. + pub fn read( + document_type: DocumentTypeRef<'_>, + property_path: &str, + document: &Document, + ) -> Result { + let declaration = encrypted_for_declaration(document_type, property_path)?; + let properties = document.properties(); + let recipient_id = match &declaration.recipient { + EncryptedForRecipient::Owner => document.owner_id(), + EncryptedForRecipient::Property(path) => identifier_at_path(properties, path)?, + }; + if owner_may_have_changed(document_type, document) { + return Err(EncryptedForError::SenderUnknownAfterTransfer { + path: property_path.to_string(), + }); + } + Ok(Self { + recipient_id, + recipient_key_id: key_id_at_path(properties, &declaration.recipient_key)?, + sender_id: document.owner_id(), + sender_key_id: key_id_at_path(properties, &declaration.sender_key)?, + }) + } +} + +/// Picks the keys to encrypt the `property_path` property of a `document_type` document under, +/// from `sender`, the writer, to `recipient`. +/// +/// The sender's key is the key of `sender` whose public key `sender_private_key` derives; the +/// recipient's is the enabled `ECDSA_SECP256K1` key of `recipient` with the highest id among +/// those that fit, a `DECRYPTION` key before an `ENCRYPTION` one. A key fits when it meets every +/// `keyRequirements` the document type's `identityPublicKey` references declare on the key id +/// property that names it (the checks consensus runs when the document is written) and is not +/// disabled. When the schema requires no purpose for a side, the dashpay convention stands in: +/// an `ENCRYPTION` key for the sender, a `DECRYPTION` or `ENCRYPTION` key for the recipient, so +/// an authentication key is never used for ECDH. When the schema requires no `boundTo` for a +/// side, a key bound to a contract must be bound to this contract, or to this document type of +/// it: consensus leaves the scope of encryption and decryption keys to clients, and a key bound +/// elsewhere is one another application may hold. +/// +/// A declaration that keeps both key ids in one property names a single key, so the recipient +/// must then be the sender and the recipient key is the sender's own. +pub fn select_encryption_keys<'a>( + document_type: DocumentTypeRef<'_>, + property_path: &str, + sender: &Identity, + sender_private_key: &'a SecretKey, + recipient: &Identity, +) -> Result, EncryptedForError> { + let declaration = encrypted_for_declaration(document_type, property_path)?; + select_declared_keys( + document_type, + &declaration, + sender, + sender_private_key, + recipient, + ) +} + +fn select_declared_keys<'a>( + document_type: DocumentTypeRef<'_>, + declaration: &EncryptedFor, + sender: &Identity, + sender_private_key: &'a SecretKey, + recipient: &Identity, +) -> Result, EncryptedForError> { + let contract_id = document_type.data_contract_id(); + + let sender_requirements = key_requirements_naming(document_type, &declaration.sender_key); + let sender_public_key = + PublicKey::from_secret_key(&Secp256k1::signing_only(), sender_private_key); + let sender_public_key_bytes = sender_public_key.serialize(); + let no_sender_key = |reason: String| EncryptedForError::NoSuitableKey { + identity_id: sender.id(), + role: EncryptionKeyRole::Sender, + reason, + }; + let sender_key = sender + .public_keys() + .values() + .find(|key| { + key.key_type() == KeyType::ECDSA_SECP256K1 + && key.data().as_slice() == sender_public_key_bytes.as_slice() + }) + .ok_or_else(|| { + no_sender_key("the private key given is not one of its ECDSA_SECP256K1 keys".into()) + })?; + if let Some(reason) = why_unfit( + sender_key, + &sender_requirements, + &[Purpose::ENCRYPTION], + contract_id, + document_type.name(), + ) { + return Err(no_sender_key(format!( + "the key of the private key given, {}, {reason}", + sender_key.id() + ))); + } + + if declaration.recipient_key == declaration.sender_key { + if recipient.id() != sender.id() { + return Err(EncryptedForError::NoSuitableKey { + identity_id: recipient.id(), + role: EncryptionKeyRole::Recipient, + reason: format!( + "{} keeps both key ids, so only the sender's own key can be the recipient key", + declaration.sender_key + ), + }); + } + return Ok(EncryptionKeys { + sender_key_id: sender_key.id(), + sender_private_key, + recipient_key_id: sender_key.id(), + recipient_public_key: sender_public_key, + }); + } + + let recipient_requirements = key_requirements_naming(document_type, &declaration.recipient_key); + let recipient_key = recipient + .public_keys() + .values() + .filter(|key| key.key_type() == KeyType::ECDSA_SECP256K1) + .filter(|key| { + why_unfit( + key, + &recipient_requirements, + &[Purpose::DECRYPTION, Purpose::ENCRYPTION], + contract_id, + document_type.name(), + ) + .is_none() + }) + .max_by_key(|key| (key.purpose() == Purpose::DECRYPTION, key.id())) + .ok_or_else(|| EncryptedForError::NoSuitableKey { + identity_id: recipient.id(), + role: EncryptionKeyRole::Recipient, + reason: format!( + "none of its enabled ECDSA_SECP256K1 keys {}", + describe_requirements(&recipient_requirements, "decryption or encryption") + ), + })?; + let recipient_public_key = PublicKey::from_slice(recipient_key.data().as_slice()) + .map_err(|e| EncryptedForError::InvalidKey(format!("recipient key: {e}")))?; + + Ok(EncryptionKeys { + sender_key_id: sender_key.id(), + sender_private_key, + recipient_key_id: recipient_key.id(), + recipient_public_key, + }) +} + +/// Encrypts `plaintext` from `sender`, the writer, to `recipient` into the `property_path` +/// property of `properties`: picks the keys with [`select_encryption_keys`], writes the +/// recipient property with the recipient's id when the declaration names one, and encrypts +/// with [`encrypt_property`]. Returns the keys used. +/// +/// A declaration whose recipient is `$ownerId` encrypts to the writer, so `recipient` must then +/// be `sender`. +pub fn encrypt_property_for<'a>( + document_type: DocumentTypeRef<'_>, + property_path: &str, + plaintext: &[u8], + sender: &Identity, + sender_private_key: &'a SecretKey, + recipient: &Identity, + properties: &mut BTreeMap, +) -> Result, EncryptedForError> { + let declaration = encrypted_for_declaration(document_type, property_path)?; + let keys = select_declared_keys( + document_type, + &declaration, + sender, + sender_private_key, + recipient, + )?; + match &declaration.recipient { + EncryptedForRecipient::Owner if recipient.id() != sender.id() => { + return Err(EncryptedForError::RecipientMustBeOwner { + path: property_path.to_string(), + sender_id: sender.id(), + recipient_id: recipient.id(), + }); + } + EncryptedForRecipient::Owner => {} + EncryptedForRecipient::Property(path) => insert( + properties, + path, + Value::Identifier(recipient.id().to_buffer()), + )?, + } + encrypt_declared( + &declaration, + property_path, + plaintext, + &keys, + &random_iv(), + properties, + )?; + Ok(keys) +} + +/// Whether `document` may have changed owner since it was written: it carries a transfer +/// time, or its type allows a transfer or a purchase and records no transfer time, so one +/// cannot be ruled out. +fn owner_may_have_changed(document_type: DocumentTypeRef<'_>, document: &Document) -> bool { + if document.transferred_at().is_some() + || document.transferred_at_block_height().is_some() + || document.transferred_at_core_block_height().is_some() + { + return true; + } + let owner_can_change = document_type.documents_transferable().is_transferable() + || document_type.trade_mode().seller_sets_price(); + let records_transfers = [ + property_names::TRANSFERRED_AT, + property_names::TRANSFERRED_AT_BLOCK_HEIGHT, + property_names::TRANSFERRED_AT_CORE_BLOCK_HEIGHT, + ] + .iter() + .any(|field| document_type.required_fields().contains(*field)); + owner_can_change && !records_transfers +} + +/// Every `keyRequirements` the schema declares on a reference to the key `key_path` names: on +/// the key id property itself, and on each identifier property whose `keyIdProperty` it is. +fn key_requirements_naming( + document_type: DocumentTypeRef<'_>, + key_path: &str, +) -> Vec { + document_type + .flattened_properties() + .iter() + .filter_map(|(path, property)| match &property.property_type { + DocumentPropertyType::KeyIdWithReference(reference) if path == key_path => { + Some(reference.key_requirements.clone()) + } + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, + key_requirements, + }, + ) if key_id_property == key_path => Some(key_requirements.clone()), + _ => None, + }) + .collect() +} + +/// Why `key` cannot be named where `requirements` apply to a document of type +/// `document_type_name` of the contract `declaring_contract_id`, `None` when it can. With no +/// purpose among the requirements, the key's purpose must be one of `default_purposes`; with no +/// `boundTo`, a contract-bound key must be bound to this contract or this document type of it. +fn why_unfit( + key: &IdentityPublicKey, + requirements: &[IdentityKeyReferenceRequirements], + default_purposes: &[Purpose], + declaring_contract_id: Identifier, + document_type_name: &str, +) -> Option { + if key.disabled_at().is_some() { + return Some("is disabled".to_string()); + } + for declaration in requirements { + if let Some(unmet) = declaration.first_unmet_by(key, declaring_contract_id) { + return Some(format!( + "does not meet keyRequirements {} {}: it has {}", + unmet.field(), + unmet.required(), + unmet.actual_of(key) + )); + } + } + // A declared `boundTo` is the scope the schema asks for, checked above; without one, a + // bound key must be bound here, never to another contract, another document type or a + // group whose members this check cannot see + let bound_to_declared = requirements + .iter() + .any(|declaration| declaration.bound_to.is_some()); + if let (false, Some(bounds)) = (bound_to_declared, key.contract_bounds()) { + let in_scope = match bounds { + ContractBounds::SingleContract { id } => *id == declaring_contract_id, + ContractBounds::SingleContractDocumentType { + id, + document_type_name: bound_document_type_name, + } => *id == declaring_contract_id && bound_document_type_name == document_type_name, + ContractBounds::ContractGroup { .. } => false, + }; + if !in_scope { + return Some(format!( + "is bound to {bounds:?}, not to contract {declaring_contract_id} or its document \ + type {document_type_name}" + )); + } + } + let purpose_declared = requirements + .iter() + .any(|declaration| declaration.purpose.is_some()); + if !purpose_declared && !default_purposes.contains(&key.purpose()) { + return Some(format!( + "has purpose {}, not an encryption purpose", + key.purpose().wire_name() + )); + } + None +} + +/// The requirements a key must meet, in words, for an error that found none meeting them. +fn describe_requirements( + requirements: &[IdentityKeyReferenceRequirements], + default_purposes: &str, +) -> String { + let mut clauses: Vec = requirements + .iter() + .flat_map(|declaration| declaration.requirements()) + .map(|requirement| format!("{} {}", requirement.field(), requirement.required())) + .collect(); + if !requirements + .iter() + .any(|declaration| declaration.purpose.is_some()) + { + clauses.push(format!("purpose {default_purposes}")); + } + format!("meets {}", clauses.join(", ")) +} + +fn insert( + properties: &mut BTreeMap, + path: &str, + value: Value, +) -> Result<(), EncryptedForError> { + properties + .insert_at_path(path, value) + .map_err(|e| EncryptedForError::InvalidProperty { + path: path.to_string(), + reason: e.to_string(), + }) +} + +fn required_at_path<'a>( + properties: &'a BTreeMap, + path: &str, +) -> Result<&'a Value, EncryptedForError> { + properties + .get_optional_at_path(path) + .map_err(|e| EncryptedForError::InvalidProperty { + path: path.to_string(), + reason: e.to_string(), + })? + .ok_or_else(|| EncryptedForError::MissingProperty { + path: path.to_string(), + }) +} + +fn identifier_at_path( + properties: &BTreeMap, + path: &str, +) -> Result { + required_at_path(properties, path)? + .to_identifier() + .map_err(|e| EncryptedForError::InvalidProperty { + path: path.to_string(), + reason: e.to_string(), + }) +} + +fn key_id_at_path( + properties: &BTreeMap, + path: &str, +) -> Result { + required_at_path(properties, path)? + .to_integer::() + .map_err(|e| EncryptedForError::InvalidProperty { + path: path.to_string(), + reason: e.to_string(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/packages/rs-sdk/src/platform/encrypted_for/tests.rs b/packages/rs-sdk/src/platform/encrypted_for/tests.rs new file mode 100644 index 00000000000..5660550e359 --- /dev/null +++ b/packages/rs-sdk/src/platform/encrypted_for/tests.rs @@ -0,0 +1,1057 @@ +use super::*; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::DataContractConfig; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; +use dpp::data_contract::document_type::DocumentType; +use dpp::document::DocumentV0; +use dpp::identity::contract_bounds::ContractBounds; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::v0::IdentityV0; +use dpp::identity::SecurityLevel; +use dpp::moderation_charter::{ + JOIN_REQUEST_DOCUMENT_TYPE_NAME, MODERATION_CHARTERS_CONTRACT_ID, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, +}; +use dpp::platform_value::{platform_value, BinaryData}; +use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use dpp::version::PlatformVersion; +use platform_encryption::{ + compact_xpub_bytes, decrypt_extended_public_key, encrypt_extended_public_key, +}; + +const ENCRYPTED_MESSAGE: &str = "encryptedMessage"; + +/// The IV of the dashpay vector below. +const DASHPAY_VECTOR_IV: [u8; 16] = [0x5a; 16]; + +/// A dashpay `encryptedPublicKey`: the 69-byte compact xpub of [`dashpay_vector_xpub`] +/// encrypted from the key pair of scalar `0xC0..` to the key pair of scalar `0x0D..` under +/// [`DASHPAY_VECTOR_IV`], by the functions `create_contact_request` calls. Pinned so that a +/// change to either the contact request's encryption or the generic helper shows here; the +/// bytes were cross-checked against an independent secp256k1 ECDH and OpenSSL AES-256-CBC. +const DASHPAY_VECTOR_HEX: &str = "5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a864d1b3807cf80fd27df6cac063a5128\ + fd6119d0d40491a7788cb4e1975bc47e5070c7919e3d8c21ab26be1a763e7908\ + b97cd32a5e36309f3bf9535c519b1b32b2f206696ec6d0e244a2e182fceaa750"; + +fn key_pair(scalar: u8) -> (SecretKey, PublicKey) { + let secret_key = SecretKey::from_slice(&[scalar; 32]).expect("a valid scalar"); + let public_key = PublicKey::from_secret_key(&Secp256k1::signing_only(), &secret_key); + (secret_key, public_key) +} + +fn dashpay_vector_xpub() -> Vec { + let (_, account_key) = key_pair(0x07); + compact_xpub_bytes( + [0x11, 0x22, 0x33, 0x44], + [0xAA; 32], + account_key.serialize(), + ) + .to_vec() +} + +fn charters_contract() -> dpp::prelude::DataContract { + load_system_data_contract( + SystemDataContract::ModerationCharters, + PlatformVersion::latest(), + ) + .expect("the moderation charters contract loads at the latest version") +} + +/// The dashpay `contactRequest` type with the declaration dashpay v2 is proposed to carry: key +/// indexes bounded to `u32` and `encryptedPublicKey` declared `encryptedFor` `toUserId`. +fn contact_request_declaring_encrypted_for() -> DocumentType { + let platform_version = PlatformVersion::latest(); + let dashpay = load_system_data_contract(SystemDataContract::Dashpay, platform_version) + .expect("the dashpay contract loads"); + let mut schema = dashpay + .document_type_for_name("contactRequest") + .expect("dashpay has contactRequest") + .schema() + .clone(); + for key_index in ["senderKeyIndex", "recipientKeyIndex"] { + schema + .set_value_at_full_path( + &format!("properties.{key_index}.maximum"), + Value::U64(u32::MAX as u64), + ) + .expect("sets the bound"); + } + schema + .set_value_at_full_path( + "properties.encryptedPublicKey.encryptedFor", + platform_value!({ + "recipient": "toUserId", + "recipientKey": "recipientKeyIndex", + "senderKey": "senderKeyIndex", + "scheme": "ecdh-secp256k1-aes256-cbc" + }), + ) + .expect("sets the declaration"); + DocumentType::try_from_schema( + dashpay.id(), + dashpay.system_version_type(), + dashpay.config().version(), + "contactRequest", + schema, + None, + &BTreeMap::new(), + dashpay.config(), + false, + &mut vec![], + platform_version, + ) + .expect("the declaring contactRequest parses") +} + +/// Encrypts with a pinned IV, which only tests may do. +fn encrypt_property_with_iv( + document_type: DocumentTypeRef<'_>, + property_path: &str, + plaintext: &[u8], + keys: &EncryptionKeys<'_>, + iv: &[u8; AES_CBC_IV_LENGTH], + properties: &mut BTreeMap, +) -> Result<(), EncryptedForError> { + let declaration = encrypted_for_declaration(document_type, property_path)?; + encrypt_declared(&declaration, property_path, plaintext, keys, iv, properties) +} + +/// The contract id of [`audited_message`]. +const AUDITED_CONTRACT_ID: [u8; 32] = [4; 32]; + +/// A `message` type whose recipient key reference requires a purpose and no binding, with a +/// second, independent `identityPublicKey` reference on the sender key id: `auditIdentityId` +/// makes consensus check that the auditor has a key of that id, nothing more. +fn audited_message() -> DocumentType { + audited_message_with(&[]) +} + +/// [`audited_message`] with the extra document type keywords `keywords`. +fn audited_message_with(keywords: &[(&str, Value)]) -> DocumentType { + let platform_version = PlatformVersion::latest(); + let config = DataContractConfig::default_for_version(platform_version).expect("config"); + let identifier = |position: u32, refers_to: Value| { + platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": position, + "refersTo": refers_to + }) + }; + let mut schema = platform_value!({ + "type": "object", + "properties": { + "recipientId": identifier(0, platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption" } + })), + "recipientKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295u64, "position": 1 }, + "senderKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295u64, "position": 2 }, + "auditIdentityId": identifier(3, platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "senderKeyId" + })), + "body": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 4096, + "position": 4, + "encryptedFor": { + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + } + } + }, + "additionalProperties": false + }); + for (keyword, value) in keywords { + schema + .set_value_at_full_path(keyword, value.clone()) + .expect("sets the keyword"); + } + DocumentType::try_from_schema( + Identifier::from(AUDITED_CONTRACT_ID), + 1, + config.version(), + "message", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut vec![], + platform_version, + ) + .expect("the message type parses") +} + +/// A `note` whose body its writer encrypts to itself, with one property for both key ids. +fn note_keeping_both_key_ids_in_one_property() -> DocumentType { + let platform_version = PlatformVersion::latest(); + let config = DataContractConfig::default_for_version(platform_version).expect("config"); + let schema = platform_value!({ + "type": "object", + "properties": { + "keyId": { "type": "integer", "minimum": 0, "maximum": 4294967295u64, "position": 0 }, + "body": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 4096, + "position": 1, + "encryptedFor": { + "recipient": "$ownerId", + "recipientKey": "keyId", + "senderKey": "keyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + } + } + }, + "additionalProperties": false + }); + DocumentType::try_from_schema( + Identifier::from([3; 32]), + 1, + config.version(), + "note", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut vec![], + platform_version, + ) + .expect("the note type parses") +} + +fn key( + id: KeyID, + purpose: Purpose, + bound_to: Option<&str>, + public_key: &PublicKey, +) -> IdentityPublicKey { + key_with_bounds( + id, + purpose, + bound_to.map( + |document_type_name| ContractBounds::SingleContractDocumentType { + id: MODERATION_CHARTERS_CONTRACT_ID, + document_type_name: document_type_name.to_string(), + }, + ), + public_key, + ) +} + +fn key_with_bounds( + id: KeyID, + purpose: Purpose, + contract_bounds: Option, + public_key: &PublicKey, +) -> IdentityPublicKey { + IdentityPublicKeyV0 { + id, + purpose, + security_level: SecurityLevel::MEDIUM, + contract_bounds, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(public_key.serialize().to_vec()), + disabled_at: None, + } + .into() +} + +fn identity(id: u8, keys: Vec) -> Identity { + IdentityV0 { + id: Identifier::from([id; 32]), + public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), + balance: 0, + revision: 0, + } + .into() +} + +fn keys_between(sender_private_key: &SecretKey, recipient_scalar: u8) -> EncryptionKeys<'_> { + let (_, recipient_public_key) = key_pair(recipient_scalar); + EncryptionKeys { + sender_key_id: 3, + sender_private_key, + recipient_key_id: 7, + recipient_public_key, + } +} + +#[test] +fn should_decrypt_what_it_encrypts_and_fill_the_key_id_properties() { + let contract = charters_contract(); + let join_request = contract + .document_type_for_name(JOIN_REQUEST_DOCUMENT_TYPE_NAME) + .expect("joinRequest exists"); + let (writer_private_key, _) = key_pair(0x21); + let keys = keys_between(&writer_private_key, 0x42); + let plaintext = b"I moderated a forum for five years and would like to help."; + + let mut properties = BTreeMap::new(); + encrypt_property( + join_request, + ENCRYPTED_MESSAGE, + plaintext, + &keys, + &mut properties, + ) + .expect("encrypts"); + + assert_eq!(properties.get("recipientKeyId"), Some(&Value::U32(7))); + assert_eq!(properties.get("senderKeyId"), Some(&Value::U32(3))); + let (recipient_private_key, _) = key_pair(0x42); + let (_, sender_public_key) = key_pair(0x21); + let decrypted = decrypt_property( + join_request, + ENCRYPTED_MESSAGE, + &properties, + &recipient_private_key, + &sender_public_key, + ) + .expect("the recipient decrypts"); + assert_eq!(decrypted, plaintext); + + // ECDH is symmetric: the sender reads its own message back + let (_, recipient_public_key) = key_pair(0x42); + let (sender_private_key, _) = key_pair(0x21); + let by_the_sender = decrypt_property( + join_request, + ENCRYPTED_MESSAGE, + &properties, + &sender_private_key, + &recipient_public_key, + ) + .expect("the sender decrypts"); + assert_eq!(by_the_sender, plaintext); +} + +#[test] +fn should_decrypt_the_dashpay_contact_request_vector_with_the_generic_helper() { + let (sender_private_key, sender_public_key) = key_pair(0xC0); + let (recipient_private_key, recipient_public_key) = key_pair(0x0D); + let xpub = dashpay_vector_xpub(); + + // What `create_contact_request` writes into `encryptedPublicKey` + let shared_key = derive_shared_key_ecdh(&sender_private_key, &recipient_public_key); + let vector = encrypt_extended_public_key(&shared_key, &DASHPAY_VECTOR_IV, &xpub); + assert_eq!(hex::encode(&vector), DASHPAY_VECTOR_HEX); + + let document_type = contact_request_declaring_encrypted_for(); + let properties = BTreeMap::from([ + ( + "toUserId".to_string(), + Value::Identifier(Identifier::from([9; 32]).to_buffer()), + ), + ( + "encryptedPublicKey".to_string(), + Value::Bytes(vector.clone()), + ), + ("senderKeyIndex".to_string(), Value::U32(2)), + ("recipientKeyIndex".to_string(), Value::U32(1)), + ("accountReference".to_string(), Value::U32(0)), + ]); + let decrypted = decrypt_property( + document_type.as_ref(), + "encryptedPublicKey", + &properties, + &recipient_private_key, + &sender_public_key, + ) + .expect("the generic helper decrypts the contact request"); + assert_eq!(decrypted, xpub); + + // And encrypting the same xpub under the same IV writes the same bytes and key ids + let mut written = BTreeMap::new(); + encrypt_property_with_iv( + document_type.as_ref(), + "encryptedPublicKey", + &xpub, + &EncryptionKeys { + sender_key_id: 2, + sender_private_key: &sender_private_key, + recipient_key_id: 1, + recipient_public_key, + }, + &DASHPAY_VECTOR_IV, + &mut written, + ) + .expect("encrypts"); + assert_eq!( + written.get("encryptedPublicKey"), + Some(&Value::Bytes(vector)) + ); + assert_eq!(written.get("senderKeyIndex"), Some(&Value::U32(2))); + assert_eq!(written.get("recipientKeyIndex"), Some(&Value::U32(1))); + let written_bytes = written["encryptedPublicKey"] + .to_binary_bytes() + .expect("bytes"); + assert_eq!( + decrypt_extended_public_key(&shared_key, &written_bytes).expect("dashpay decrypts it"), + xpub + ); +} + +#[test] +fn should_write_an_iv_plus_whole_blocks_that_pass_the_consensus_shape_check() { + let contract = charters_contract(); + let join_request = contract + .document_type_for_name(JOIN_REQUEST_DOCUMENT_TYPE_NAME) + .expect("joinRequest exists"); + let scheme = EncryptionScheme::EcdhSecp256k1Aes256Cbc; + for plaintext_length in [0usize, 1, 15, 16, 17, 31, 32, 500, 1023] { + let mut properties = BTreeMap::new(); + encrypt_property( + join_request, + ENCRYPTED_MESSAGE, + &vec![0x61; plaintext_length], + &keys_between(&key_pair(0x21).0, 0x42), + &mut properties, + ) + .expect("encrypts"); + let length = properties[ENCRYPTED_MESSAGE] + .to_binary_bytes() + .expect("bytes") + .len(); + // PKCS7 always pads, so a whole block past the plaintext at most + assert_eq!( + length, + 16 + (plaintext_length / 16 + 1) * 16, + "{plaintext_length} bytes" + ); + assert!(scheme.is_valid_ciphertext_length(length)); + assert!(join_request + .validate_encrypted_property_shapes(&properties, PlatformVersion::latest()) + .expect("the check runs") + .is_valid()); + } +} + +#[test] +fn should_fail_to_decrypt_with_a_wrong_key() { + let contract = charters_contract(); + let join_request = contract + .document_type_for_name(JOIN_REQUEST_DOCUMENT_TYPE_NAME) + .expect("joinRequest exists"); + let mut properties = BTreeMap::new(); + encrypt_property_with_iv( + join_request, + ENCRYPTED_MESSAGE, + b"only the leader reads this", + &keys_between(&key_pair(0x21).0, 0x42), + &[0x01; 16], + &mut properties, + ) + .expect("encrypts"); + + let (_, sender_public_key) = key_pair(0x21); + let (someone_else, _) = key_pair(0x43); + assert_eq!( + decrypt_property( + join_request, + ENCRYPTED_MESSAGE, + &properties, + &someone_else, + &sender_public_key, + ), + Err(EncryptedForError::DecryptionFailed) + ); + let (recipient_private_key, _) = key_pair(0x42); + let (_, another_sender) = key_pair(0x22); + assert_eq!( + decrypt_property( + join_request, + ENCRYPTED_MESSAGE, + &properties, + &recipient_private_key, + &another_sender, + ), + Err(EncryptedForError::DecryptionFailed) + ); +} + +#[test] +fn should_refuse_bytes_of_the_wrong_shape_before_decrypting() { + let contract = charters_contract(); + let join_request = contract + .document_type_for_name(JOIN_REQUEST_DOCUMENT_TYPE_NAME) + .expect("joinRequest exists"); + let (recipient_private_key, _) = key_pair(0x42); + let (_, sender_public_key) = key_pair(0x21); + for length in [0usize, 16, 31, 33, 47] { + let properties = + BTreeMap::from([(ENCRYPTED_MESSAGE.to_string(), Value::Bytes(vec![0; length]))]); + assert_eq!( + decrypt_property( + join_request, + ENCRYPTED_MESSAGE, + &properties, + &recipient_private_key, + &sender_public_key, + ), + Err(EncryptedForError::InvalidCiphertextLength { + path: ENCRYPTED_MESSAGE.to_string(), + scheme: EncryptionScheme::EcdhSecp256k1Aes256Cbc, + length, + }) + ); + } +} + +#[test] +fn should_refuse_a_property_that_declares_no_encrypted_for() { + let contract = charters_contract(); + let join_request = contract + .document_type_for_name(JOIN_REQUEST_DOCUMENT_TYPE_NAME) + .expect("joinRequest exists"); + assert_eq!( + encrypt_property( + join_request, + "submittedCharterId", + b"x", + &keys_between(&key_pair(0x21).0, 0x42), + &mut BTreeMap::new(), + ), + Err(EncryptedForError::NotDeclared { + document_type: JOIN_REQUEST_DOCUMENT_TYPE_NAME.to_string(), + property: "submittedCharterId".to_string(), + }) + ); +} + +#[test] +fn should_pick_the_keys_the_key_requirements_demand() { + let contract = charters_contract(); + let join_request = contract + .document_type_for_name(JOIN_REQUEST_DOCUMENT_TYPE_NAME) + .expect("joinRequest exists"); + let (writer_private_key, writer_public_key) = key_pair(0x21); + let (_, unbound_writer_key) = key_pair(0x22); + let writer = identity( + 1, + vec![ + key(0, Purpose::AUTHENTICATION, None, &unbound_writer_key), + key( + 4, + Purpose::ENCRYPTION, + Some(JOIN_REQUEST_DOCUMENT_TYPE_NAME), + &writer_public_key, + ), + ], + ); + let (_, leader_bound) = key_pair(0x42); + let (_, leader_bound_disabled) = key_pair(0x43); + let (_, leader_unbound) = key_pair(0x44); + let (_, leader_bound_elsewhere) = key_pair(0x45); + let mut disabled = key( + 9, + Purpose::DECRYPTION, + Some(SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME), + &leader_bound_disabled, + ); + if let IdentityPublicKey::V0(v0) = &mut disabled { + v0.disabled_at = Some(1); + } + let leader = identity( + 2, + vec![ + key( + 2, + Purpose::DECRYPTION, + Some(SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME), + &leader_bound, + ), + key(3, Purpose::DECRYPTION, None, &leader_unbound), + key( + 5, + Purpose::DECRYPTION, + Some(JOIN_REQUEST_DOCUMENT_TYPE_NAME), + &leader_bound_elsewhere, + ), + disabled, + ], + ); + + let keys = select_encryption_keys( + join_request, + ENCRYPTED_MESSAGE, + &writer, + &writer_private_key, + &leader, + ) + .expect("keys are found"); + assert_eq!(keys.sender_key_id, 4); + assert_eq!(keys.recipient_key_id, 2); + assert_eq!(keys.recipient_public_key, leader_bound); + + // A writer key that is not bound to joinRequest is refused, as consensus would + let (other_private_key, other_public_key) = key_pair(0x23); + let unbound_writer = identity( + 1, + vec![key(4, Purpose::ENCRYPTION, None, &other_public_key)], + ); + assert!(matches!( + select_encryption_keys( + join_request, + ENCRYPTED_MESSAGE, + &unbound_writer, + &other_private_key, + &leader, + ), + Err(EncryptedForError::NoSuitableKey { + role: EncryptionKeyRole::Sender, + .. + }) + )); + // A private key that is none of the writer's keys is refused + let (stranger, _) = key_pair(0x77); + assert!(matches!( + select_encryption_keys(join_request, ENCRYPTED_MESSAGE, &writer, &stranger, &leader), + Err(EncryptedForError::NoSuitableKey { + role: EncryptionKeyRole::Sender, + .. + }) + )); + // A leader without a decryption key bound to submittedCharter cannot be written to + let leader_without = identity(2, vec![key(3, Purpose::DECRYPTION, None, &leader_unbound)]); + assert!(matches!( + select_encryption_keys( + join_request, + ENCRYPTED_MESSAGE, + &writer, + &writer_private_key, + &leader_without, + ), + Err(EncryptedForError::NoSuitableKey { + role: EncryptionKeyRole::Recipient, + .. + }) + )); +} + +#[test] +fn should_write_the_recipient_and_read_the_envelope_back() { + let contract = charters_contract(); + let join_request = contract + .document_type_for_name(JOIN_REQUEST_DOCUMENT_TYPE_NAME) + .expect("joinRequest exists"); + let (writer_private_key, writer_public_key) = key_pair(0x21); + let writer = identity( + 1, + vec![key( + 4, + Purpose::ENCRYPTION, + Some(JOIN_REQUEST_DOCUMENT_TYPE_NAME), + &writer_public_key, + )], + ); + let (leader_private_key, leader_public_key) = key_pair(0x42); + let leader = identity( + 2, + vec![key( + 2, + Purpose::DECRYPTION, + Some(SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME), + &leader_public_key, + )], + ); + + let mut properties = BTreeMap::new(); + encrypt_property_for( + join_request, + ENCRYPTED_MESSAGE, + b"hello", + &writer, + &writer_private_key, + &leader, + &mut properties, + ) + .expect("encrypts"); + assert_eq!( + properties.get("recipientId"), + Some(&Value::Identifier(leader.id().to_buffer())) + ); + + let document: Document = DocumentV0 { + id: Identifier::from([5; 32]), + owner_id: writer.id(), + properties, + ..Default::default() + } + .into(); + let envelope = + EncryptedPropertyEnvelope::read(join_request, ENCRYPTED_MESSAGE, &document).expect("reads"); + assert_eq!( + envelope, + EncryptedPropertyEnvelope { + recipient_id: leader.id(), + recipient_key_id: 2, + sender_id: writer.id(), + sender_key_id: 4, + } + ); + assert_eq!( + decrypt_property( + join_request, + ENCRYPTED_MESSAGE, + document.properties(), + &leader_private_key, + &writer_public_key, + ) + .expect("the leader decrypts"), + b"hello" + ); +} + +#[test] +fn should_refuse_a_ciphertext_that_is_not_bytes() { + // A document built without its contract holds a byte array as a list of numbers; the wasm + // bindings sanitize it against the document type before calling here + let contract = charters_contract(); + let join_request = contract + .document_type_for_name(JOIN_REQUEST_DOCUMENT_TYPE_NAME) + .expect("joinRequest exists"); + let properties = BTreeMap::from([( + ENCRYPTED_MESSAGE.to_string(), + Value::Array(vec![Value::U64(1); 32]), + )]); + let (recipient_private_key, _) = key_pair(0x42); + let (_, sender_public_key) = key_pair(0x21); + assert!(matches!( + decrypt_property( + join_request, + ENCRYPTED_MESSAGE, + &properties, + &recipient_private_key, + &sender_public_key, + ), + Err(EncryptedForError::InvalidProperty { .. }) + )); +} + +#[test] +fn should_encrypt_under_one_key_when_one_property_keeps_both_key_ids() { + let note = note_keeping_both_key_ids_in_one_property(); + let (writer_private_key, writer_public_key) = key_pair(0x21); + let (_, decryption_public_key) = key_pair(0x22); + let writer = identity( + 1, + vec![ + key(1, Purpose::ENCRYPTION, None, &writer_public_key), + key(2, Purpose::DECRYPTION, None, &decryption_public_key), + ], + ); + + // The writer's decryption key would be picked for a separate recipient key property; with + // one property for both, the one key is the writer's own + let mut properties = BTreeMap::new(); + let keys = encrypt_property_for( + note.as_ref(), + "body", + b"note to self", + &writer, + &writer_private_key, + &writer, + &mut properties, + ) + .expect("encrypts"); + assert_eq!((keys.sender_key_id, keys.recipient_key_id), (1, 1)); + assert_eq!(properties.get("keyId"), Some(&Value::U32(1))); + assert_eq!( + decrypt_property( + note.as_ref(), + "body", + &properties, + &writer_private_key, + &writer_public_key, + ) + .expect("the key the document names decrypts"), + b"note to self" + ); + + // Two different key ids for the one property are refused rather than one overwritten + let mismatched = EncryptionKeys { + sender_key_id: 1, + sender_private_key: &writer_private_key, + recipient_key_id: 2, + recipient_public_key: decryption_public_key, + }; + assert!(matches!( + encrypt_property( + note.as_ref(), + "body", + b"x", + &mismatched, + &mut BTreeMap::new() + ), + Err(EncryptedForError::SharedKeyIdProperty { .. }) + )); +} + +#[test] +fn should_skip_keys_bound_to_another_scope_when_no_bound_to_is_required() { + // No keyRequirements at all: the dashpay-shaped contactRequest declaration + let contact_request = contact_request_declaring_encrypted_for(); + // Purpose-only keyRequirements: the audited message + let message = audited_message(); + + let (writer_private_key, writer_public_key) = key_pair(0x21); + let writer = identity( + 1, + vec![key(4, Purpose::ENCRYPTION, None, &writer_public_key)], + ); + let public = |scalar| key_pair(scalar).1; + + for (document_type, property) in [ + (contact_request.as_ref(), "encryptedPublicKey"), + (message.as_ref(), "body"), + ] { + let contract_id = document_type.data_contract_id(); + let elsewhere = ContractBounds::SingleContract { + id: MODERATION_CHARTERS_CONTRACT_ID, + }; + let other_type_here = ContractBounds::SingleContractDocumentType { + id: contract_id, + document_type_name: "somethingElse".to_string(), + }; + let recipient = identity( + 2, + vec![ + key_with_bounds( + 9, + Purpose::DECRYPTION, + Some(elsewhere.clone()), + &public(0x49), + ), + key_with_bounds(8, Purpose::DECRYPTION, Some(other_type_here), &public(0x48)), + key_with_bounds( + 7, + Purpose::DECRYPTION, + Some(ContractBounds::ContractGroup { id: contract_id }), + &public(0x47), + ), + key_with_bounds(2, Purpose::DECRYPTION, None, &public(0x42)), + ], + ); + let keys = select_encryption_keys( + document_type, + property, + &writer, + &writer_private_key, + &recipient, + ) + .expect("an unbound key is in scope"); + assert_eq!( + keys.recipient_key_id, + 2, + "{} passes over keys bound elsewhere", + document_type.name() + ); + + // A key bound to this contract, or to this very document type, is in scope + let mut in_scope = recipient.clone(); + for (id, bounds) in [ + (5, ContractBounds::SingleContract { id: contract_id }), + ( + 6, + ContractBounds::SingleContractDocumentType { + id: contract_id, + document_type_name: document_type.name().clone(), + }, + ), + ] { + let Identity::V0(IdentityV0 { public_keys, .. }) = &mut in_scope; + public_keys.insert( + id, + key_with_bounds( + id, + Purpose::DECRYPTION, + Some(bounds), + &public(0x50 + id as u8), + ), + ); + } + let keys = select_encryption_keys( + document_type, + property, + &writer, + &writer_private_key, + &in_scope, + ) + .expect("keys are found"); + assert_eq!(keys.recipient_key_id, 6); + + // A writer key bound to another contract is refused as the sender key + let (bound_private_key, bound_public_key) = key_pair(0x23); + let bound_writer = identity( + 1, + vec![key_with_bounds( + 4, + Purpose::ENCRYPTION, + Some(elsewhere), + &bound_public_key, + )], + ); + assert!(matches!( + select_encryption_keys( + document_type, + property, + &bound_writer, + &bound_private_key, + &recipient, + ), + Err(EncryptedForError::NoSuitableKey { + role: EncryptionKeyRole::Sender, + .. + }) + )); + } +} + +#[test] +fn should_name_the_owner_as_the_sender_whatever_else_refers_to_the_sender_key() { + let message = audited_message(); + let (writer_private_key, writer_public_key) = key_pair(0x21); + let writer = identity( + 1, + vec![key(4, Purpose::ENCRYPTION, None, &writer_public_key)], + ); + let (leader_private_key, leader_public_key) = key_pair(0x42); + let leader = identity( + 2, + vec![key(2, Purpose::DECRYPTION, None, &leader_public_key)], + ); + let auditor = Identifier::from([0xAD; 32]); + + let mut properties = BTreeMap::from([( + "auditIdentityId".to_string(), + Value::Identifier(auditor.to_buffer()), + )]); + encrypt_property_for( + message.as_ref(), + "body", + b"for the leader", + &writer, + &writer_private_key, + &leader, + &mut properties, + ) + .expect("encrypts"); + // A creator other than the owner does not make the creator the sender either + let document: Document = DocumentV0 { + id: Identifier::from([5; 32]), + owner_id: writer.id(), + creator_id: Some(Identifier::from([0xC0; 32])), + properties, + ..Default::default() + } + .into(); + + let envelope = + EncryptedPropertyEnvelope::read(message.as_ref(), "body", &document).expect("reads"); + assert_eq!(envelope.sender_id, writer.id()); + assert_eq!(envelope.recipient_id, leader.id()); + assert_eq!( + decrypt_property( + message.as_ref(), + "body", + document.properties(), + &leader_private_key, + &writer_public_key, + ) + .expect("the owner's key is the sender key"), + b"for the leader" + ); +} + +#[test] +fn should_refuse_to_name_a_sender_once_the_owner_may_have_changed() { + let (writer_private_key, writer_public_key) = key_pair(0x21); + let writer = identity( + 1, + vec![key(4, Purpose::ENCRYPTION, None, &writer_public_key)], + ); + let (_, leader_public_key) = key_pair(0x42); + let leader = identity( + 2, + vec![key(2, Purpose::DECRYPTION, None, &leader_public_key)], + ); + let written = |document_type: &DocumentType| -> BTreeMap { + let mut properties = + BTreeMap::from([("auditIdentityId".to_string(), Value::Identifier([0xAD; 32]))]); + encrypt_property_for( + document_type.as_ref(), + "body", + b"x", + &writer, + &writer_private_key, + &leader, + &mut properties, + ) + .expect("encrypts"); + properties + }; + let refused = |document_type: &DocumentType, document: &Document| { + matches!( + EncryptedPropertyEnvelope::read(document_type.as_ref(), "body", document), + Err(EncryptedForError::SenderUnknownAfterTransfer { .. }) + ) + }; + + // A transfer time on the document: its owner changed after the bytes were written + let message = audited_message(); + let transferred: Document = DocumentV0 { + id: Identifier::from([5; 32]), + owner_id: Identifier::from([0x0E; 32]), + properties: written(&message), + transferred_at: Some(1), + ..Default::default() + } + .into(); + assert!(refused(&message, &transferred)); + + // A transferable type that records no transfer time: a transfer cannot be ruled out + let transferable = audited_message_with(&[("transferable", Value::U8(1))]); + let document: Document = DocumentV0 { + id: Identifier::from([6; 32]), + owner_id: writer.id(), + properties: written(&transferable), + ..Default::default() + } + .into(); + assert!(refused(&transferable, &document)); + + // One that records transfer times and carries none still has its writer as its owner + let recorded = audited_message_with(&[ + ("transferable", Value::U8(1)), + ( + "required", + Value::Array(vec![Value::Text("$transferredAt".to_string())]), + ), + ]); + let untransferred: Document = DocumentV0 { + id: Identifier::from([7; 32]), + owner_id: writer.id(), + properties: written(&recorded), + ..Default::default() + } + .into(); + assert_eq!( + EncryptedPropertyEnvelope::read(recorded.as_ref(), "body", &untransferred) + .expect("reads") + .sender_id, + writer.id() + ); +} diff --git a/packages/rs-sdk/src/platform/moderation_charters/mod.rs b/packages/rs-sdk/src/platform/moderation_charters/mod.rs new file mode 100644 index 00000000000..3752525d6e9 --- /dev/null +++ b/packages/rs-sdk/src/platform/moderation_charters/mod.rs @@ -0,0 +1,75 @@ +//! The moderation charters system contract (protocol version 14): reading who moderates a +//! contract that declares elected moderation, and building the requests members send its +//! leader. +//! +//! Everything here is an ordinary proved document query on the system contract, through the +//! indexes its schema declares: +//! +//! * [`Sdk::fetch_seated_charter`]: the `electedCharter` whose `targetContractId` is the +//! contract (`byTargetContract`). Only a contest's winner is ever stored there, contenders +//! living in the contest, so there is at most one and it is the seated charter. +//! * [`Sdk::fetch_submitted_charter`]: a proposal by id, such as the seated charter's +//! `submittedCharterId`. +//! * [`Sdk::fetch_moderation_team`]: the seated charter's team, the leader plus +//! [`ElectedCharter::active_members`](dpp::moderation_charter::ElectedCharter::active_members) +//! over its `addedModerator` and `removedModerator` documents (`byElectedCharterMember`). +//! * [`Sdk::fetch_submitted_charters`]: the proposals for a contract in filing order +//! (`submittedCharter.byTargetContract`), one page at a time. +//! * [`Sdk::fetch_join_requests`]: the join requests for a proposal +//! (`joinRequest.bySubmittedCharter`), one page at a time. +//! * [`Sdk::fetch_pending_resignation_requests`]: the resignation requests for a charter +//! whose writer the leader has not removed yet. +//! +//! [`Sdk::build_join_request`] and [`Sdk::build_resignation_request`] build the two documents +//! whose message only the leader reads, encrypted with the generic +//! [`encrypted_for`](crate::platform::encrypted_for) helpers under the keys the schema's +//! `keyRequirements` demand: the leader's decryption key bound to `submittedCharter` and the +//! writer's encryption key bound to `joinRequest`. + +mod readers; +mod requests; +mod team; + +pub use readers::{CharterDocumentsPage, SeatedCharter}; +pub use requests::{ + build_join_request_document, build_resignation_request_document, JoinRequestInput, + ModerationCharterRequest, ResignationRequestInput, +}; +pub use team::ModerationTeam; + +use crate::platform::DataContract; +use crate::{Error, Sdk}; +use dpp::moderation_charter::MODERATION_CHARTERS_CONTRACT_ID; +use dpp::platform_value::string_encoding::Encoding; +use dpp::version::feature_initial_protocol_versions::MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION; +use std::sync::Arc; + +impl Sdk { + /// The moderation charters system contract: from the context provider when it holds it, + /// fetched otherwise. + /// + /// The contract exists from protocol version 14. An unpinned SDK starts mainnet and testnet + /// at 13 and only learns a newer version from a verified response, and the contract's schema + /// does not parse at 13, so below 14 the SDK first learns the network's version; a network + /// still below 14 has no such contract, which is an error. + pub async fn fetch_moderation_charters_contract(&self) -> Result, Error> { + if self.protocol_version_number() < MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION { + let version = self.refresh_protocol_version().await?; + if version < MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION { + return Err(Error::Generic(format!( + "the moderation charters contract exists from protocol version \ + {MODERATION_CHARTERS_CONTRACT_INITIAL_PROTOCOL_VERSION}; the SDK runs \ + protocol version {version}" + ))); + } + } + self.fetch_system_data_contract(MODERATION_CHARTERS_CONTRACT_ID) + .await? + .ok_or_else(|| { + Error::MissingDependency( + "moderation charters contract".to_string(), + MODERATION_CHARTERS_CONTRACT_ID.to_string(Encoding::Base58), + ) + }) + } +} diff --git a/packages/rs-sdk/src/platform/moderation_charters/readers.rs b/packages/rs-sdk/src/platform/moderation_charters/readers.rs new file mode 100644 index 00000000000..51f269cc5e2 --- /dev/null +++ b/packages/rs-sdk/src/platform/moderation_charters/readers.rs @@ -0,0 +1,780 @@ +//! Proved document queries on the moderation charters contract. + +use super::ModerationTeam; +use crate::platform::documents::document_query::DocumentQuery; +use crate::platform::{DataContract, Document, Fetch, FetchMany}; +use crate::{Error, Sdk}; +use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; +use dpp::document::DocumentV0Getters; +use dpp::moderation_charter::{ + property_names, ElectedCharter, ADDED_MODERATOR_DOCUMENT_TYPE_NAME, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, JOIN_REQUEST_DOCUMENT_TYPE_NAME, + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, +}; +use dpp::platform_value::{Identifier, Value}; +use drive::query::{OrderClause, WhereClause, WhereOperator}; +use drive_proof_verifier::types::Documents; +use std::collections::BTreeSet; +use std::future::Future; +use std::sync::Arc; + +/// The most documents one query returns, the platform's cap. +const MAX_PAGE_SIZE: u32 = 100; + +/// How many full pages a reader that must see every document (the team's additions and +/// removals, a charter's resignation requests) fetches before it gives up rather than answer +/// from part of the set. Nothing caps additions per charter until seating lands (it will +/// allow at most 15), so the budget only bounds a loop over a set a leader could grow at will. +const MAX_PAGES_PER_READ: u32 = 50; + +/// One page of a listing: at most `limit` documents (the platform's cap of 100 when `None` or +/// 0), after the document `start_after` in the index's order. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CharterDocumentsPage { + /// How many documents at most; `None` and 0 are 100. + pub limit: Option, + /// The id of the last document of the previous page; `None` for the first page. + pub start_after: Option, +} + +impl CharterDocumentsPage { + /// The page after the one `page` holds, `None` when `page` was the last: it held fewer + /// documents than the limit. + pub fn after(&self, page: &Documents) -> Option { + let limit = self.page_size(); + if page.len() < limit as usize { + return None; + } + page.keys().last().map(|last| Self { + limit: self.limit, + start_after: Some(*last), + }) + } + + /// The number of documents a page holds when it is full. + fn page_size(&self) -> u32 { + match self.limit { + None | Some(0) => MAX_PAGE_SIZE, + Some(limit) => limit, + } + } +} + +/// A contract's seated charter: its `electedCharter` document and the properties read out of +/// it. Its owner is the leader. +#[derive(Debug, Clone, PartialEq)] +pub struct SeatedCharter { + /// The `electedCharter` document. + pub document: Document, + /// Its properties: the target, the proposal and the elected members. + pub charter: ElectedCharter, +} + +impl SeatedCharter { + /// Reads a seated charter out of an `electedCharter` document. + pub fn from_document(document: Document) -> Result { + let charter = elected_charter_of(&document)?; + Ok(Self { document, charter }) + } + + /// The id of the `electedCharter` document. + pub fn id(&self) -> Identifier { + self.document.id() + } + + /// The leader: the owner of the charter, and of its proposal. + pub fn leader_id(&self) -> Identifier { + self.document.owner_id() + } +} + +/// Reads the properties of an `electedCharter` document. +pub(super) fn elected_charter_of(document: &Document) -> Result { + let result = ElectedCharter::from_document_properties(document.properties()); + if let Some(error) = result.errors.first() { + return Err(Error::Generic(format!( + "electedCharter {} is malformed: {error}", + document.id() + ))); + } + result.into_data().map_err(Error::Protocol) +} + +/// A query for the documents of `document_type_name` whose `field` is `value`, in the order of +/// `order_by` (the index property after `field`), one page of them. +pub(super) fn index_query( + contract: Arc, + document_type_name: &str, + field: &str, + value: Identifier, + order_by: Option<&str>, + page: CharterDocumentsPage, +) -> Result { + let mut query = DocumentQuery::new(contract, document_type_name)? + .with_where(WhereClause { + field: field.to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(value.to_buffer()), + }) + .with_limit(page.page_size()); + // The order clause pins the index: a bare equality on the first property of a + // two-property index is proven absent instead of served. + if let Some(order_by) = order_by { + query = query.with_order_by(OrderClause { + field: order_by.to_string(), + ascending: true, + }); + } + query.start = page + .start_after + .map(|id| Start::StartAfter(id.to_buffer().to_vec())); + Ok(query) +} + +/// The query for the seated charter of `target_contract_id`: its `electedCharter` through the +/// contested unique index `byTargetContract`. +pub(super) fn seated_charter_query( + contract: Arc, + target_contract_id: Identifier, +) -> Result { + index_query( + contract, + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names::TARGET_CONTRACT_ID, + target_contract_id, + None, + CharterDocumentsPage { + limit: Some(1), + start_after: None, + }, + ) +} + +/// The queries for a charter's additions and removals, `byElectedCharterMember` on each type. +pub(super) fn team_change_query( + contract: Arc, + document_type_name: &str, + elected_charter_id: Identifier, + page: CharterDocumentsPage, +) -> Result { + index_query( + contract, + document_type_name, + property_names::ELECTED_CHARTER_ID, + elected_charter_id, + Some(property_names::MEMBER_ID), + page, + ) +} + +/// The query for a charter's resignation requests, `byElectedCharterOwner`. +pub(super) fn resignation_requests_query( + contract: Arc, + elected_charter_id: Identifier, + page: CharterDocumentsPage, +) -> Result { + index_query( + contract, + RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, + property_names::ELECTED_CHARTER_ID, + elected_charter_id, + Some("$ownerId"), + page, + ) +} + +/// The query for the proposals for a contract, `byTargetContract` in filing order. +pub(super) fn submitted_charters_query( + contract: Arc, + target_contract_id: Identifier, + page: CharterDocumentsPage, +) -> Result { + index_query( + contract, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + property_names::TARGET_CONTRACT_ID, + target_contract_id, + Some("$createdAt"), + page, + ) +} + +/// The query for the join requests for a proposal, `bySubmittedCharter` in owner order. +pub(super) fn join_requests_query( + contract: Arc, + submitted_charter_id: Identifier, + page: CharterDocumentsPage, +) -> Result { + index_query( + contract, + JOIN_REQUEST_DOCUMENT_TYPE_NAME, + property_names::SUBMITTED_CHARTER_ID, + submitted_charter_id, + Some("$ownerId"), + page, + ) +} + +/// The resignation requests among `requests` whose writer is not among `removed`, the +/// `memberId`s of the charter's removals: the ones the leader has not acted on. +pub(super) fn pending_resignation_requests( + requests: Vec, + removed: &BTreeSet, +) -> Vec { + requests + .into_iter() + .filter(|request| !removed.contains(&request.owner_id())) + .collect() +} + +/// The `memberId` of each of `documents`. +pub(super) fn member_ids(documents: &[Document]) -> Result, Error> { + documents + .iter() + .map(|document| { + document + .properties() + .get(property_names::MEMBER_ID) + .ok_or_else(|| { + Error::Generic(format!("document {} has no memberId", document.id())) + })? + .to_identifier() + .map_err(|e| Error::Generic(format!("document {}: {e}", document.id()))) + }) + .collect() +} + +impl Sdk { + /// The seated charter of `target_contract_id`: the `electedCharter` whose + /// `targetContractId` is it, `None` when the contract has none (no contest awarded yet, or + /// no elected moderation at all). + pub async fn fetch_seated_charter( + &self, + target_contract_id: Identifier, + ) -> Result, Error> { + let contract = self.fetch_moderation_charters_contract().await?; + self.fetch_seated_charter_of(contract, target_contract_id) + .await + } + + async fn fetch_seated_charter_of( + &self, + contract: Arc, + target_contract_id: Identifier, + ) -> Result, Error> { + let documents = + Document::fetch_many(self, seated_charter_query(contract, target_contract_id)?).await?; + documents + .into_values() + .flatten() + .next() + .map(SeatedCharter::from_document) + .transpose() + } + + /// The `submittedCharter` (a proposal) with id `submitted_charter_id`, `None` when there is + /// none. + pub async fn fetch_submitted_charter( + &self, + submitted_charter_id: Identifier, + ) -> Result, Error> { + let contract = self.fetch_moderation_charters_contract().await?; + self.fetch_submitted_charter_of(contract, submitted_charter_id) + .await + } + + /// [`Sdk::fetch_submitted_charter`] with the charters contract already resolved. + pub(super) async fn fetch_submitted_charter_of( + &self, + contract: Arc, + submitted_charter_id: Identifier, + ) -> Result, Error> { + let query = DocumentQuery::new(contract, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME)? + .with_document_id(&submitted_charter_id); + Document::fetch(self, query).await + } + + /// The `electedCharter` with id `elected_charter_id`, `None` when there is none. A stored + /// elected charter is always a seated one. + pub async fn fetch_elected_charter( + &self, + elected_charter_id: Identifier, + ) -> Result, Error> { + let contract = self.fetch_moderation_charters_contract().await?; + self.fetch_elected_charter_of(contract, elected_charter_id) + .await + } + + /// [`Sdk::fetch_elected_charter`] with the charters contract already resolved. + pub(super) async fn fetch_elected_charter_of( + &self, + contract: Arc, + elected_charter_id: Identifier, + ) -> Result, Error> { + let query = DocumentQuery::new(contract, ELECTED_CHARTER_DOCUMENT_TYPE_NAME)? + .with_document_id(&elected_charter_id); + Document::fetch(self, query) + .await? + .map(SeatedCharter::from_document) + .transpose() + } + + /// The team that moderates `target_contract_id`: the seated charter's leader plus its + /// elected members and the members the leader added, less those the leader removed. + /// `None` when the contract has no seated charter. + pub async fn fetch_moderation_team( + &self, + target_contract_id: Identifier, + ) -> Result, Error> { + let contract = self.fetch_moderation_charters_contract().await?; + let Some(seated) = self + .fetch_seated_charter_of(contract.clone(), target_contract_id) + .await? + else { + return Ok(None); + }; + let (added, removed) = futures::try_join!( + self.fetch_every_page(|page| team_change_query( + contract.clone(), + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, + seated.id(), + page, + )), + self.fetch_every_page(|page| team_change_query( + contract.clone(), + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + seated.id(), + page, + )), + )?; + ModerationTeam::from_documents(&seated.document, &added, &removed).map(Some) + } + + /// One page of the proposals (`submittedCharter` documents) for `target_contract_id`, in + /// filing order. [`CharterDocumentsPage::after`] gives the next page. + pub async fn fetch_submitted_charters( + &self, + target_contract_id: Identifier, + page: CharterDocumentsPage, + ) -> Result { + let contract = self.fetch_moderation_charters_contract().await?; + Document::fetch_many( + self, + submitted_charters_query(contract, target_contract_id, page)?, + ) + .await + } + + /// One page of the join requests for the proposal `submitted_charter_id`, in the order of + /// their owners' ids. [`CharterDocumentsPage::after`] gives the next page. + pub async fn fetch_join_requests( + &self, + submitted_charter_id: Identifier, + page: CharterDocumentsPage, + ) -> Result { + let contract = self.fetch_moderation_charters_contract().await?; + Document::fetch_many( + self, + join_requests_query(contract, submitted_charter_id, page)?, + ) + .await + } + + /// The resignation requests for the charter `elected_charter_id` the leader has not acted + /// on: those whose writer the charter has no `removedModerator` for. A withdrawn request is + /// deleted, so it is not among them either. + pub async fn fetch_pending_resignation_requests( + &self, + elected_charter_id: Identifier, + ) -> Result, Error> { + let contract = self.fetch_moderation_charters_contract().await?; + let (requests, removed) = futures::try_join!( + self.fetch_every_page(|page| resignation_requests_query( + contract.clone(), + elected_charter_id, + page, + )), + self.fetch_every_page(|page| team_change_query( + contract.clone(), + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + elected_charter_id, + page, + )), + )?; + Ok(pending_resignation_requests( + requests, + &member_ids(&removed)?, + )) + } + + /// Every document the query `query_for_page` builds matches, page after page, or an error + /// when there are more than [`MAX_PAGES_PER_READ`] pages of them: a reader that answers for + /// the whole set must not answer from part of it. + async fn fetch_every_page( + &self, + query_for_page: impl Fn(CharterDocumentsPage) -> Result, + ) -> Result, Error> { + collect_every_page(|page| { + let query = query_for_page(page); + async move { Document::fetch_many(self, query?).await } + }) + .await + } +} + +/// Every document `fetch_page` returns, page after page, up to [`MAX_PAGES_PER_READ`] full +/// pages. With the budget spent, one more page is read: empty, the set held exactly the budget +/// and is complete; not empty, the set is larger and the read is refused. +pub(super) async fn collect_every_page(mut fetch_page: F) -> Result, Error> +where + F: FnMut(CharterDocumentsPage) -> Fut, + Fut: Future>, +{ + let mut documents = Vec::new(); + let mut page = CharterDocumentsPage::default(); + for _ in 0..MAX_PAGES_PER_READ { + let fetched = fetch_page(page).await?; + let next = page.after(&fetched); + documents.extend(fetched.into_values().flatten()); + match next { + Some(next) => page = next, + None => return Ok(documents), + } + } + if fetch_page(page).await?.is_empty() { + return Ok(documents); + } + Err(Error::Generic(format!( + "more than {} moderation charter documents match; refusing to answer from part of them", + MAX_PAGES_PER_READ * MAX_PAGE_SIZE + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dpp::document::DocumentV0; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + use drive::query::DriveDocumentQuery; + use std::collections::BTreeMap; + use std::future::{ready, Ready}; + + fn charters_contract() -> Arc { + Arc::new( + load_system_data_contract( + SystemDataContract::ModerationCharters, + PlatformVersion::latest(), + ) + .expect("the moderation charters contract loads"), + ) + } + + /// The index `query` is served through, and that a proof of it can be verified: the path + /// query the verifier rebuilds constructs. + fn served_through(query: &DocumentQuery) -> String { + let platform_version = PlatformVersion::latest(); + let drive_query = DriveDocumentQuery::try_from(query).expect("converts"); + drive_query + .construct_path_query(None, platform_version) + .expect("a provable path query"); + drive_query + .find_best_index(platform_version) + .expect("an index serves it") + .name + .clone() + } + + #[test] + fn should_serve_every_reader_through_the_index_the_schema_declares_for_it() { + let contract = charters_contract(); + let id = Identifier::from([7; 32]); + let page = CharterDocumentsPage::default(); + let cases = [ + ( + seated_charter_query(contract.clone(), id).expect("builds"), + "byTargetContract", + ), + ( + submitted_charters_query(contract.clone(), id, page).expect("builds"), + "byTargetContract", + ), + ( + join_requests_query(contract.clone(), id, page).expect("builds"), + "bySubmittedCharter", + ), + ( + team_change_query( + contract.clone(), + ADDED_MODERATOR_DOCUMENT_TYPE_NAME, + id, + page, + ) + .expect("builds"), + "byElectedCharterMember", + ), + ( + team_change_query( + contract.clone(), + REMOVED_MODERATOR_DOCUMENT_TYPE_NAME, + id, + page, + ) + .expect("builds"), + "byElectedCharterMember", + ), + ( + resignation_requests_query(contract.clone(), id, page).expect("builds"), + "byElectedCharterOwner", + ), + ]; + for (query, index) in cases { + assert_eq!( + served_through(&query), + index, + "{} query", + query.document_type_name + ); + } + // The contested index is the elected charter's own + assert!(contract + .document_type_for_name(ELECTED_CHARTER_DOCUMENT_TYPE_NAME) + .expect("exists") + .indexes() + .get("byTargetContract") + .expect("declared") + .contested_index + .is_some()); + } + + #[test] + fn should_page_after_the_last_document_of_a_full_page_only() { + let page = CharterDocumentsPage { + limit: Some(2), + start_after: None, + }; + let full: Documents = [Identifier::from([1; 32]), Identifier::from([2; 32])] + .into_iter() + .map(|id| (id, None)) + .collect(); + assert_eq!( + page.after(&full), + Some(CharterDocumentsPage { + limit: Some(2), + start_after: Some(Identifier::from([2; 32])), + }) + ); + let short: Documents = [(Identifier::from([1; 32]), None)].into_iter().collect(); + assert_eq!(page.after(&short), None); + // A limit of 0 is the platform's default of 100, so two documents are a short page + let default_size = CharterDocumentsPage { + limit: Some(0), + start_after: None, + }; + assert_eq!(default_size.after(&full), None); + } + + fn document(id: u32, owner: u8) -> Document { + let mut bytes = [0u8; 32]; + bytes[..4].copy_from_slice(&id.to_be_bytes()); + DocumentV0 { + id: Identifier::from(bytes), + owner_id: Identifier::from([owner; 32]), + ..Default::default() + } + .into() + } + + /// A store of `total` documents served a page at a time, as the platform pages them. + fn serve_pages( + total: u32, + ) -> impl FnMut(CharterDocumentsPage) -> Ready> { + move |page| { + let first = match page.start_after { + None => 0, + Some(last) => { + let bytes = last.to_buffer(); + u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) + 1 + } + }; + let end = total.min(first + page.page_size()); + ready(Ok((first..end) + .map(|id| { + let document = document(id, 1); + (document.id(), Some(document)) + }) + .collect())) + } + } + + #[tokio::test] + async fn should_read_a_set_of_exactly_the_page_budget_in_full() { + let budget = MAX_PAGES_PER_READ * MAX_PAGE_SIZE; + let documents = collect_every_page(serve_pages(budget)) + .await + .expect("a set of exactly the budget is complete"); + assert_eq!(documents.len(), budget as usize); + + let short = collect_every_page(serve_pages(250)).await.expect("reads"); + assert_eq!(short.len(), 250); + } + + #[tokio::test] + async fn should_refuse_a_set_larger_than_the_page_budget() { + let budget = MAX_PAGES_PER_READ * MAX_PAGE_SIZE; + assert!(collect_every_page(serve_pages(budget + 1)).await.is_err()); + } + + #[test] + fn should_keep_only_the_resignation_requests_whose_writer_was_not_removed() { + let requests = vec![document(1, 0xA1), document(2, 0xA2), document(3, 0xA3)]; + let removed = BTreeSet::from([Identifier::from([0xA2; 32]), Identifier::from([0xFF; 32])]); + + let pending = pending_resignation_requests(requests, &removed); + + assert_eq!( + pending + .iter() + .map(|request| request.owner_id()) + .collect::>(), + vec![Identifier::from([0xA1; 32]), Identifier::from([0xA3; 32])] + ); + assert!(pending_resignation_requests(vec![], &removed).is_empty()); + } + + #[test] + fn should_read_the_member_of_each_team_change() { + let change = |member: u8| -> Document { + DocumentV0 { + id: Identifier::from([member; 32]), + properties: BTreeMap::from([( + property_names::MEMBER_ID.to_string(), + Value::Identifier([member; 32]), + )]), + ..Default::default() + } + .into() + }; + assert_eq!( + member_ids(&[change(5), change(6)]).expect("reads"), + BTreeSet::from([Identifier::from([5; 32]), Identifier::from([6; 32])]) + ); + assert!(member_ids(&[document(1, 1)]).is_err()); + } + + /// Serves `documents` a page at a time in their given order, the order of the index a query + /// walks, continuing after the document a page names. + fn serve_in_order( + documents: Vec, + ) -> impl FnMut(CharterDocumentsPage) -> Ready> { + move |page| { + let first = match page.start_after { + None => 0, + Some(last) => { + documents + .iter() + .position(|document| document.id() == last) + .expect("the cursor names a served document") + + 1 + } + }; + let end = documents.len().min(first + page.page_size() as usize); + ready(Ok(documents[first..end] + .iter() + .map(|document| (document.id(), Some(document.clone()))) + .collect())) + } + } + + #[tokio::test] + async fn should_continue_after_the_last_document_of_each_page_in_query_order() { + // Ids run against the index order, so the last document of a page is not its largest id + let documents: Vec = (0..102u32).rev().map(|id| document(id, 1)).collect(); + let mut serve = serve_in_order(documents.clone()); + let mut cursors = Vec::new(); + let collected = collect_every_page(|page: CharterDocumentsPage| { + cursors.push(page.start_after); + serve(page) + }) + .await + .expect("reads"); + + assert_eq!(cursors, vec![None, Some(documents[99].id())]); + assert_eq!( + collected + .iter() + .map(|document| document.id()) + .collect::>(), + documents + .iter() + .map(|document| document.id()) + .collect::>() + ); + } + + #[tokio::test] + async fn should_build_the_team_from_changes_on_every_page() { + let charter_id = Identifier::from([0xC1; 32]); + let leader = Identifier::from([0x01; 32]); + let member = |n: u32| { + let mut bytes = [0xEE; 32]; + bytes[..4].copy_from_slice(&n.to_be_bytes()); + Identifier::from(bytes) + }; + let change = |n: u32, id_byte: u8| -> Document { + let mut id = [id_byte; 32]; + id[..4].copy_from_slice(&n.to_be_bytes()); + DocumentV0 { + id: Identifier::from(id), + owner_id: leader, + properties: BTreeMap::from([ + ( + property_names::ELECTED_CHARTER_ID.to_string(), + Value::Identifier(charter_id.to_buffer()), + ), + ( + property_names::MEMBER_ID.to_string(), + Value::Identifier(member(n).to_buffer()), + ), + ]), + ..Default::default() + } + .into() + }; + let charter: Document = DocumentV0 { + id: charter_id, + owner_id: leader, + properties: ElectedCharter { + target_contract_id: Identifier::from([0xAA; 32]), + submitted_charter_id: Identifier::from([0xBB; 32]), + members: vec![member(1000), member(1001)], + } + .to_document_properties(), + ..Default::default() + } + .into(); + + // 105 additions over two pages; 101 removals over two pages, the last one on the + // second page removing an elected member + let added = collect_every_page(serve_in_order((0..105).map(|n| change(n, 0xA0)).collect())) + .await + .expect("reads the additions"); + let removed = collect_every_page(serve_in_order( + (0..100) + .map(|n| change(n, 0xB0)) + .chain([change(1001, 0xB1)]) + .collect(), + )) + .await + .expect("reads the removals"); + assert_eq!((added.len(), removed.len()), (105, 101)); + + let team = ModerationTeam::from_documents(&charter, &added, &removed).expect("reads"); + let mut expected: BTreeSet = (100..105).map(member).collect(); + expected.insert(member(1000)); + assert_eq!(team.members, expected); + } +} diff --git a/packages/rs-sdk/src/platform/moderation_charters/requests.rs b/packages/rs-sdk/src/platform/moderation_charters/requests.rs new file mode 100644 index 00000000000..7eae0c8718e --- /dev/null +++ b/packages/rs-sdk/src/platform/moderation_charters/requests.rs @@ -0,0 +1,448 @@ +//! Building the join and resignation requests, whose message only the leader reads. + +use crate::platform::encrypted_for::encrypt_property_for; +use crate::platform::{DataContract, Document, Fetch, Identity}; +use crate::{Error, Sdk}; +use dpp::dashcore::secp256k1::rand::rngs::StdRng; +use dpp::dashcore::secp256k1::rand::SeedableRng; +use dpp::dashcore::secp256k1::SecretKey; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; +use dpp::document::{DocumentV0, DocumentV0Getters, INITIAL_REVISION}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::moderation_charter::{ + property_names, ELECTED_CHARTER_DOCUMENT_TYPE_NAME, JOIN_REQUEST_DOCUMENT_TYPE_NAME, + RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, +}; +use dpp::platform_value::{Bytes32, Identifier, Value}; +use std::collections::BTreeMap; + +/// The property of both request types that carries the message, declared `encryptedFor` the +/// leader. +const ENCRYPTED_MESSAGE: &str = "encryptedMessage"; + +/// What a join request says: which proposal the writer offers to serve on, and why. +pub struct JoinRequestInput { + /// The proposal, a `submittedCharter` document. + pub submitted_charter_id: Identifier, + /// Why the writer wants to join, readable by the leader alone. The property holds at most + /// 1040 bytes, so the message at most 1023, checked when the document is validated. + pub message: Vec, + /// The identity offering to serve, the join request's owner. + pub writer: Identity, + /// The private half of the writer's encryption key bound to `joinRequest`, the key the + /// schema requires for `senderKeyId`. + pub writer_encryption_key: SecretKey, +} + +/// What a resignation request says: which seated charter the writer asks to leave, and why. +pub struct ResignationRequestInput { + /// The seated charter, an `electedCharter` document. + pub elected_charter_id: Identifier, + /// Why the writer leaves, readable by the leader alone; at most 1023 bytes, as for a join + /// request. + pub message: Vec, + /// The member asking to leave, the request's owner. Consensus refuses a writer who is not + /// on the charter's team. + pub writer: Identity, + /// The private half of the writer's encryption key bound to `joinRequest`, the key the + /// schema requires for `senderKeyId` (the same one its join request used). + pub writer_encryption_key: SecretKey, +} + +/// A request document ready to be put with +/// [`PutDocument`](crate::platform::transition::put_document::PutDocument): the document, the +/// name of its type and the entropy its id derives from, which the put must reuse. +#[derive(Debug, Clone)] +pub struct ModerationCharterRequest { + /// The document. Its id is a placeholder until it is put: from protocol version 14 the id + /// also commits to the identity contract nonce of the create transition. + pub document: Document, + /// `joinRequest` or `resignationRequest`. + pub document_type_name: String, + /// The entropy the document id derives from. + pub entropy: Bytes32, +} + +/// Builds a join request of `input.writer` for `proposal`, the `submittedCharter` document +/// `input.submitted_charter_id`, whose owner is `leader`: the message encrypted to the leader's +/// decryption key bound to `submittedCharter` from the writer's encryption key bound to +/// `joinRequest`, and `recipientId`, `recipientKeyId` and `senderKeyId` set to match. +pub fn build_join_request_document( + contract: &DataContract, + proposal: &Document, + leader: &Identity, + input: &JoinRequestInput, + entropy: Bytes32, +) -> Result { + if proposal.id() != input.submitted_charter_id { + return Err(Error::Generic(format!( + "the proposal given is {}, not {}", + proposal.id(), + input.submitted_charter_id + ))); + } + build_request( + contract, + JOIN_REQUEST_DOCUMENT_TYPE_NAME, + (property_names::SUBMITTED_CHARTER_ID, proposal), + leader, + &input.message, + &input.writer, + &input.writer_encryption_key, + entropy, + ) +} + +/// Builds a resignation request of `input.writer` from `elected_charter`, the `electedCharter` +/// document `input.elected_charter_id`, whose owner is `leader`, encrypted as a join request +/// is. +pub fn build_resignation_request_document( + contract: &DataContract, + elected_charter: &Document, + leader: &Identity, + input: &ResignationRequestInput, + entropy: Bytes32, +) -> Result { + if elected_charter.id() != input.elected_charter_id { + return Err(Error::Generic(format!( + "the elected charter given is {}, not {}", + elected_charter.id(), + input.elected_charter_id + ))); + } + build_request( + contract, + RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, + (property_names::ELECTED_CHARTER_ID, elected_charter), + leader, + &input.message, + &input.writer, + &input.writer_encryption_key, + entropy, + ) +} + +/// A request of `document_type_name` referring to `referred`, whose owner, `leader`, the +/// message is encrypted to. +#[allow(clippy::too_many_arguments)] +fn build_request( + contract: &DataContract, + document_type_name: &str, + (reference_property, referred): (&str, &Document), + leader: &Identity, + message: &[u8], + writer: &Identity, + writer_encryption_key: &SecretKey, + entropy: Bytes32, +) -> Result { + if referred.owner_id() != leader.id() { + return Err(Error::Generic(format!( + "the leader given, {}, is not the owner of {}", + leader.id(), + referred.id() + ))); + } + let document_type = contract + .document_type_for_name(document_type_name) + .map_err(|e| Error::Protocol(e.into()))?; + let mut properties = BTreeMap::from([( + reference_property.to_string(), + Value::Identifier(referred.id().to_buffer()), + )]); + encrypt_property_for( + document_type, + ENCRYPTED_MESSAGE, + message, + writer, + writer_encryption_key, + leader, + &mut properties, + )?; + let document = DocumentV0 { + id: Document::generate_document_id_v0( + &contract.id(), + &writer.id(), + document_type_name, + entropy.as_slice(), + ), + owner_id: writer.id(), + properties, + revision: document_type + .requires_revision() + .then_some(INITIAL_REVISION), + ..Default::default() + } + .into(); + Ok(ModerationCharterRequest { + document, + document_type_name: document_type_name.to_string(), + entropy, + }) +} + +impl Sdk { + /// Builds a join request for the proposal `input.submitted_charter_id`: fetches the + /// proposal and its leader, and encrypts the message with the keys the schema demands (see + /// [`build_join_request_document`]). The result is put like any document create. + pub async fn build_join_request( + &self, + input: JoinRequestInput, + ) -> Result { + let contract = self.fetch_moderation_charters_contract().await?; + let proposal = self + .fetch_submitted_charter_of(contract.clone(), input.submitted_charter_id) + .await? + .ok_or_else(|| { + Error::Generic(format!( + "no {SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME} {}", + input.submitted_charter_id + )) + })?; + let leader = self.fetch_leader(&proposal).await?; + build_join_request_document(&contract, &proposal, &leader, &input, fresh_entropy()) + } + + /// Builds a resignation request from the seated charter `input.elected_charter_id`: + /// fetches the charter and its leader, and encrypts the message with the keys the schema + /// demands (see [`build_resignation_request_document`]). The result is put like any + /// document create; deleting the document withdraws the request. + pub async fn build_resignation_request( + &self, + input: ResignationRequestInput, + ) -> Result { + let contract = self.fetch_moderation_charters_contract().await?; + let charter = self + .fetch_elected_charter_of(contract.clone(), input.elected_charter_id) + .await? + .ok_or_else(|| { + Error::Generic(format!( + "no {ELECTED_CHARTER_DOCUMENT_TYPE_NAME} {}", + input.elected_charter_id + )) + })?; + let leader = self.fetch_leader(&charter.document).await?; + build_resignation_request_document( + &contract, + &charter.document, + &leader, + &input, + fresh_entropy(), + ) + } + + /// The owner of `document`, the leader of a proposal or a charter. + async fn fetch_leader(&self, document: &Document) -> Result { + Identity::fetch(self, document.owner_id()) + .await? + .ok_or_else(|| Error::Generic(format!("leader {} not found", document.owner_id()))) + } +} + +fn fresh_entropy() -> Bytes32 { + Bytes32::random_with_rng(&mut StdRng::from_entropy()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::encrypted_for::{decrypt_property, EncryptedPropertyEnvelope}; + use dpp::dashcore::secp256k1::{PublicKey, Secp256k1}; + use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; + use dpp::identity::contract_bounds::ContractBounds; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::moderation_charter::MODERATION_CHARTERS_CONTRACT_ID; + use dpp::platform_value::BinaryData; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + fn key_pair(scalar: u8) -> (SecretKey, PublicKey) { + let secret_key = SecretKey::from_slice(&[scalar; 32]).expect("a valid scalar"); + let public_key = PublicKey::from_secret_key(&Secp256k1::signing_only(), &secret_key); + (secret_key, public_key) + } + + fn identity_with_key( + id: u8, + key_id: u32, + purpose: Purpose, + bound_to: &str, + public_key: &PublicKey, + ) -> Identity { + let key: IdentityPublicKey = IdentityPublicKeyV0 { + id: key_id, + purpose, + security_level: SecurityLevel::MEDIUM, + contract_bounds: Some(ContractBounds::SingleContractDocumentType { + id: MODERATION_CHARTERS_CONTRACT_ID, + document_type_name: bound_to.to_string(), + }), + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(public_key.serialize().to_vec()), + disabled_at: None, + } + .into(); + IdentityV0 { + id: Identifier::from([id; 32]), + public_keys: BTreeMap::from([(key_id, key)]), + balance: 0, + revision: 0, + } + .into() + } + + fn owned_by(id: u8, owner: &Identity) -> Document { + DocumentV0 { + id: Identifier::from([id; 32]), + owner_id: owner.id(), + ..Default::default() + } + .into() + } + + #[test] + fn should_build_requests_the_leader_decrypts_that_the_schema_accepts() { + let platform_version = PlatformVersion::latest(); + let contract = + load_system_data_contract(SystemDataContract::ModerationCharters, platform_version) + .expect("loads"); + let (leader_private_key, leader_public_key) = key_pair(0x42); + let leader = identity_with_key( + 1, + 6, + Purpose::DECRYPTION, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + &leader_public_key, + ); + let (writer_private_key, writer_public_key) = key_pair(0x21); + let writer = identity_with_key( + 2, + 3, + Purpose::ENCRYPTION, + JOIN_REQUEST_DOCUMENT_TYPE_NAME, + &writer_public_key, + ); + let proposal = owned_by(0x50, &leader); + let charter = owned_by(0x60, &leader); + + let join = build_join_request_document( + &contract, + &proposal, + &leader, + &JoinRequestInput { + submitted_charter_id: proposal.id(), + message: b"let me help".to_vec(), + writer: writer.clone(), + writer_encryption_key: writer_private_key, + }, + Bytes32::new([9; 32]), + ) + .expect("builds"); + let resignation = build_resignation_request_document( + &contract, + &charter, + &leader, + &ResignationRequestInput { + elected_charter_id: charter.id(), + message: b"moving on".to_vec(), + writer: writer.clone(), + writer_encryption_key: writer_private_key, + }, + Bytes32::new([8; 32]), + ) + .expect("builds"); + + for (request, reference, referred, message) in [ + ( + &join, + property_names::SUBMITTED_CHARTER_ID, + &proposal, + &b"let me help"[..], + ), + ( + &resignation, + property_names::ELECTED_CHARTER_ID, + &charter, + &b"moving on"[..], + ), + ] { + let document_type = contract + .document_type_for_name(&request.document_type_name) + .expect("exists"); + assert_eq!(request.document.owner_id(), writer.id()); + assert_eq!( + request.document.properties().get(reference), + Some(&Value::Identifier(referred.id().to_buffer())) + ); + assert_eq!( + EncryptedPropertyEnvelope::read( + document_type, + ENCRYPTED_MESSAGE, + &request.document + ) + .expect("reads"), + EncryptedPropertyEnvelope { + recipient_id: leader.id(), + recipient_key_id: 6, + sender_id: writer.id(), + sender_key_id: 3, + } + ); + assert_eq!( + decrypt_property( + document_type, + ENCRYPTED_MESSAGE, + request.document.properties(), + &leader_private_key, + &writer_public_key, + ) + .expect("the leader decrypts"), + message + ); + // The properties pass the schema and the consensus shape check + let properties = request.document.properties(); + assert!(document_type + .validate_encrypted_property_shapes(properties, platform_version) + .expect("runs") + .is_valid()); + let schema_result = contract + .validate_document_properties( + &request.document_type_name, + Value::from(properties.clone()), + platform_version, + ) + .expect("runs"); + assert!( + schema_result.is_valid(), + "{} fails its schema: {:?}", + request.document_type_name, + schema_result.errors + ); + } + + // A leader who does not own the proposal is refused + let someone = identity_with_key( + 3, + 6, + Purpose::DECRYPTION, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + &leader_public_key, + ); + assert!(build_join_request_document( + &contract, + &proposal, + &someone, + &JoinRequestInput { + submitted_charter_id: proposal.id(), + message: b"x".to_vec(), + writer, + writer_encryption_key: writer_private_key, + }, + Bytes32::new([9; 32]), + ) + .is_err()); + } +} diff --git a/packages/rs-sdk/src/platform/moderation_charters/team.rs b/packages/rs-sdk/src/platform/moderation_charters/team.rs new file mode 100644 index 00000000000..58028f00b18 --- /dev/null +++ b/packages/rs-sdk/src/platform/moderation_charters/team.rs @@ -0,0 +1,161 @@ +//! The team a seated charter acts with. + +use super::readers::{elected_charter_of, member_ids}; +use crate::platform::Document; +use crate::Error; +use dpp::document::DocumentV0Getters; +use dpp::moderation_charter::property_names; +use dpp::platform_value::Identifier; +use std::collections::BTreeSet; + +/// The team that moderates a contract: the seated charter's leader and its active members. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModerationTeam { + /// The seated `electedCharter` document. + pub elected_charter_id: Identifier, + /// The proposal the team runs on. + pub submitted_charter_id: Identifier, + /// The leader, the owner of the elected charter. + pub leader_id: Identifier, + /// The members besides the leader: the elected members and those the leader added, less + /// those the leader removed. + pub members: BTreeSet, +} + +impl ModerationTeam { + /// The team of the `electedCharter` document `elected_charter`, given the charter's + /// `addedModerator` and `removedModerator` documents: exactly + /// [`ElectedCharter::active_members`](dpp::moderation_charter::ElectedCharter::active_members) + /// over their `memberId`s. A document that names another charter is refused rather than + /// counted. + pub fn from_documents( + elected_charter: &Document, + added_moderators: &[Document], + removed_moderators: &[Document], + ) -> Result { + let charter = elected_charter_of(elected_charter)?; + let elected_charter_id = elected_charter.id(); + for change in added_moderators.iter().chain(removed_moderators) { + let names = change + .properties() + .get(property_names::ELECTED_CHARTER_ID) + .and_then(|value| value.to_identifier().ok()); + if names != Some(elected_charter_id) { + return Err(Error::Generic(format!( + "team change {} is not a change of the charter {elected_charter_id}", + change.id() + ))); + } + } + let leader_id = elected_charter.owner_id(); + let added = member_ids(added_moderators)?; + let removed = member_ids(removed_moderators)?; + Ok(Self { + elected_charter_id, + submitted_charter_id: charter.submitted_charter_id, + leader_id, + members: charter.active_members(leader_id, &added, &removed), + }) + } + + /// Whether `identity_id` is on the team: the leader or an active member. + pub fn contains(&self, identity_id: &Identifier) -> bool { + *identity_id == self.leader_id || self.members.contains(identity_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::document::DocumentV0; + use dpp::moderation_charter::ElectedCharter; + use dpp::platform_value::Value; + use std::collections::BTreeMap; + + fn id(byte: u8) -> Identifier { + Identifier::from([byte; 32]) + } + + const CHARTER: u8 = 0xC1; + const LEADER: u8 = 0x01; + + fn elected_charter(members: &[u8]) -> (Document, ElectedCharter) { + let charter = ElectedCharter { + target_contract_id: id(0xAA), + submitted_charter_id: id(0xBB), + members: members.iter().copied().map(id).collect(), + }; + let document = DocumentV0 { + id: id(CHARTER), + owner_id: id(LEADER), + properties: charter.to_document_properties(), + ..Default::default() + } + .into(); + (document, charter) + } + + fn change(document_id: u8, charter: u8, member: u8) -> Document { + DocumentV0 { + id: id(document_id), + owner_id: id(LEADER), + properties: BTreeMap::from([ + ( + property_names::ELECTED_CHARTER_ID.to_string(), + Value::Identifier(id(charter).to_buffer()), + ), + ( + property_names::MEMBER_ID.to_string(), + Value::Identifier(id(member).to_buffer()), + ), + ]), + ..Default::default() + } + .into() + } + + #[test] + fn should_combine_members_additions_and_removals_as_active_members_does() { + // Elected 2, 3, 4; added 5, 6; removed 3 (elected) and 6 (added) and 9 (never on it) + let (document, charter) = elected_charter(&[2, 3, 4]); + let added = [change(0x50, CHARTER, 5), change(0x51, CHARTER, 6)]; + let removed = [ + change(0x60, CHARTER, 3), + change(0x61, CHARTER, 6), + change(0x62, CHARTER, 9), + ]; + + let team = ModerationTeam::from_documents(&document, &added, &removed).expect("reads"); + + let expected = charter.active_members(id(LEADER), &[id(5), id(6)], &[id(3), id(6), id(9)]); + assert_eq!(team.members, expected); + assert_eq!(team.members, BTreeSet::from([id(2), id(4), id(5)])); + assert_eq!(team.leader_id, id(LEADER)); + assert_eq!(team.elected_charter_id, id(CHARTER)); + assert_eq!(team.submitted_charter_id, id(0xBB)); + assert!(team.contains(&id(LEADER))); + assert!(team.contains(&id(5))); + assert!(!team.contains(&id(3))); + } + + #[test] + fn should_be_the_leader_and_the_elected_members_without_changes() { + let (document, charter) = elected_charter(&[2, 3]); + let team = ModerationTeam::from_documents(&document, &[], &[]).expect("reads"); + assert_eq!(team.members, charter.active_members(id(LEADER), &[], &[])); + assert_eq!(team.members, BTreeSet::from([id(2), id(3)])); + + let (alone, _) = elected_charter(&[]); + assert!(ModerationTeam::from_documents(&alone, &[], &[]) + .expect("reads") + .members + .is_empty()); + } + + #[test] + fn should_refuse_a_team_change_of_another_charter() { + let (document, _) = elected_charter(&[2]); + assert!(ModerationTeam::from_documents(&document, &[change(0x50, 0xC2, 5)], &[]).is_err()); + assert!(ModerationTeam::from_documents(&document, &[], &[change(0x60, 0xC2, 2)]).is_err()); + } +} diff --git a/packages/rs-sdk/src/platform/system_data_contract.rs b/packages/rs-sdk/src/platform/system_data_contract.rs new file mode 100644 index 00000000000..66953140548 --- /dev/null +++ b/packages/rs-sdk/src/platform/system_data_contract.rs @@ -0,0 +1,24 @@ +//! Resolving a system data contract for the helpers built on one (DPNS, DashPay, moderation +//! charters). + +use crate::platform::{DataContract, Fetch, Identifier}; +use crate::{Error, Sdk}; +use dash_context_provider::ContextProvider; +use std::sync::Arc; + +impl Sdk { + /// The system data contract `contract_id`, from the context provider when it holds it at + /// the SDK's protocol version, fetched and proved otherwise. `None` when the network has no + /// such contract. + pub async fn fetch_system_data_contract( + &self, + contract_id: Identifier, + ) -> Result>, Error> { + if let Some(provider) = self.context_provider() { + if let Some(contract) = provider.get_data_contract(&contract_id, self.version())? { + return Ok(Some(contract)); + } + } + Ok(DataContract::fetch(self, contract_id).await?.map(Arc::new)) + } +} diff --git a/packages/wasm-sdk/Cargo.toml b/packages/wasm-sdk/Cargo.toml index 83a98bec8c9..b3c9c473da3 100644 --- a/packages/wasm-sdk/Cargo.toml +++ b/packages/wasm-sdk/Cargo.toml @@ -15,6 +15,7 @@ default = [ "wallet-utils-contract", "token-history-contract", "keywords-contract", + "moderation-charters-contract", "mocks", ] @@ -51,6 +52,10 @@ keywords-contract = [ "dash-sdk/keywords-contract", "rs-sdk-trusted-context-provider/keywords-contract", ] +moderation-charters-contract = [ + "dash-sdk/moderation-charters-contract", + "rs-sdk-trusted-context-provider/moderation-charters-contract", +] token_reward_explanations = ["dash-sdk/token_reward_explanations"] diff --git a/packages/wasm-sdk/src/encrypted_for.rs b/packages/wasm-sdk/src/encrypted_for.rs new file mode 100644 index 00000000000..45e6f3575cc --- /dev/null +++ b/packages/wasm-sdk/src/encrypted_for.rs @@ -0,0 +1,301 @@ +//! Encrypting and decrypting the byte properties a document type declares `encryptedFor` +//! (`WasmSdk.encryptDocumentProperty`, `WasmSdk.decryptDocumentProperty`, +//! `WasmSdk.encryptedPropertyEnvelope`): thin bindings over +//! `dash_sdk::platform::encrypted_for`, which reads the declaration from the contract. + +use crate::error::WasmSdkError; +use crate::sdk::WasmSdk; +use dash_sdk::dpp::dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; +use dash_sdk::dpp::data_contract::DataContract; +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::{IdentityPublicKey, KeyType}; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::platform::encrypted_for::{ + decrypt_property, encrypt_property, EncryptedPropertyEnvelope, EncryptionKeys, +}; +use dash_sdk::platform::{Document, Identifier}; +use std::collections::BTreeMap; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsValue; +use wasm_dpp2::data_contract::document::DocumentWasm; +use wasm_dpp2::identifier::IdentifierWasm; +use wasm_dpp2::identity::public_key::IdentityPublicKeyWasm; +use wasm_dpp2::serialization::conversions::platform_value_to_object; +use wasm_dpp2::utils::{try_from_options_with, try_to_bytes, try_to_string}; +use wasm_dpp2::{DataContractWasm, PrivateKeyWasm}; + +#[wasm_bindgen(typescript_custom_section)] +const ENCRYPTED_FOR_TS: &'static str = r#" +/** + * Options for `WasmSdk.encryptDocumentProperty`: encrypt a message into a byte property whose + * schema declares `encryptedFor`, the way the declaration says. + */ +export interface EncryptDocumentPropertyOptions { + /** The contract that declares the document type. */ + dataContract: DataContract; + /** The document type. */ + documentTypeName: string; + /** The property declaring `encryptedFor`, such as `encryptedMessage`; dotted when nested. */ + property: string; + /** The message. A string is encoded as UTF-8. */ + plaintext: Uint8Array | string; + /** The writer's key; its id goes into the declaration's `senderKey` property. */ + senderKey: IdentityPublicKey; + /** The private half of `senderKey`. */ + senderPrivateKey: PrivateKey; + /** The recipient's key; its id goes into the declaration's `recipientKey` property. */ + recipientKey: IdentityPublicKey; +} + +/** + * Options for `WasmSdk.decryptDocumentProperty`. The recipient decrypts with its private key + * and the sender's key; the sender can read its own message back with its private key and the + * recipient's key. + */ +export interface DecryptDocumentPropertyOptions { + /** The contract that declares the document's type. */ + dataContract: DataContract; + /** The document holding the ciphertext; its type is `document.documentTypeName`. */ + document: Document; + /** The property declaring `encryptedFor`. */ + property: string; + /** The private half of the recipient's key, the one `recipientKey` names. */ + recipientPrivateKey: PrivateKey; + /** The sender's key, the one `senderKey` names (see `encryptedPropertyEnvelope`). */ + senderKey: IdentityPublicKey; +} + +/** Options for `WasmSdk.encryptedPropertyEnvelope`. */ +export interface EncryptedPropertyEnvelopeOptions { + /** The contract that declares the document's type. */ + dataContract: DataContract; + /** The document; its type is `document.documentTypeName`. */ + document: Document; + /** The property declaring `encryptedFor`. */ + property: string; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "EncryptDocumentPropertyOptions")] + pub type EncryptDocumentPropertyOptionsJs; + + #[wasm_bindgen(typescript_type = "DecryptDocumentPropertyOptions")] + pub type DecryptDocumentPropertyOptionsJs; + + #[wasm_bindgen(typescript_type = "EncryptedPropertyEnvelopeOptions")] + pub type EncryptedPropertyEnvelopeOptionsJs; +} + +/// Whose keys an encrypted property of a stored document is under: what a reader fetches to +/// decrypt it. +#[wasm_bindgen(js_name = "EncryptedPropertyEnvelope")] +#[derive(Clone)] +pub struct EncryptedPropertyEnvelopeWasm(EncryptedPropertyEnvelope); + +#[wasm_bindgen(js_class = EncryptedPropertyEnvelope)] +impl EncryptedPropertyEnvelopeWasm { + /// The identity the bytes are encrypted for. + #[wasm_bindgen(getter = recipientId)] + pub fn recipient_id(&self) -> IdentifierWasm { + self.0.recipient_id.into() + } + + /// The id of the recipient's key, from the declaration's `recipientKey` property. + #[wasm_bindgen(getter = recipientKeyId)] + pub fn recipient_key_id(&self) -> u32 { + self.0.recipient_key_id + } + + /// The identity whose key `senderKeyId` names: the document owner, the writer that + /// encrypted the bytes. A document whose owner may have changed since (transferred or + /// sold) is refused rather than given a sender that did not encrypt it. + #[wasm_bindgen(getter = senderId)] + pub fn sender_id(&self) -> IdentifierWasm { + self.0.sender_id.into() + } + + /// The id of the sender's key, from the declaration's `senderKey` property. + #[wasm_bindgen(getter = senderKeyId)] + pub fn sender_key_id(&self) -> u32 { + self.0.sender_key_id + } +} + +/// The message of an options object's `field`: a Uint8Array, or a string encoded as UTF-8. +pub(crate) fn message_from_options( + options: &JsValue, + field: &str, +) -> Result, WasmSdkError> { + Ok(try_from_options_with(options, field, |value| { + if value.is_string() { + try_to_string(value, field).map(String::into_bytes) + } else { + try_to_bytes(value.clone(), field) + } + })?) +} + +/// The secp256k1 public key of an identity key, refused unless it is an `ECDSA_SECP256K1` key, +/// the only type the scheme's ECDH takes. +fn secp256k1_public_key(key: &IdentityPublicKey, field: &str) -> Result { + if key.key_type() != KeyType::ECDSA_SECP256K1 { + return Err(WasmSdkError::invalid_argument(format!( + "{field} must be an ECDSA_SECP256K1 key, not {:?}", + key.key_type() + ))); + } + PublicKey::from_slice(key.data().as_slice()) + .map_err(|e| WasmSdkError::invalid_argument(format!("{field}: {e}"))) +} + +fn secret_key_from_options(options: &JsValue, field: &str) -> Result { + Ok(PrivateKeyWasm::try_from_options(options, field)? + .inner() + .inner) +} + +/// `document` with its properties coerced to the types its document type declares: a document +/// built in JavaScript without its contract holds a byte array or an identifier as a list of +/// numbers. +fn sanitized(document_type: DocumentTypeRef<'_>, mut document: Document) -> Document { + document_type.sanitize_document_properties(document.properties_mut()); + document +} + +fn document_type_name_of( + contract: &DataContract, + document: &DocumentWasm, +) -> Result { + if Identifier::from(document.data_contract_id()) != contract.id() { + return Err(WasmSdkError::invalid_argument( + "document is not a document of dataContract", + )); + } + Ok(document.document_type_name()) +} + +#[wasm_bindgen] +impl WasmSdk { + /// Encrypts a message into a byte property whose schema declares `encryptedFor`, the way + /// the declaration says (the scheme dashpay contact requests use: ECDH on secp256k1, a + /// random 16-byte IV, AES-256-CBC). + /// + /// @returns The properties to set on the document: the ciphertext at `property` and the + /// two key ids at the declaration's `recipientKey` and `senderKey` paths, nested like the + /// paths. The recipient property is the caller's to set. + #[wasm_bindgen( + js_name = "encryptDocumentProperty", + unchecked_return_type = "Record" + )] + pub fn encrypt_document_property( + options: EncryptDocumentPropertyOptionsJs, + ) -> Result { + let options: JsValue = options.into(); + let contract: DataContract = + DataContractWasm::try_from_options(&options, "dataContract")?.into(); + let document_type_name: String = + try_from_options_with(&options, "documentTypeName", |value| { + try_to_string(value, "documentTypeName") + })?; + let property: String = try_from_options_with(&options, "property", |value| { + try_to_string(value, "property") + })?; + let plaintext = message_from_options(&options, "plaintext")?; + let sender_key: IdentityPublicKey = + IdentityPublicKeyWasm::try_from_options(&options, "senderKey")?.into(); + let sender_private_key = secret_key_from_options(&options, "senderPrivateKey")?; + let recipient_key: IdentityPublicKey = + IdentityPublicKeyWasm::try_from_options(&options, "recipientKey")?.into(); + + // A private key that is not the sender key's would write a message nobody can read + let sender_public_key = secp256k1_public_key(&sender_key, "senderKey")?; + if PublicKey::from_secret_key(&Secp256k1::signing_only(), &sender_private_key) + != sender_public_key + { + return Err(WasmSdkError::invalid_argument( + "senderPrivateKey is not the private half of senderKey", + )); + } + let keys = EncryptionKeys { + sender_key_id: sender_key.id(), + sender_private_key: &sender_private_key, + recipient_key_id: recipient_key.id(), + recipient_public_key: secp256k1_public_key(&recipient_key, "recipientKey")?, + }; + + let document_type = contract + .document_type_for_name(&document_type_name) + .map_err(|e| WasmSdkError::invalid_argument(e.to_string()))?; + let mut properties = BTreeMap::new(); + encrypt_property(document_type, &property, &plaintext, &keys, &mut properties)?; + Ok(platform_value_to_object(&Value::from(properties))?) + } + + /// Decrypts a byte property whose schema declares `encryptedFor`. + /// + /// The scheme carries no authentication tag: a wrong key is caught only by the padding + /// check, which it passes about once in 256 attempts, returning garbage. + /// + /// @returns The message. + #[wasm_bindgen(js_name = "decryptDocumentProperty")] + pub fn decrypt_document_property( + options: DecryptDocumentPropertyOptionsJs, + ) -> Result, WasmSdkError> { + let options: JsValue = options.into(); + let contract: DataContract = + DataContractWasm::try_from_options(&options, "dataContract")?.into(); + let document = DocumentWasm::try_from_options(&options, "document")?; + let property: String = try_from_options_with(&options, "property", |value| { + try_to_string(value, "property") + })?; + let recipient_private_key = secret_key_from_options(&options, "recipientPrivateKey")?; + let sender_key: IdentityPublicKey = + IdentityPublicKeyWasm::try_from_options(&options, "senderKey")?.into(); + + let document_type_name = document_type_name_of(&contract, &document)?; + let document_type = contract + .document_type_for_name(&document_type_name) + .map_err(|e| WasmSdkError::invalid_argument(e.to_string()))?; + let document = sanitized(document_type, document.into()); + Ok(decrypt_property( + document_type, + &property, + document.properties(), + &recipient_private_key, + &secp256k1_public_key(&sender_key, "senderKey")?, + )?) + } + + /// Reads whose keys an encrypted property of a document is under: the recipient and the + /// sender identities and the ids of their keys, which a reader fetches to decrypt it. A + /// document whose owner may have changed since it was written is refused: its sender key + /// id may name a previous owner's key. + #[wasm_bindgen(js_name = "encryptedPropertyEnvelope")] + pub fn encrypted_property_envelope( + options: EncryptedPropertyEnvelopeOptionsJs, + ) -> Result { + let options: JsValue = options.into(); + let contract: DataContract = + DataContractWasm::try_from_options(&options, "dataContract")?.into(); + let document = DocumentWasm::try_from_options(&options, "document")?; + let property: String = try_from_options_with(&options, "property", |value| { + try_to_string(value, "property") + })?; + + let document_type_name = document_type_name_of(&contract, &document)?; + let document_type = contract + .document_type_for_name(&document_type_name) + .map_err(|e| WasmSdkError::invalid_argument(e.to_string()))?; + let document = sanitized(document_type, document.into()); + Ok( + EncryptedPropertyEnvelope::read(document_type, &property, &document) + .map(EncryptedPropertyEnvelopeWasm)?, + ) + } +} diff --git a/packages/wasm-sdk/src/error.rs b/packages/wasm-sdk/src/error.rs index bebc02a33d2..8a66fa95e56 100644 --- a/packages/wasm-sdk/src/error.rs +++ b/packages/wasm-sdk/src/error.rs @@ -1,4 +1,5 @@ use dash_sdk::dpp::ProtocolError; +use dash_sdk::platform::encrypted_for::EncryptedForError; use dash_sdk::{error::StateTransitionBroadcastError, Error as SdkError}; use rs_dapi_client::CanRetry; use wasm_bindgen::prelude::wasm_bindgen; @@ -45,6 +46,13 @@ pub enum WasmSdkErrorKind { /// (vs `Generic`) to detect "the API exists but execution waits /// on a follow-up" without parsing the message. NotImplemented, + /// An `encryptedFor` property did not decrypt: the keys are not the ones it was encrypted + /// with, or the bytes are corrupt. + DecryptionFailed, + /// No key of an identity can serve as the recipient or sender key of an `encryptedFor` + /// property: none meets the schema's `keyRequirements`, or the private key given is not + /// one of the identity's keys. + EncryptionKeyNotFound, } /// Structured error surfaced to JS consumers @@ -248,9 +256,21 @@ impl From for WasmSdkError { None, retriable, ), + EncryptedFor(e) => e.into(), } } } + +impl From for WasmSdkError { + fn from(err: EncryptedForError) -> Self { + let kind = match err { + EncryptedForError::DecryptionFailed => WasmSdkErrorKind::DecryptionFailed, + EncryptedForError::NoSuitableKey { .. } => WasmSdkErrorKind::EncryptionKeyNotFound, + _ => WasmSdkErrorKind::InvalidArgument, + }; + Self::new(kind, err.to_string(), None, false) + } +} impl From for WasmSdkError { fn from(err: ProtocolError) -> Self { Self::new(WasmSdkErrorKind::Protocol, err.to_string(), None, false) @@ -326,6 +346,8 @@ impl WasmSdkError { K::SerializationError => "SerializationError", K::NotFound => "NotFound", K::NotImplemented => "NotImplemented", + K::DecryptionFailed => "DecryptionFailed", + K::EncryptionKeyNotFound => "EncryptionKeyNotFound", } .to_string() } diff --git a/packages/wasm-sdk/src/lib.rs b/packages/wasm-sdk/src/lib.rs index caca5c8457b..b0b6583324b 100644 --- a/packages/wasm-sdk/src/lib.rs +++ b/packages/wasm-sdk/src/lib.rs @@ -4,8 +4,10 @@ mod browser_storage; pub mod context_provider; mod contract_store; pub mod dpns; +pub mod encrypted_for; pub mod error; pub mod logging; +pub mod moderation_charters; mod protocol_version_store; pub mod queries; pub mod sdk; diff --git a/packages/wasm-sdk/src/moderation_charters.rs b/packages/wasm-sdk/src/moderation_charters.rs new file mode 100644 index 00000000000..6971e22cc74 --- /dev/null +++ b/packages/wasm-sdk/src/moderation_charters.rs @@ -0,0 +1,414 @@ +//! The moderation charters system contract: who moderates a contract that declares elected +//! moderation, the proposals and join requests behind it, and the two requests members send the +//! leader. Thin bindings over `dash_sdk::platform::moderation_charters`, where every read is a +//! proved document query on the system contract. + +use crate::encrypted_for::message_from_options; +use crate::error::WasmSdkError; +use crate::sdk::WasmSdk; +use dash_sdk::dpp::moderation_charter::{ + ELECTED_CHARTER_DOCUMENT_TYPE_NAME, JOIN_REQUEST_DOCUMENT_TYPE_NAME, + MODERATION_CHARTERS_CONTRACT_ID, RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, +}; +use dash_sdk::platform::moderation_charters::{ + CharterDocumentsPage, JoinRequestInput, ModerationCharterRequest, ModerationTeam, + ResignationRequestInput, +}; +use dash_sdk::platform::{Document, Fetch, Identifier, Identity}; +use drive_proof_verifier::types::Documents; +use js_sys::{Array, Map}; +use wasm_bindgen::prelude::wasm_bindgen; +use wasm_bindgen::JsValue; +use wasm_dpp2::data_contract::document::DocumentWasm; +use wasm_dpp2::identifier::{IdentifierLikeJs, IdentifierWasm}; +use wasm_dpp2::identity::IdentityWasm; +use wasm_dpp2::utils::{try_from_options_optional_with, try_to_u32}; +use wasm_dpp2::PrivateKeyWasm; + +#[wasm_bindgen(typescript_custom_section)] +const MODERATION_CHARTERS_TS: &'static str = r#" +/** One page of the proposals for a contract (`getModerationSubmittedCharters`). */ +export interface ModerationSubmittedChartersQuery { + /** The contract the proposals are for. */ + targetContractId: IdentifierLike; + /** Continue after this proposal; omit for the first page. */ + startAfter?: IdentifierLike; + /** + * Maximum number of proposals to return, 1 to 100. + * @default 100 + */ + limit?: number; +} + +/** One page of the join requests for a proposal (`getModerationJoinRequests`). */ +export interface ModerationJoinRequestsQuery { + /** The proposal, a `submittedCharter` document. */ + submittedCharterId: IdentifierLike; + /** Continue after this join request; omit for the first page. */ + startAfter?: IdentifierLike; + /** + * Maximum number of join requests to return, 1 to 100. + * @default 100 + */ + limit?: number; +} + +/** + * Options for `buildModerationJoinRequest`: an identity's offer to serve on the team of a + * proposal, with a message only the proposal's leader can read. + */ +export interface ModerationJoinRequestOptions { + /** The proposal, a `submittedCharter` document. */ + submittedCharterId: IdentifierLike; + /** Why the writer wants to join, at most 1023 bytes. A string is encoded as UTF-8. */ + message: Uint8Array | string; + /** The identity offering to serve, the request's owner: an `Identity`, or its id to fetch it. */ + writer: Identity | IdentifierLike; + /** The private half of the writer's encryption key bound to `joinRequest`. */ + writerEncryptionKey: PrivateKey; +} + +/** + * Options for `buildModerationResignationRequest`: a member's request to leave a seated team, + * with a message only the leader can read. The leader acts on it with a removal; deleting the + * document withdraws it. + */ +export interface ModerationResignationRequestOptions { + /** The seated charter, an `electedCharter` document. */ + electedCharterId: IdentifierLike; + /** Why the member leaves, at most 1023 bytes. A string is encoded as UTF-8. */ + message: Uint8Array | string; + /** The member, the request's owner: an `Identity`, or its id to fetch it. */ + writer: Identity | IdentifierLike; + /** The private half of the writer's encryption key bound to `joinRequest`. */ + writerEncryptionKey: PrivateKey; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "ModerationSubmittedChartersQuery")] + pub type ModerationSubmittedChartersQueryJs; + + #[wasm_bindgen(typescript_type = "ModerationJoinRequestsQuery")] + pub type ModerationJoinRequestsQueryJs; + + #[wasm_bindgen(typescript_type = "ModerationJoinRequestOptions")] + pub type ModerationJoinRequestOptionsJs; + + #[wasm_bindgen(typescript_type = "ModerationResignationRequestOptions")] + pub type ModerationResignationRequestOptionsJs; +} + +/// The team that moderates a contract: the seated charter's leader and its active members. +#[wasm_bindgen(js_name = "ModerationTeam")] +#[derive(Clone)] +pub struct ModerationTeamWasm(ModerationTeam); + +#[wasm_bindgen(js_class = ModerationTeam)] +impl ModerationTeamWasm { + /// The seated `electedCharter` document. + #[wasm_bindgen(getter = electedCharterId)] + pub fn elected_charter_id(&self) -> IdentifierWasm { + self.0.elected_charter_id.into() + } + + /// The proposal the team runs on. + #[wasm_bindgen(getter = submittedCharterId)] + pub fn submitted_charter_id(&self) -> IdentifierWasm { + self.0.submitted_charter_id.into() + } + + /// The leader, the owner of the elected charter. + #[wasm_bindgen(getter = leaderId)] + pub fn leader_id(&self) -> IdentifierWasm { + self.0.leader_id.into() + } + + /// The members besides the leader, in id order: the elected members and those the leader + /// added, less those the leader removed. + #[wasm_bindgen(getter = members, unchecked_return_type = "Identifier[]")] + pub fn members(&self) -> Array { + self.0 + .members + .iter() + .map(|id| JsValue::from(IdentifierWasm::from(*id))) + .collect() + } + + /// Whether `identityId` is on the team: the leader or an active member. + #[wasm_bindgen(js_name = "contains")] + pub fn contains( + &self, + #[wasm_bindgen(js_name = "identityId")] identity_id: IdentifierLikeJs, + ) -> Result { + let identity_id: Identifier = identity_id.try_into()?; + Ok(self.0.contains(&identity_id)) + } +} + +/// The identifier `id_field` of a page query and the page it asks for. Each field is read as +/// an `IdentifierLike` on its own: an `Identifier` instance does not survive the serde +/// conversion a whole-object deserialization goes through. +fn page_query( + query: &JsValue, + id_field: &str, +) -> Result<(Identifier, CharterDocumentsPage), WasmSdkError> { + if query.is_undefined() || query.is_null() { + return Err(WasmSdkError::invalid_argument("Query object is required")); + } + let id = IdentifierWasm::try_from_options(query, id_field)?.into(); + let start_after = + IdentifierWasm::try_from_optional_options(query, "startAfter")?.map(Identifier::from); + let limit = try_from_options_optional_with(query, "limit", |value| try_to_u32(value, "limit"))?; + Ok((id, CharterDocumentsPage { limit, start_after })) +} + +fn document_wasm(document: Document, document_type_name: &str) -> DocumentWasm { + DocumentWasm::new( + document, + MODERATION_CHARTERS_CONTRACT_ID, + document_type_name.to_string(), + None, + ) +} + +fn documents_map(documents: Documents, document_type_name: &str) -> Map { + let map = Map::new(); + for (id, document) in documents { + let key: JsValue = IdentifierWasm::from(id).to_base58().into(); + match document { + Some(document) => { + map.set( + &key, + &JsValue::from(document_wasm(document, document_type_name)), + ); + } + None => { + map.set(&key, &JsValue::NULL); + } + } + } + map +} + +fn request_wasm(request: ModerationCharterRequest) -> DocumentWasm { + DocumentWasm::new( + request.document, + MODERATION_CHARTERS_CONTRACT_ID, + request.document_type_name, + Some(request.entropy.0), + ) +} + +impl WasmSdk { + /// The writer of a request: the `Identity` given, or the identity fetched by the id given. + async fn writer_from_options(&self, options: &JsValue) -> Result { + let value = js_sys::Reflect::get(options, &JsValue::from_str("writer")) + .map_err(|_| WasmSdkError::invalid_argument("writer is required"))?; + if value.is_undefined() || value.is_null() { + return Err(WasmSdkError::invalid_argument("writer is required")); + } + if let Ok(identity) = IdentityWasm::try_from(&value) { + return Ok(identity.into()); + } + let id: Identifier = IdentifierWasm::try_from(&value) + .map_err(|_| { + WasmSdkError::invalid_argument("writer must be an Identity or an identity id") + })? + .into(); + Identity::fetch(self.as_ref(), id) + .await? + .ok_or_else(|| WasmSdkError::not_found(format!("writer identity {id} not found"))) + } +} + +#[wasm_bindgen] +impl WasmSdk { + /// The seated charter of a contract: the `electedCharter` whose `targetContractId` is it. + /// Only a contest's winner is ever stored, so there is at most one. + /// + /// @param targetContractId - The moderated contract. + /// @returns The `electedCharter` document, or undefined when the contract has none. + #[wasm_bindgen(js_name = "getModerationSeatedCharter")] + pub async fn get_moderation_seated_charter( + &self, + #[wasm_bindgen(js_name = "targetContractId")] target_contract_id: IdentifierLikeJs, + ) -> Result, WasmSdkError> { + let target_contract_id: Identifier = target_contract_id.try_into()?; + Ok(self + .as_ref() + .fetch_seated_charter(target_contract_id) + .await? + .map(|seated| document_wasm(seated.document, ELECTED_CHARTER_DOCUMENT_TYPE_NAME))) + } + + /// A proposal by id, such as a seated charter's `submittedCharterId`. + /// + /// @param submittedCharterId - The `submittedCharter` document. + /// @returns The proposal, or undefined when there is none. + #[wasm_bindgen(js_name = "getModerationSubmittedCharter")] + pub async fn get_moderation_submitted_charter( + &self, + #[wasm_bindgen(js_name = "submittedCharterId")] submitted_charter_id: IdentifierLikeJs, + ) -> Result, WasmSdkError> { + let submitted_charter_id: Identifier = submitted_charter_id.try_into()?; + Ok(self + .as_ref() + .fetch_submitted_charter(submitted_charter_id) + .await? + .map(|document| document_wasm(document, SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME))) + } + + /// The team that moderates a contract: the seated charter's leader plus its elected + /// members and the members the leader added, less those the leader removed. + /// + /// @param targetContractId - The moderated contract. + /// @returns The team, or undefined when the contract has no seated charter. + #[wasm_bindgen(js_name = "getModerationTeam")] + pub async fn get_moderation_team( + &self, + #[wasm_bindgen(js_name = "targetContractId")] target_contract_id: IdentifierLikeJs, + ) -> Result, WasmSdkError> { + let target_contract_id: Identifier = target_contract_id.try_into()?; + Ok(self + .as_ref() + .fetch_moderation_team(target_contract_id) + .await? + .map(ModerationTeamWasm)) + } + + /// One page of the proposals for a contract, in filing order. + /// + /// @returns The `submittedCharter` documents by id; pass the last id as `startAfter` for + /// the next page. + #[wasm_bindgen( + js_name = "getModerationSubmittedCharters", + unchecked_return_type = "Map" + )] + pub async fn get_moderation_submitted_charters( + &self, + query: ModerationSubmittedChartersQueryJs, + ) -> Result { + let (target_contract_id, page) = page_query(&query.into(), "targetContractId")?; + let documents = self + .as_ref() + .fetch_submitted_charters(target_contract_id, page) + .await?; + Ok(documents_map( + documents, + SUBMITTED_CHARTER_DOCUMENT_TYPE_NAME, + )) + } + + /// One page of the join requests for a proposal, in the order of their owners' ids. + /// + /// @returns The `joinRequest` documents by id; pass the last id as `startAfter` for the + /// next page. + #[wasm_bindgen( + js_name = "getModerationJoinRequests", + unchecked_return_type = "Map" + )] + pub async fn get_moderation_join_requests( + &self, + query: ModerationJoinRequestsQueryJs, + ) -> Result { + let (submitted_charter_id, page) = page_query(&query.into(), "submittedCharterId")?; + let documents = self + .as_ref() + .fetch_join_requests(submitted_charter_id, page) + .await?; + Ok(documents_map(documents, JOIN_REQUEST_DOCUMENT_TYPE_NAME)) + } + + /// The resignation requests for a seated charter the leader has not acted on: those whose + /// writer the charter has no removal for. A withdrawn request is deleted, so it is not + /// among them either. + /// + /// @param electedCharterId - The seated charter, an `electedCharter` document. + #[wasm_bindgen( + js_name = "getModerationPendingResignationRequests", + unchecked_return_type = "Document[]" + )] + pub async fn get_moderation_pending_resignation_requests( + &self, + #[wasm_bindgen(js_name = "electedCharterId")] elected_charter_id: IdentifierLikeJs, + ) -> Result { + let elected_charter_id: Identifier = elected_charter_id.try_into()?; + Ok(self + .as_ref() + .fetch_pending_resignation_requests(elected_charter_id) + .await? + .into_iter() + .map(|document| { + JsValue::from(document_wasm( + document, + RESIGNATION_REQUEST_DOCUMENT_TYPE_NAME, + )) + }) + .collect()) + } + + /// Builds a join request: the message encrypted to the proposal leader's decryption key + /// bound to `submittedCharter`, from the writer's encryption key bound to `joinRequest`, + /// with `recipientId`, `recipientKeyId` and `senderKeyId` set to match. Fetches the + /// proposal and the leader. + /// + /// @returns The document, with its entropy set, to pass to `documentCreate`. + #[wasm_bindgen(js_name = "buildModerationJoinRequest")] + pub async fn build_moderation_join_request( + &self, + options: ModerationJoinRequestOptionsJs, + ) -> Result { + let options: JsValue = options.into(); + let submitted_charter_id: Identifier = + IdentifierWasm::try_from_options(&options, "submittedCharterId")?.into(); + let message = message_from_options(&options, "message")?; + let writer_encryption_key = + PrivateKeyWasm::try_from_options(&options, "writerEncryptionKey")? + .inner() + .inner; + let writer = self.writer_from_options(&options).await?; + let request = self + .as_ref() + .build_join_request(JoinRequestInput { + submitted_charter_id, + message, + writer, + writer_encryption_key, + }) + .await?; + Ok(request_wasm(request)) + } + + /// Builds a resignation request from a seated charter, encrypted to its leader as a join + /// request is. Fetches the charter and the leader. + /// + /// @returns The document, with its entropy set, to pass to `documentCreate`. + #[wasm_bindgen(js_name = "buildModerationResignationRequest")] + pub async fn build_moderation_resignation_request( + &self, + options: ModerationResignationRequestOptionsJs, + ) -> Result { + let options: JsValue = options.into(); + let elected_charter_id: Identifier = + IdentifierWasm::try_from_options(&options, "electedCharterId")?.into(); + let message = message_from_options(&options, "message")?; + let writer_encryption_key = + PrivateKeyWasm::try_from_options(&options, "writerEncryptionKey")? + .inner() + .inner; + let writer = self.writer_from_options(&options).await?; + let request = self + .as_ref() + .build_resignation_request(ResignationRequestInput { + elected_charter_id, + message, + writer, + writer_encryption_key, + }) + .await?; + Ok(request_wasm(request)) + } +} diff --git a/packages/wasm-sdk/tests/unit/encrypted-for.spec.ts b/packages/wasm-sdk/tests/unit/encrypted-for.spec.ts new file mode 100644 index 00000000000..d23a8d28edc --- /dev/null +++ b/packages/wasm-sdk/tests/unit/encrypted-for.spec.ts @@ -0,0 +1,206 @@ +/** + * `WasmSdk.encryptDocumentProperty`, `decryptDocumentProperty` and + * `encryptedPropertyEnvelope`: the `encryptedFor` helpers, keyed off the + * contract's declaration, run locally with no connection. + */ +import { expect } from './helpers/chai.ts'; +import init, * as sdk from '../../dist/sdk.compressed.js'; + +const ownerId = '11111111111111111111111111111111'; + +const identifier = { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', +}; + +const keyId = { type: 'integer', minimum: 0, maximum: 4294967295 }; + +/** A `secret` whose message is encrypted to `recipientId`. */ +const schemas = { + secret: { + type: 'object', + properties: { + recipientId: { ...identifier, position: 0 }, + recipientKeyId: { ...keyId, position: 1 }, + senderKeyId: { ...keyId, position: 2 }, + encryptedMessage: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 1040, + position: 3, + encryptedFor: { + recipient: 'recipientId', + recipientKey: 'recipientKeyId', + senderKey: 'senderKeyId', + scheme: 'ecdh-secp256k1-aes256-cbc', + }, + }, + }, + required: ['recipientId', 'recipientKeyId', 'senderKeyId', 'encryptedMessage'], + additionalProperties: false, + }, +}; + +const ENCRYPTION = 1; +const DECRYPTION = 2; +const MEDIUM = 3; +const ECDSA_SECP256K1 = 0; + +const leaderId = new Uint8Array(32).fill(7); + +describe('encryptedFor helpers', () => { + let contract: sdk.DataContract; + let senderPrivateKey: sdk.PrivateKey; + let recipientPrivateKey: sdk.PrivateKey; + let senderKey: sdk.IdentityPublicKey; + let recipientKey: sdk.IdentityPublicKey; + + function identityKey(id: number, purpose: number, privateKey: sdk.PrivateKey) { + return new sdk.IdentityPublicKey({ + keyId: id, + purpose, + securityLevel: MEDIUM, + keyType: ECDSA_SECP256K1, + isReadOnly: false, + data: privateKey.getPublicKey().toBytes(), + }); + } + + function encrypt(plaintext: Uint8Array | string) { + return sdk.WasmSdk.encryptDocumentProperty({ + dataContract: contract, + documentTypeName: 'secret', + property: 'encryptedMessage', + plaintext, + senderKey, + senderPrivateKey, + recipientKey, + }) as Record; + } + + function documentWith(fields: Record) { + return new sdk.Document({ + properties: { recipientId: leaderId, ...fields }, + documentTypeName: 'secret', + dataContractId: contract.id, + ownerId, + }); + } + + before(async () => { + await init(); + contract = new sdk.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas, + definitions: null, + fullValidation: true, + platformVersion: new sdk.PlatformVersion(14), + }); + senderPrivateKey = sdk.PrivateKey.fromHex('21'.repeat(32), 'testnet'); + recipientPrivateKey = sdk.PrivateKey.fromHex('42'.repeat(32), 'testnet'); + senderKey = identityKey(4, ENCRYPTION, senderPrivateKey); + recipientKey = identityKey(2, DECRYPTION, recipientPrivateKey); + }); + + it('should decrypt what it encrypts and fill the key id properties', () => { + const message = 'I would like to help moderate'; + const fields = encrypt(message); + + expect(fields.recipientKeyId).to.equal(2); + expect(fields.senderKeyId).to.equal(4); + expect(fields.encryptedMessage).to.be.instanceOf(Uint8Array); + + const decrypted = sdk.WasmSdk.decryptDocumentProperty({ + dataContract: contract, + document: documentWith(fields), + property: 'encryptedMessage', + recipientPrivateKey, + senderKey, + }); + expect(new TextDecoder().decode(decrypted)).to.equal(message); + + // ECDH is symmetric: the writer reads its own message back + const bySender = sdk.WasmSdk.decryptDocumentProperty({ + dataContract: contract, + document: documentWith(fields), + property: 'encryptedMessage', + recipientPrivateKey: senderPrivateKey, + senderKey: recipientKey, + }); + expect(new TextDecoder().decode(bySender)).to.equal(message); + }); + + it('should write an IV plus whole blocks for every plaintext length', () => { + [0, 1, 15, 16, 17, 500, 1023].forEach((length) => { + const fields = encrypt(new Uint8Array(length).fill(0x61)); + const ciphertext = fields.encryptedMessage as Uint8Array; + expect(ciphertext.length, `${length} bytes`).to.equal(16 + (Math.floor(length / 16) + 1) * 16); + }); + }); + + it('should read whose keys the property is under', () => { + const envelope = sdk.WasmSdk.encryptedPropertyEnvelope({ + dataContract: contract, + document: documentWith(encrypt('hello')), + property: 'encryptedMessage', + }); + + expect(Array.from(envelope.recipientId.toBytes())).to.deep.equal(Array.from(leaderId)); + expect(envelope.recipientKeyId).to.equal(2); + // The schema declares no key reference, so the sender is the writer + expect(envelope.senderId.toBase58()).to.equal(ownerId); + expect(envelope.senderKeyId).to.equal(4); + }); + + it('should not recover the message with a wrong key', () => { + const message = 'only the leader reads this'; + const document = documentWith(encrypt(message)); + const someoneElse = sdk.PrivateKey.fromHex('43'.repeat(32), 'testnet'); + + // The padding check catches a wrong key but about once in 256 tries, and then the + // bytes are garbage: either way the message does not come back. + let recovered: string | undefined; + try { + recovered = new TextDecoder().decode(sdk.WasmSdk.decryptDocumentProperty({ + dataContract: contract, + document, + property: 'encryptedMessage', + recipientPrivateKey: someoneElse, + senderKey, + })); + } catch (e) { + expect((e as Error).message).to.match(/decryption failed/); + expect((e as sdk.WasmSdkError).kind).to.equal(sdk.WasmSdkErrorKind.DecryptionFailed); + } + expect(recovered).to.not.equal(message); + }); + + it('should refuse a private key that is not the sender key', () => { + expect(() => sdk.WasmSdk.encryptDocumentProperty({ + dataContract: contract, + documentTypeName: 'secret', + property: 'encryptedMessage', + plaintext: 'x', + senderKey, + senderPrivateKey: recipientPrivateKey, + recipientKey, + })).to.throw(/senderPrivateKey is not the private half of senderKey/); + }); + + it('should refuse a property that declares no encryptedFor', () => { + expect(() => sdk.WasmSdk.encryptDocumentProperty({ + dataContract: contract, + documentTypeName: 'secret', + property: 'recipientId', + plaintext: 'x', + senderKey, + senderPrivateKey, + recipientKey, + })).to.throw(/declares no encryptedFor/); + }); +}); diff --git a/packages/wasm-sdk/tests/unit/moderation-charters.spec.ts b/packages/wasm-sdk/tests/unit/moderation-charters.spec.ts new file mode 100644 index 00000000000..8bd4cfa24da --- /dev/null +++ b/packages/wasm-sdk/tests/unit/moderation-charters.spec.ts @@ -0,0 +1,63 @@ +/** + * The page queries of `getModerationSubmittedCharters` and `getModerationJoinRequests` take + * `IdentifierLike` ids and cursors. Each field is parsed on its own before anything is + * fetched, and `limit` is parsed last, so a query whose only fault is its limit fails on the + * limit: its identifiers were accepted, and nothing reached the network. + */ +import { expect } from './helpers/chai.ts'; +import init, * as sdk from '../../dist/sdk.compressed.js'; + +const contractId = 'EG7RGfV8fDTayC2FyVr8HwdpJh3fXDbVztcfE94UmN88'; + +async function rejectionOf(promise: Promise): Promise { + try { + await promise; + } catch (e) { + return e as Error; + } + throw new Error('expected the query to be refused'); +} + +describe('moderation charters page queries', () => { + let client: sdk.WasmSdk; + + before(async () => { + await init(); + client = await sdk.WasmSdkBuilder.testnet().build(); + }); + + it('should accept Identifier instances as the proposal query id and cursor', async () => { + const error = await rejectionOf(client.getModerationSubmittedCharters({ + targetContractId: new sdk.Identifier(contractId), + startAfter: new sdk.Identifier(contractId), + limit: 1.5, + })); + expect(error.message).to.match(/'limit' must be an integer/); + }); + + it('should accept Identifier instances as the join request query id and cursor', async () => { + const error = await rejectionOf(client.getModerationJoinRequests({ + submittedCharterId: new sdk.Identifier(contractId), + startAfter: new sdk.Identifier(contractId), + limit: 1.5, + })); + expect(error.message).to.match(/'limit' must be an integer/); + }); + + it('should accept base58 strings as the query id and cursor', async () => { + const error = await rejectionOf(client.getModerationJoinRequests({ + submittedCharterId: contractId, + startAfter: contractId, + limit: 1.5, + })); + expect(error.message).to.match(/'limit' must be an integer/); + }); + + it('should refuse a query id that is not an identifier before the limit', async () => { + const error = await rejectionOf(client.getModerationSubmittedCharters({ + targetContractId: 42 as never, + limit: 1.5, + })); + expect(error.message).to.not.match(/limit/); + }); +});